diff --git a/package-lock.json b/package-lock.json index 6ee2827..284b274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -61,6 +62,7 @@ "tailwindcss-animate": "^1.0.7", "three": "^0.177.0", "vaul": "^0.9.3", + "xlsx": "^0.18.5", "zod": "^3.23.8", "zustand": "^4.4.7" }, @@ -3421,6 +3423,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3714,6 +3725,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4167,6 +4191,15 @@ } } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4204,6 +4237,18 @@ "dev": true, "license": "MIT" }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4885,6 +4930,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6106,6 +6160,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6893,6 +6953,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -7496,6 +7568,24 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7622,6 +7712,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yaml": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", diff --git a/package.json b/package.json index 19a7104..6f68b29 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "input-otp": "^1.2.4", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", + "papaparse": "^5.5.3", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -64,6 +65,7 @@ "tailwindcss-animate": "^1.0.7", "three": "^0.177.0", "vaul": "^0.9.3", + "xlsx": "^0.18.5", "zod": "^3.23.8", "zustand": "^4.4.7" }, diff --git a/src/App.tsx b/src/App.tsx index ec0e075..2f56d81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import Categorias from "./pages/Categorias"; import AdminFAQ from "./pages/AdminFAQ"; import Assinatura from "./pages/Assinatura"; import AvisosContas from "./pages/AvisosContas"; +import ImportarExtrato from "./pages/ImportarExtrato"; const queryClient = new QueryClient(); @@ -81,6 +82,7 @@ function App() { } /> } /> } /> + } /> {/* Rota 404 */} diff --git a/src/components/importacao/ContaBancariaSelect.tsx b/src/components/importacao/ContaBancariaSelect.tsx new file mode 100644 index 0000000..ac028fe --- /dev/null +++ b/src/components/importacao/ContaBancariaSelect.tsx @@ -0,0 +1,69 @@ +import { ContaBancaria } from '@/types/importacaoTypes'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Button } from '@/components/ui/button'; +import { Plus, Building2 } from 'lucide-react'; +import { Card } from '@/components/ui/card'; + +interface ContaBancariaSelectProps { + contas: ContaBancaria[]; + value?: string; + onChange: (value: string) => void; + onNovaConta: () => void; + isLoading?: boolean; +} + +export const ContaBancariaSelect = ({ + contas, + value, + onChange, + onNovaConta, + isLoading +}: ContaBancariaSelectProps) => { + return ( + +
+
+ +

Selecione a Conta Bancária

+
+ +
+ +
+ + + {contas.length === 0 && ( +

+ Você ainda não tem contas cadastradas. Clique em "Nova Conta" para adicionar. +

+ )} +
+
+ ); +}; diff --git a/src/components/importacao/FileUpload.tsx b/src/components/importacao/FileUpload.tsx new file mode 100644 index 0000000..fa10ecd --- /dev/null +++ b/src/components/importacao/FileUpload.tsx @@ -0,0 +1,111 @@ +import { useRef, useState } from 'react'; +import { Upload, File } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface FileUploadProps { + onFileSelect: (file: File) => void; + isProcessing?: boolean; +} + +export const FileUpload = ({ onFileSelect, isProcessing }: FileUploadProps) => { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const file = e.dataTransfer.files[0]; + if (file) { + handleFile(file); + } + }; + + const handleFileInput = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleFile(file); + } + }; + + const handleFile = (file: File) => { + setSelectedFile(file); + onFileSelect(file); + }; + + return ( +
+
!isProcessing && inputRef.current?.click()} + > + + + + + {selectedFile ? ( +
+
+ + {selectedFile.name} +
+

+ {(selectedFile.size / 1024).toFixed(2)} KB +

+
+ ) : ( + <> +

+ Arraste seu extrato aqui +

+

+ ou clique para selecionar +

+

+ Formatos aceitos: OFX, CSV, XLSX, XLS (máx. 10MB) +

+ + )} +
+ + {selectedFile && !isProcessing && ( + + )} +
+ ); +}; diff --git a/src/components/importacao/NovaContaDialog.tsx b/src/components/importacao/NovaContaDialog.tsx new file mode 100644 index 0000000..b60f3d5 --- /dev/null +++ b/src/components/importacao/NovaContaDialog.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { ContaBancaria } from '@/types/importacaoTypes'; + +interface NovaContaDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (conta: Partial) => Promise; +} + +const bancos = [ + 'Banco do Brasil', + 'Bradesco', + 'Caixa Econômica', + 'Itaú', + 'Santander', + 'Nubank', + 'Inter', + 'C6 Bank', + 'BTG Pactual', + 'Sicoob', + 'Sicredi', + 'Outro' +]; + +export const NovaContaDialog = ({ open, onOpenChange, onSave }: NovaContaDialogProps) => { + const [nome, setNome] = useState(''); + const [banco, setBanco] = useState(''); + const [tipo, setTipo] = useState('corrente'); + const [saldoInicial, setSaldoInicial] = useState('0'); + const [isSaving, setIsSaving] = useState(false); + + const handleSave = async () => { + if (!nome) return; + + setIsSaving(true); + try { + await onSave({ + nome, + banco, + tipo, + saldo_inicial: parseFloat(saldoInicial) || 0 + }); + + // Limpar formulário + setNome(''); + setBanco(''); + setTipo('corrente'); + setSaldoInicial('0'); + onOpenChange(false); + } catch (error) { + console.error('Erro ao salvar conta:', error); + } finally { + setIsSaving(false); + } + }; + + return ( + + + + Nova Conta Bancária + + +
+
+ + setNome(e.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + setSaldoInicial(e.target.value)} + /> +
+
+ + + + + +
+
+ ); +}; diff --git a/src/components/importacao/PreviewTransacoes.tsx b/src/components/importacao/PreviewTransacoes.tsx new file mode 100644 index 0000000..f296c1f --- /dev/null +++ b/src/components/importacao/PreviewTransacoes.tsx @@ -0,0 +1,175 @@ +import { TransacaoImportada } from '@/types/importacaoTypes'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { AlertCircle, TrendingUp, TrendingDown, Copy } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +interface PreviewTransacoesProps { + transacoes: TransacaoImportada[]; + onCategoriaChange: (hash: string, categoria: string) => void; +} + +const categorias = [ + 'Transporte', + 'Alimentação', + 'Supermercado', + 'Saúde', + 'Educação', + 'Lazer', + 'Moradia', + 'Vestuário', + 'Receita Fixa', + 'Investimentos', + 'Impostos', + 'Outros' +]; + +export const PreviewTransacoes = ({ transacoes, onCategoriaChange }: PreviewTransacoesProps) => { + const novas = transacoes.filter(t => !t.isDuplicada); + const duplicadas = transacoes.filter(t => t.isDuplicada); + const totalEntradas = novas.filter(t => t.tipo === 'entrada').reduce((sum, t) => sum + t.valor, 0); + const totalSaidas = novas.filter(t => t.tipo === 'saida').reduce((sum, t) => sum + t.valor, 0); + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(value); + }; + + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('pt-BR'); + }; + + return ( +
+ {/* Resumo */} +
+ +
+
+

Total de Transações

+

{transacoes.length}

+
+ +
+

+ {novas.length} novas | {duplicadas.length} duplicadas +

+
+ + +
+
+

Total Entradas

+

{formatCurrency(totalEntradas)}

+
+ +
+

+ {novas.filter(t => t.tipo === 'entrada').length} transações +

+
+ + +
+
+

Total Saídas

+

{formatCurrency(totalSaidas)}

+
+ +
+

+ {novas.filter(t => t.tipo === 'saida').length} transações +

+
+
+ + {/* Alerta de duplicatas */} + {duplicadas.length > 0 && ( + + + + {duplicadas.length} transações duplicadas foram encontradas e serão ignoradas na importação. + + + )} + + {/* Tabela de transações */} + +

Transações para Importar

+
+ + + + Data + Descrição + Categoria + Tipo + Valor + Status + + + + {transacoes.map((transacao) => ( + + + {formatDate(transacao.data)} + + + {transacao.descricao} + + + {transacao.isDuplicada ? ( + + {transacao.categoria} + + ) : ( + + )} + + + + {transacao.tipo === 'entrada' ? 'Entrada' : 'Saída'} + + + + + {formatCurrency(transacao.valor)} + + + + {transacao.isDuplicada ? ( + Duplicada + ) : ( + Nova + )} + + + ))} + +
+
+
+
+ ); +}; diff --git a/src/components/importacao/ResultadoImportacaoDialog.tsx b/src/components/importacao/ResultadoImportacaoDialog.tsx new file mode 100644 index 0000000..ba32c6f --- /dev/null +++ b/src/components/importacao/ResultadoImportacaoDialog.tsx @@ -0,0 +1,94 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { LogImportacao } from '@/types/importacaoTypes'; +import { CheckCircle2, XCircle, AlertCircle } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +interface ResultadoImportacaoDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + log: LogImportacao | null; +} + +export const ResultadoImportacaoDialog = ({ open, onOpenChange, log }: ResultadoImportacaoDialogProps) => { + const navigate = useNavigate(); + + if (!log) return null; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('pt-BR', { + style: 'currency', + currency: 'BRL', + }).format(value); + }; + + const getIcon = () => { + if (log.status === 'sucesso') return ; + if (log.status === 'erro') return ; + return ; + }; + + const getTitulo = () => { + if (log.status === 'sucesso') return 'Importação Concluída com Sucesso!'; + if (log.status === 'erro') return 'Erro na Importação'; + return 'Importação Parcialmente Concluída'; + }; + + const handleVerTransacoes = () => { + onOpenChange(false); + navigate('/transacoes'); + }; + + return ( + + + +
+ {getIcon()} + + {getTitulo()} + +
+
+ +
+
+ Total de registros: + {log.total_registros} +
+ +
+ Importadas: + {log.importados} +
+ +
+ Duplicadas (ignoradas): + {log.duplicados} +
+ + {log.erros > 0 && ( +
+ Erros: + {log.erros} +
+ )} + +
+ Valor total importado: + {formatCurrency(log.valor_total)} +
+
+ + + + + +
+
+ ); +}; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index fa34839..97b294a 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -14,12 +14,14 @@ import { BarChart3, Bell, HelpCircle, - Crown + Crown, + FileUp } from 'lucide-react'; const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: Home }, { name: 'Transações', href: '/transacoes', icon: Receipt }, + { name: 'Importar Extrato', href: '/importar-extrato', icon: FileUp }, { name: 'Cartões de Crédito', href: '/cartoes', icon: CreditCard }, { name: 'Metas', href: '/metas', icon: Target }, { name: 'Calendário', href: '/calendario', icon: Calendar }, diff --git a/src/hooks/useContasBancarias.ts b/src/hooks/useContasBancarias.ts new file mode 100644 index 0000000..e2b1a3c --- /dev/null +++ b/src/hooks/useContasBancarias.ts @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; +import { ContaBancaria } from '@/types/importacaoTypes'; +import { getContasBancarias, criarContaBancaria, atualizarContaBancaria, deletarContaBancaria } from '@/services/contasBancariasService'; +import { useToast } from '@/hooks/use-toast'; + +export const useContasBancarias = () => { + const [contas, setContas] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const { toast } = useToast(); + + const loadContas = async () => { + try { + setIsLoading(true); + const data = await getContasBancarias(); + setContas(data); + } catch (error) { + console.error('Erro ao carregar contas:', error); + toast({ + title: 'Erro ao carregar contas', + description: 'Não foi possível carregar suas contas bancárias', + variant: 'destructive' + }); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadContas(); + }, []); + + const criar = async (conta: Partial) => { + try { + const novaConta = await criarContaBancaria(conta); + setContas([novaConta, ...contas]); + toast({ + title: 'Conta criada', + description: 'Conta bancária criada com sucesso' + }); + return novaConta; + } catch (error) { + console.error('Erro ao criar conta:', error); + toast({ + title: 'Erro ao criar conta', + description: 'Não foi possível criar a conta bancária', + variant: 'destructive' + }); + throw error; + } + }; + + const atualizar = async (id: string, conta: Partial) => { + try { + const contaAtualizada = await atualizarContaBancaria(id, conta); + setContas(contas.map(c => c.id === id ? contaAtualizada : c)); + toast({ + title: 'Conta atualizada', + description: 'Conta bancária atualizada com sucesso' + }); + return contaAtualizada; + } catch (error) { + console.error('Erro ao atualizar conta:', error); + toast({ + title: 'Erro ao atualizar conta', + description: 'Não foi possível atualizar a conta bancária', + variant: 'destructive' + }); + throw error; + } + }; + + const deletar = async (id: string) => { + try { + await deletarContaBancaria(id); + setContas(contas.filter(c => c.id !== id)); + toast({ + title: 'Conta removida', + description: 'Conta bancária removida com sucesso' + }); + } catch (error) { + console.error('Erro ao deletar conta:', error); + toast({ + title: 'Erro ao remover conta', + description: 'Não foi possível remover a conta bancária', + variant: 'destructive' + }); + throw error; + } + }; + + return { + contas, + isLoading, + criar, + atualizar, + deletar, + recarregar: loadContas + }; +}; diff --git a/src/hooks/useImportacaoExtrato.ts b/src/hooks/useImportacaoExtrato.ts new file mode 100644 index 0000000..9e5e3c3 --- /dev/null +++ b/src/hooks/useImportacaoExtrato.ts @@ -0,0 +1,129 @@ +import { useState } from 'react'; +import { TransacaoImportada, TipoArquivo } from '@/types/importacaoTypes'; +import { parseExtrato, detectarTipoArquivo, validarTamanhoArquivo } from '@/services/importacao'; +import { verificarDuplicatas, importarTransacoes } from '@/services/importacaoService'; +import { useToast } from '@/hooks/use-toast'; + +export const useImportacaoExtrato = () => { + const [transacoes, setTransacoes] = useState([]); + const [isProcessing, setIsProcessing] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const { toast } = useToast(); + + const processarArquivo = async (file: File, contaBancariaId?: string) => { + try { + setIsProcessing(true); + + // Validar tamanho + if (!validarTamanhoArquivo(file)) { + toast({ + title: 'Arquivo muito grande', + description: 'O arquivo deve ter no máximo 10MB', + variant: 'destructive' + }); + return; + } + + // Detectar tipo + const tipoArquivo = detectarTipoArquivo(file.name); + if (!tipoArquivo) { + toast({ + title: 'Tipo de arquivo não suportado', + description: 'Formatos aceitos: OFX, CSV, XLSX, XLS', + variant: 'destructive' + }); + return; + } + + // Fazer parse + const transacoesParseadas = await parseExtrato(file, tipoArquivo, contaBancariaId); + + if (transacoesParseadas.length === 0) { + toast({ + title: 'Nenhuma transação encontrada', + description: 'O arquivo não contém transações válidas', + variant: 'destructive' + }); + return; + } + + // Verificar duplicatas + const transacoesComDuplicatas = await verificarDuplicatas(transacoesParseadas); + setTransacoes(transacoesComDuplicatas); + + const duplicadas = transacoesComDuplicatas.filter(t => t.isDuplicada).length; + const novas = transacoesComDuplicatas.length - duplicadas; + + toast({ + title: 'Arquivo processado', + description: `${novas} transações novas, ${duplicadas} duplicadas encontradas` + }); + + } catch (error) { + console.error('Erro ao processar arquivo:', error); + toast({ + title: 'Erro ao processar arquivo', + description: error instanceof Error ? error.message : 'Erro desconhecido', + variant: 'destructive' + }); + } finally { + setIsProcessing(false); + } + }; + + const importar = async ( + transacoesParaImportar: TransacaoImportada[], + contaBancariaId: string, + nomeArquivo: string, + tipoArquivo: string + ) => { + try { + setIsImporting(true); + + const log = await importarTransacoes( + transacoesParaImportar, + contaBancariaId, + nomeArquivo, + tipoArquivo + ); + + toast({ + title: 'Importação concluída', + description: `${log.importados} transações importadas com sucesso` + }); + + return log; + + } catch (error) { + console.error('Erro ao importar transações:', error); + toast({ + title: 'Erro ao importar', + description: 'Não foi possível importar as transações', + variant: 'destructive' + }); + throw error; + } finally { + setIsImporting(false); + } + }; + + const limpar = () => { + setTransacoes([]); + }; + + const atualizarCategoria = (hash: string, categoria: string) => { + setTransacoes(transacoes.map(t => + t.hash_unico === hash ? { ...t, categoria } : t + )); + }; + + return { + transacoes, + isProcessing, + isImporting, + processarArquivo, + importar, + limpar, + atualizarCategoria + }; +}; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 2bec4e2..df75f3f 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -859,6 +859,45 @@ export type Database = { } Relationships: [] } + contas_bancarias: { + Row: { + ativo: boolean | null + banco: string | null + created_at: string | null + id: string + login: string | null + nome: string + saldo_inicial: number | null + tipo: string | null + updated_at: string | null + user_id: string + } + Insert: { + ativo?: boolean | null + banco?: string | null + created_at?: string | null + id?: string + login?: string | null + nome: string + saldo_inicial?: number | null + tipo?: string | null + updated_at?: string | null + user_id: string + } + Update: { + ativo?: boolean | null + banco?: string | null + created_at?: string | null + id?: string + login?: string | null + nome?: string + saldo_inicial?: number | null + tipo?: string | null + updated_at?: string | null + user_id?: string + } + Relationships: [] + } contas_recorrentes: { Row: { ativo: boolean @@ -1505,6 +1544,65 @@ export type Database = { }, ] } + logs_importacao: { + Row: { + conta_bancaria_id: string | null + created_at: string | null + detalhes_erro: string | null + duplicados: number | null + erros: number | null + id: string + importados: number | null + login: string | null + nome_arquivo: string + status: string + tipo_arquivo: string + total_registros: number | null + user_id: string + valor_total: number | null + } + Insert: { + conta_bancaria_id?: string | null + created_at?: string | null + detalhes_erro?: string | null + duplicados?: number | null + erros?: number | null + id?: string + importados?: number | null + login?: string | null + nome_arquivo: string + status?: string + tipo_arquivo: string + total_registros?: number | null + user_id: string + valor_total?: number | null + } + Update: { + conta_bancaria_id?: string | null + created_at?: string | null + detalhes_erro?: string | null + duplicados?: number | null + erros?: number | null + id?: string + importados?: number | null + login?: string | null + nome_arquivo?: string + status?: string + tipo_arquivo?: string + total_registros?: number | null + user_id?: string + valor_total?: number | null + } + Relationships: [ + { + foreignKeyName: "logs_importacao_conta_bancaria_id_fkey" + columns: ["conta_bancaria_id"] + isOneToOne: false + referencedRelation: "contas_bancarias" + referencedColumns: ["id"] + }, + ] + } metas: { Row: { ano: number @@ -2061,12 +2159,15 @@ export type Database = { transacoes: { Row: { categoria: string | null + conta_bancaria_id: string | null created_at: string detalhes: string | null estabelecimento: string | null grupo_id: string | null + hash_unico: string | null id: number login: string | null + origem: string | null quando: string | null tipo: string | null user: string | null @@ -2074,12 +2175,15 @@ export type Database = { } Insert: { categoria?: string | null + conta_bancaria_id?: string | null created_at?: string detalhes?: string | null estabelecimento?: string | null grupo_id?: string | null + hash_unico?: string | null id?: number login?: string | null + origem?: string | null quando?: string | null tipo?: string | null user?: string | null @@ -2087,18 +2191,29 @@ export type Database = { } Update: { categoria?: string | null + conta_bancaria_id?: string | null created_at?: string detalhes?: string | null estabelecimento?: string | null grupo_id?: string | null + hash_unico?: string | null id?: number login?: string | null + origem?: string | null quando?: string | null tipo?: string | null user?: string | null valor?: number | null } - Relationships: [] + Relationships: [ + { + foreignKeyName: "transacoes_conta_bancaria_id_fkey" + columns: ["conta_bancaria_id"] + isOneToOne: false + referencedRelation: "contas_bancarias" + referencedColumns: ["id"] + }, + ] } units: { Row: { diff --git a/src/pages/ImportarExtrato.tsx b/src/pages/ImportarExtrato.tsx new file mode 100644 index 0000000..8077bd9 --- /dev/null +++ b/src/pages/ImportarExtrato.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { FileUpload } from '@/components/importacao/FileUpload'; +import { PreviewTransacoes } from '@/components/importacao/PreviewTransacoes'; +import { ContaBancariaSelect } from '@/components/importacao/ContaBancariaSelect'; +import { NovaContaDialog } from '@/components/importacao/NovaContaDialog'; +import { ResultadoImportacaoDialog } from '@/components/importacao/ResultadoImportacaoDialog'; +import { useImportacaoExtrato } from '@/hooks/useImportacaoExtrato'; +import { useContasBancarias } from '@/hooks/useContasBancarias'; +import { detectarTipoArquivo } from '@/services/importacao'; +import { LogImportacao } from '@/types/importacaoTypes'; +import { ArrowLeft, FileCheck, Upload as UploadIcon } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +const ImportarExtrato = () => { + const navigate = useNavigate(); + const [step, setStep] = useState<1 | 2 | 3>(1); + const [contaSelecionada, setContaSelecionada] = useState(''); + const [arquivo, setArquivo] = useState(null); + const [novaContaDialogOpen, setNovaContaDialogOpen] = useState(false); + const [resultadoDialog, setResultadoDialog] = useState(false); + const [logImportacao, setLogImportacao] = useState(null); + + const { transacoes, isProcessing, isImporting, processarArquivo, importar, limpar, atualizarCategoria } = useImportacaoExtrato(); + const { contas, isLoading, criar, recarregar } = useContasBancarias(); + + const handleFileSelect = async (file: File) => { + setArquivo(file); + if (contaSelecionada) { + await processarArquivo(file, contaSelecionada); + setStep(2); + } + }; + + const handleContaChange = async (contaId: string) => { + setContaSelecionada(contaId); + if (arquivo) { + await processarArquivo(arquivo, contaId); + if (transacoes.length > 0) { + setStep(2); + } + } + }; + + const handleNovaConta = async (novaConta: any) => { + const conta = await criar(novaConta); + setContaSelecionada(conta.id); + await recarregar(); + }; + + const handleImportar = async () => { + if (!arquivo || !contaSelecionada) return; + + const tipoArquivo = detectarTipoArquivo(arquivo.name); + if (!tipoArquivo) return; + + try { + const log = await importar(transacoes, contaSelecionada, arquivo.name, tipoArquivo); + setLogImportacao(log); + setResultadoDialog(true); + + // Limpar estado após importação + setStep(1); + setArquivo(null); + setContaSelecionada(''); + limpar(); + } catch (error) { + console.error('Erro ao importar:', error); + } + }; + + const handleVoltar = () => { + if (step === 2) { + setStep(1); + limpar(); + } else { + navigate('/dashboard'); + } + }; + + const novasTransacoes = transacoes.filter(t => !t.isDuplicada); + + return ( +
+
+ {/* Header */} +
+
+ +
+

Importar Extrato Bancário

+

+ Importe seus extratos em OFX, CSV ou XLSX +

+
+
+
+ + {/* Steps indicator */} + +
+
= 1 ? 'text-primary' : 'text-muted-foreground'}`}> +
= 1 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}> + 1 +
+ Selecionar Arquivo +
+
+
= 2 ? 'text-primary' : 'text-muted-foreground'}`}> +
= 2 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}> + 2 +
+ Revisar Dados +
+
+
= 3 ? 'text-primary' : 'text-muted-foreground'}`}> +
= 3 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}> + 3 +
+ Confirmar +
+
+ + + {/* Step 1: Upload */} + {step === 1 && ( +
+ setNovaContaDialogOpen(true)} + isLoading={isLoading} + /> + + {contaSelecionada && ( + +

+ + Upload do Extrato +

+ +
+ )} +
+ )} + + {/* Step 2: Preview */} + {step === 2 && transacoes.length > 0 && ( +
+ + + +
+ + +
+
+
+ )} +
+ + {/* Dialogs */} + + + +
+ ); +}; + +export default ImportarExtrato; diff --git a/src/services/contasBancariasService.ts b/src/services/contasBancariasService.ts new file mode 100644 index 0000000..c4d916e --- /dev/null +++ b/src/services/contasBancariasService.ts @@ -0,0 +1,97 @@ +import { supabase } from '@/integrations/supabase/client'; +import { ContaBancaria } from '@/types/importacaoTypes'; + +/** + * Busca todas as contas bancárias do usuário + */ +export async function getContasBancarias(): Promise { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Usuário não autenticado'); + + const userEmail = user.email?.toLowerCase(); + + const { data, error } = await supabase + .from('contas_bancarias') + .select('*') + .eq('user_id', user.id) + .eq('ativo', true) + .order('created_at', { ascending: false }); + + if (error) { + console.error('Erro ao buscar contas bancárias:', error); + throw error; + } + + return data || []; +} + +/** + * Cria uma nova conta bancária + */ +export async function criarContaBancaria(conta: Partial): Promise { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Usuário não autenticado'); + + const userEmail = user.email?.toLowerCase(); + + const { data, error } = await supabase + .from('contas_bancarias') + .insert({ + user_id: user.id, + login: userEmail, + nome: conta.nome, + banco: conta.banco, + tipo: conta.tipo || 'corrente', + saldo_inicial: conta.saldo_inicial || 0, + ativo: true + }) + .select() + .single(); + + if (error) { + console.error('Erro ao criar conta bancária:', error); + throw error; + } + + return data; +} + +/** + * Atualiza uma conta bancária existente + */ +export async function atualizarContaBancaria(id: string, conta: Partial): Promise { + const { data, error } = await supabase + .from('contas_bancarias') + .update({ + nome: conta.nome, + banco: conta.banco, + tipo: conta.tipo, + saldo_inicial: conta.saldo_inicial, + updated_at: new Date().toISOString() + }) + .eq('id', id) + .select() + .single(); + + if (error) { + console.error('Erro ao atualizar conta bancária:', error); + throw error; + } + + return data; +} + +/** + * Deleta (desativa) uma conta bancária + */ +export async function deletarContaBancaria(id: string): Promise { + const { error } = await supabase + .from('contas_bancarias') + .update({ ativo: false }) + .eq('id', id); + + if (error) { + console.error('Erro ao deletar conta bancária:', error); + throw error; + } +} diff --git a/src/services/importacao/index.ts b/src/services/importacao/index.ts new file mode 100644 index 0000000..fcd1277 --- /dev/null +++ b/src/services/importacao/index.ts @@ -0,0 +1,63 @@ +import { TipoArquivo, TransacaoImportada } from '@/types/importacaoTypes'; +import { parseOFX } from './parseOFX'; +import { parseCSV } from './parseCSV'; +import { parseXLSX } from './parseXLSX'; + +/** + * Função principal para fazer parse de extratos bancários + */ +export async function parseExtrato( + file: File, + tipoArquivo: TipoArquivo, + contaBancariaId?: string +): Promise { + console.log(`Iniciando parse de arquivo ${file.name} (${tipoArquivo})`); + + try { + switch (tipoArquivo) { + case 'ofx': + return await parseOFX(file, contaBancariaId); + + case 'csv': + return await parseCSV(file, contaBancariaId); + + case 'xlsx': + case 'xls': + return await parseXLSX(file, contaBancariaId); + + default: + throw new Error(`Tipo de arquivo não suportado: ${tipoArquivo}`); + } + } catch (error) { + console.error('Erro ao fazer parse do extrato:', error); + throw error; + } +} + +/** + * Detecta o tipo de arquivo pela extensão + */ +export function detectarTipoArquivo(nomeArquivo: string): TipoArquivo | null { + const extensao = nomeArquivo.split('.').pop()?.toLowerCase(); + + switch (extensao) { + case 'ofx': + return 'ofx'; + case 'csv': + return 'csv'; + case 'xlsx': + return 'xlsx'; + case 'xls': + return 'xls'; + default: + return null; + } +} + +/** + * Valida o tamanho do arquivo (máximo 10MB) + */ +export function validarTamanhoArquivo(file: File): boolean { + const MAX_SIZE = 10 * 1024 * 1024; // 10MB + return file.size <= MAX_SIZE; +} diff --git a/src/services/importacao/parseCSV.ts b/src/services/importacao/parseCSV.ts new file mode 100644 index 0000000..974cb2e --- /dev/null +++ b/src/services/importacao/parseCSV.ts @@ -0,0 +1,124 @@ +import Papa from 'papaparse'; +import { TransacaoImportada } from '@/types/importacaoTypes'; +import { gerarHashTransacao } from '@/utils/hashGenerator'; +import { limparDescricao } from '@/utils/categorizacaoAutomatica'; + +/** + * Parse de arquivos CSV + * Tenta detectar automaticamente as colunas + */ +export async function parseCSV(file: File, contaBancariaId?: string): Promise { + return new Promise((resolve, reject) => { + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: (results) => { + try { + const transacoes: TransacaoImportada[] = []; + const data = results.data as any[]; + + if (data.length === 0) { + throw new Error('Arquivo CSV vazio'); + } + + // Tentar detectar colunas automaticamente + const primeiraLinha = data[0]; + const colunas = Object.keys(primeiraLinha); + + // Encontrar colunas relevantes + const colunaData = detectarColuna(colunas, ['data', 'date', 'quando', 'dt']); + const colunaDescricao = detectarColuna(colunas, ['descricao', 'description', 'historico', 'memo', 'estabelecimento']); + const colunaValor = detectarColuna(colunas, ['valor', 'value', 'amount', 'quantia']); + const colunaTipo = detectarColuna(colunas, ['tipo', 'type', 'natureza', 'credito', 'debito']); + + if (!colunaData || !colunaDescricao || !colunaValor) { + throw new Error('Não foi possível identificar as colunas necessárias (Data, Descrição, Valor)'); + } + + for (const linha of data) { + const dataStr = linha[colunaData]; + const descricao = limparDescricao(String(linha[colunaDescricao] || '')); + const valorStr = String(linha[colunaValor] || '0'); + + // Parse de data (tenta vários formatos) + const data = parseData(dataStr); + if (!data) continue; + + // Parse de valor (remove símbolos de moeda e converte) + const valor = parseValor(valorStr); + if (isNaN(valor)) continue; + + // Determinar tipo + let tipo: 'entrada' | 'saida'; + if (colunaTipo) { + const tipoStr = String(linha[colunaTipo]).toLowerCase(); + tipo = tipoStr.includes('credit') || tipoStr.includes('entrada') || tipoStr.includes('receita') ? 'entrada' : 'saida'; + } else { + tipo = valor >= 0 ? 'entrada' : 'saida'; + } + + transacoes.push({ + data, + descricao, + valor: Math.abs(valor), + tipo, + hash_unico: gerarHashTransacao(data, descricao, valor, contaBancariaId) + }); + } + + resolve(transacoes); + } catch (error) { + reject(error); + } + }, + error: (error) => { + reject(new Error(`Erro ao processar CSV: ${error.message}`)); + } + }); + }); +} + +function detectarColuna(colunas: string[], possibilidades: string[]): string | null { + for (const coluna of colunas) { + const colunaLower = coluna.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + for (const possibilidade of possibilidades) { + if (colunaLower.includes(possibilidade)) { + return coluna; + } + } + } + return null; +} + +function parseData(dataStr: string): string | null { + if (!dataStr) return null; + + // Formato ISO (YYYY-MM-DD) + if (/^\d{4}-\d{2}-\d{2}/.test(dataStr)) { + return dataStr.substring(0, 10); + } + + // Formato BR (DD/MM/YYYY) + const matchBR = dataStr.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (matchBR) { + return `${matchBR[3]}-${matchBR[2]}-${matchBR[1]}`; + } + + // Formato US (MM/DD/YYYY) + const matchUS = dataStr.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (matchUS) { + return `${matchUS[3]}-${matchUS[1]}-${matchUS[2]}`; + } + + return null; +} + +function parseValor(valorStr: string): number { + // Remove símbolos de moeda, espaços e converte vírgula em ponto + const valorLimpo = valorStr + .replace(/[R$\s€£¥]/g, '') + .replace(/\./g, '') + .replace(',', '.'); + + return parseFloat(valorLimpo); +} diff --git a/src/services/importacao/parseOFX.ts b/src/services/importacao/parseOFX.ts new file mode 100644 index 0000000..7851526 --- /dev/null +++ b/src/services/importacao/parseOFX.ts @@ -0,0 +1,48 @@ +import { TransacaoImportada } from '@/types/importacaoTypes'; +import { gerarHashTransacao } from '@/utils/hashGenerator'; +import { limparDescricao } from '@/utils/categorizacaoAutomatica'; + +/** + * Parse de arquivos OFX (Open Financial Exchange) + */ +export async function parseOFX(file: File, contaBancariaId?: string): Promise { + const texto = await file.text(); + const transacoes: TransacaoImportada[] = []; + + try { + // Regex para extrair transações do OFX + const transacoesRegex = /([\s\S]*?)<\/STMTTRN>/g; + let match; + + while ((match = transacoesRegex.exec(texto)) !== null) { + const transacaoXml = match[1]; + + // Extrair campos + const dataMatch = transacaoXml.match(/(\d{8})/); + const valorMatch = transacaoXml.match(/([-\d.]+)/); + const descricaoMatch = transacaoXml.match(/(.*?)(.*?)(\w+)/); + + if (dataMatch && valorMatch && descricaoMatch) { + const data = dataMatch[1]; + const dataFormatada = `${data.substring(0, 4)}-${data.substring(4, 6)}-${data.substring(6, 8)}`; + const valor = parseFloat(valorMatch[1]); + const descricao = limparDescricao(descricaoMatch[1]); + const tipo: 'entrada' | 'saida' = valor >= 0 ? 'entrada' : 'saida'; + + transacoes.push({ + data: dataFormatada, + descricao, + valor: Math.abs(valor), + tipo, + hash_unico: gerarHashTransacao(dataFormatada, descricao, valor, contaBancariaId) + }); + } + } + + return transacoes; + } catch (error) { + console.error('Erro ao fazer parse do OFX:', error); + throw new Error('Erro ao processar arquivo OFX. Verifique se o formato está correto.'); + } +} diff --git a/src/services/importacao/parseXLSX.ts b/src/services/importacao/parseXLSX.ts new file mode 100644 index 0000000..2e87b62 --- /dev/null +++ b/src/services/importacao/parseXLSX.ts @@ -0,0 +1,115 @@ +import * as XLSX from 'xlsx'; +import { TransacaoImportada } from '@/types/importacaoTypes'; +import { gerarHashTransacao } from '@/utils/hashGenerator'; +import { limparDescricao } from '@/utils/categorizacaoAutomatica'; + +/** + * Parse de arquivos Excel (XLSX/XLS) + */ +export async function parseXLSX(file: File, contaBancariaId?: string): Promise { + const buffer = await file.arrayBuffer(); + const workbook = XLSX.read(buffer, { type: 'array' }); + + // Pega a primeira planilha + const primeiraAba = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[primeiraAba]; + + // Converte para JSON + const data: any[] = XLSX.utils.sheet_to_json(worksheet); + + if (data.length === 0) { + throw new Error('Planilha vazia'); + } + + const transacoes: TransacaoImportada[] = []; + const colunas = Object.keys(data[0]); + + // Detectar colunas + const colunaData = detectarColuna(colunas, ['data', 'date', 'quando', 'dt']); + const colunaDescricao = detectarColuna(colunas, ['descricao', 'description', 'historico', 'memo', 'estabelecimento']); + const colunaValor = detectarColuna(colunas, ['valor', 'value', 'amount', 'quantia']); + const colunaTipo = detectarColuna(colunas, ['tipo', 'type', 'natureza', 'credito', 'debito']); + + if (!colunaData || !colunaDescricao || !colunaValor) { + throw new Error('Não foi possível identificar as colunas necessárias (Data, Descrição, Valor)'); + } + + for (const linha of data) { + const dataStr = linha[colunaData]; + const descricao = limparDescricao(String(linha[colunaDescricao] || '')); + const valorStr = linha[colunaValor]; + + // Parse de data + const data = parseData(dataStr); + if (!data) continue; + + // Parse de valor + const valor = typeof valorStr === 'number' ? valorStr : parseValor(String(valorStr)); + if (isNaN(valor)) continue; + + // Determinar tipo + let tipo: 'entrada' | 'saida'; + if (colunaTipo) { + const tipoStr = String(linha[colunaTipo]).toLowerCase(); + tipo = tipoStr.includes('credit') || tipoStr.includes('entrada') || tipoStr.includes('receita') ? 'entrada' : 'saida'; + } else { + tipo = valor >= 0 ? 'entrada' : 'saida'; + } + + transacoes.push({ + data, + descricao, + valor: Math.abs(valor), + tipo, + hash_unico: gerarHashTransacao(data, descricao, valor, contaBancariaId) + }); + } + + return transacoes; +} + +function detectarColuna(colunas: string[], possibilidades: string[]): string | null { + for (const coluna of colunas) { + const colunaLower = coluna.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + for (const possibilidade of possibilidades) { + if (colunaLower.includes(possibilidade)) { + return coluna; + } + } + } + return null; +} + +function parseData(dataStr: any): string | null { + if (!dataStr) return null; + + // Se for número (formato Excel de data serial) + if (typeof dataStr === 'number') { + const date = XLSX.SSF.parse_date_code(dataStr); + return `${date.y}-${String(date.m).padStart(2, '0')}-${String(date.d).padStart(2, '0')}`; + } + + const str = String(dataStr); + + // Formato ISO (YYYY-MM-DD) + if (/^\d{4}-\d{2}-\d{2}/.test(str)) { + return str.substring(0, 10); + } + + // Formato BR (DD/MM/YYYY) + const matchBR = str.match(/^(\d{2})\/(\d{2})\/(\d{4})/); + if (matchBR) { + return `${matchBR[3]}-${matchBR[2]}-${matchBR[1]}`; + } + + return null; +} + +function parseValor(valorStr: string): number { + const valorLimpo = valorStr + .replace(/[R$\s€£¥]/g, '') + .replace(/\./g, '') + .replace(',', '.'); + + return parseFloat(valorLimpo); +} diff --git a/src/services/importacaoService.ts b/src/services/importacaoService.ts new file mode 100644 index 0000000..3f8d721 --- /dev/null +++ b/src/services/importacaoService.ts @@ -0,0 +1,132 @@ +import { supabase } from '@/integrations/supabase/client'; +import { TransacaoImportada, LogImportacao } from '@/types/importacaoTypes'; +import { sugerirCategoria } from '@/utils/categorizacaoAutomatica'; + +/** + * Verifica duplicatas comparando hashes + */ +export async function verificarDuplicatas(transacoes: TransacaoImportada[]): Promise { + const hashes = transacoes.map(t => t.hash_unico); + + const { data, error } = await supabase + .from('transacoes') + .select('hash_unico') + .in('hash_unico', hashes); + + if (error) { + console.error('Erro ao verificar duplicatas:', error); + return transacoes; + } + + const hashesExistentes = new Set(data?.map(t => t.hash_unico) || []); + + return transacoes.map(t => ({ + ...t, + isDuplicada: hashesExistentes.has(t.hash_unico), + categoria: t.categoria || sugerirCategoria(t.descricao) + })); +} + +/** + * Importa transações para o banco de dados + */ +export async function importarTransacoes( + transacoes: TransacaoImportada[], + contaBancariaId: string, + nomeArquivo: string, + tipoArquivo: string +): Promise { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Usuário não autenticado'); + + const userEmail = user.email?.toLowerCase(); + + // Filtra apenas transações não duplicadas + const transacoesParaImportar = transacoes.filter(t => !t.isDuplicada); + + let importadas = 0; + let erros = 0; + const valorTotal = transacoesParaImportar.reduce((sum, t) => sum + t.valor, 0); + + // Importa transações em batch + for (const transacao of transacoesParaImportar) { + try { + const { error } = await supabase + .from('transacoes') + .insert({ + user_id: user.id, + login: userEmail, + user: user.email, + quando: transacao.data, + descricao: transacao.descricao, + estabelecimento: transacao.descricao, + detalhes: `Importado de ${nomeArquivo}`, + valor: transacao.tipo === 'entrada' ? transacao.valor : -transacao.valor, + tipo: transacao.tipo === 'entrada' ? 'receita' : 'despesa', + categoria: transacao.categoria || 'Outros', + origem: 'importado', + hash_unico: transacao.hash_unico, + conta_bancaria_id: contaBancariaId + }); + + if (error) { + console.error('Erro ao importar transação:', error); + erros++; + } else { + importadas++; + } + } catch (error) { + console.error('Erro ao importar transação:', error); + erros++; + } + } + + // Criar log de importação + const log: LogImportacao = { + user_id: user.id, + login: userEmail, + conta_bancaria_id: contaBancariaId, + nome_arquivo: nomeArquivo, + tipo_arquivo: tipoArquivo, + status: erros === 0 ? 'sucesso' : (importadas > 0 ? 'parcial' : 'erro'), + total_registros: transacoes.length, + importados: importadas, + duplicados: transacoes.filter(t => t.isDuplicada).length, + erros, + valor_total: valorTotal + }; + + const { data: logData, error: logError } = await supabase + .from('logs_importacao') + .insert(log) + .select() + .single(); + + if (logError) { + console.error('Erro ao criar log de importação:', logError); + } + + return (logData as LogImportacao) || log; +} + +/** + * Busca histórico de importações + */ +export async function getHistoricoImportacoes(): Promise { + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Usuário não autenticado'); + + const { data, error } = await supabase + .from('logs_importacao') + .select('*') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) + .limit(20); + + if (error) { + console.error('Erro ao buscar histórico:', error); + throw error; + } + + return (data as LogImportacao[]) || []; +} diff --git a/src/types/importacaoTypes.ts b/src/types/importacaoTypes.ts new file mode 100644 index 0000000..a4d4963 --- /dev/null +++ b/src/types/importacaoTypes.ts @@ -0,0 +1,48 @@ +export interface ContaBancaria { + id: string; + user_id: string; + login?: string; + nome: string; + banco?: string; + tipo: string; + saldo_inicial: number; + ativo: boolean; + created_at?: string; + updated_at?: string; +} + +export interface TransacaoImportada { + data: string; + descricao: string; + valor: number; + tipo: 'entrada' | 'saida'; + categoria?: string; + hash_unico: string; + isDuplicada?: boolean; +} + +export interface LogImportacao { + id?: string; + user_id: string; + login?: string; + conta_bancaria_id?: string; + nome_arquivo: string; + tipo_arquivo: string; + status: 'processando' | 'sucesso' | 'erro' | 'parcial'; + total_registros: number; + importados: number; + duplicados: number; + erros: number; + valor_total: number; + detalhes_erro?: string; + created_at?: string; +} + +export interface ResultadoImportacao { + sucesso: boolean; + transacoes: TransacaoImportada[]; + log: LogImportacao; + mensagem: string; +} + +export type TipoArquivo = 'ofx' | 'csv' | 'xlsx' | 'xls'; diff --git a/src/utils/categorizacaoAutomatica.ts b/src/utils/categorizacaoAutomatica.ts new file mode 100644 index 0000000..30bf552 --- /dev/null +++ b/src/utils/categorizacaoAutomatica.ts @@ -0,0 +1,97 @@ +// Sistema de categorização automática baseado em palavras-chave + +interface RegraCategorizacao { + categoria: string; + keywords: string[]; +} + +const regras: RegraCategorizacao[] = [ + // Transporte + { + categoria: 'Transporte', + keywords: ['uber', '99', 'taxi', 'cabify', 'transporte', 'onibus', 'metro', 'combustivel', 'gasolina', 'etanol', 'posto', 'ipva', 'estacionamento', 'pedágio'] + }, + // Alimentação + { + categoria: 'Alimentação', + keywords: ['ifood', 'rappi', 'ubereats', 'restaurante', 'lanchonete', 'padaria', 'pizzaria', 'burger', 'sushi', 'comida', 'mcdonalds', 'burguer king', 'subway', 'cafe'] + }, + // Supermercado + { + categoria: 'Supermercado', + keywords: ['mercado', 'supermercado', 'hortifruti', 'açougue', 'pao de acucar', 'carrefour', 'extra', 'dia%', 'atacadao', 'assai'] + }, + // Saúde + { + categoria: 'Saúde', + keywords: ['farmacia', 'drogaria', 'droga', 'hospital', 'clinica', 'laboratorio', 'medico', 'dentista', 'plano de saude', 'unimed', 'amil', 'sulamerica'] + }, + // Educação + { + categoria: 'Educação', + keywords: ['escola', 'faculdade', 'universidade', 'curso', 'livro', 'livraria', 'material escolar', 'mensalidade'] + }, + // Lazer + { + categoria: 'Lazer', + keywords: ['cinema', 'teatro', 'show', 'netflix', 'spotify', 'amazon prime', 'disney', 'ingresso', 'parque', 'clube', 'academia', 'gym'] + }, + // Moradia + { + categoria: 'Moradia', + keywords: ['aluguel', 'condominio', 'iptu', 'luz', 'agua', 'gas', 'internet', 'telefone', 'celular', 'energia', 'enel', 'sabesp', 'claro', 'vivo', 'tim', 'oi'] + }, + // Vestuário + { + categoria: 'Vestuário', + keywords: ['roupa', 'calçado', 'sapato', 'tenis', 'camisa', 'calca', 'vestido', 'loja', 'renner', 'c&a', 'riachuelo', 'zara', 'nike', 'adidas'] + }, + // Receitas + { + categoria: 'Receita Fixa', + keywords: ['salario', 'credito salarial', 'pagamento', 'rendimento', 'pix recebido', 'transferencia recebida', 'ted recebida'] + }, + // Investimentos + { + categoria: 'Investimentos', + keywords: ['aplicacao', 'resgate', 'investimento', 'poupanca', 'cdb', 'tesouro', 'acao', 'fundo', 'b3'] + }, + // Impostos + { + categoria: 'Impostos', + keywords: ['imposto', 'taxa', 'tributo', 'darf', 'multa', 'juros'] + } +]; + +/** + * Sugere uma categoria baseada na descrição da transação + */ +export function sugerirCategoria(descricao: string): string { + const descricaoLower = descricao.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + + for (const regra of regras) { + for (const keyword of regra.keywords) { + const keywordNormalized = keyword.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + if (descricaoLower.includes(keywordNormalized)) { + return regra.categoria; + } + } + } + + return 'Outros'; +} + +/** + * Limpa a descrição removendo textos desnecessários dos bancos + */ +export function limparDescricao(descricao: string): string { + let limpa = descricao.trim(); + + // Remove padrões comuns de bancos + limpa = limpa.replace(/^(PIX|TED|DOC|TRANSF|PAGTO)\s*/i, ''); + limpa = limpa.replace(/\s*-\s*\d+\/\d+\/\d+/g, ''); // Remove datas no formato - DD/MM/YYYY + limpa = limpa.replace(/\s+/g, ' '); // Remove espaços múltiplos + limpa = limpa.replace(/^\s*[-:]\s*/, ''); // Remove traços/dois pontos iniciais + + return limpa.trim(); +} diff --git a/src/utils/hashGenerator.ts b/src/utils/hashGenerator.ts new file mode 100644 index 0000000..449b72a --- /dev/null +++ b/src/utils/hashGenerator.ts @@ -0,0 +1,26 @@ +/** + * Gera um hash único para uma transação baseado em seus dados + * Isso permite identificar duplicatas durante a importação + */ +export function gerarHashTransacao( + data: string, + descricao: string, + valor: number, + contaBancariaId?: string +): string { + const dataFormatada = new Date(data).toISOString().split('T')[0]; + const valorFormatado = Math.abs(valor).toFixed(2); + const descricaoLimpa = descricao.toLowerCase().trim().substring(0, 50); + + const stringParaHash = `${dataFormatada}_${descricaoLimpa}_${valorFormatado}_${contaBancariaId || ''}`; + + // Implementação simples de hash (para produção, considere usar crypto-js ou similar) + let hash = 0; + for (let i = 0; i < stringParaHash.length; i++) { + const char = stringParaHash.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + + return Math.abs(hash).toString(36); +} diff --git a/supabase/migrations/20251125224807_b8961206-0700-433c-a199-398b430bd319.sql b/supabase/migrations/20251125224807_b8961206-0700-433c-a199-398b430bd319.sql new file mode 100644 index 0000000..e485c91 --- /dev/null +++ b/supabase/migrations/20251125224807_b8961206-0700-433c-a199-398b430bd319.sql @@ -0,0 +1,80 @@ +-- Criar tabela de contas bancárias +CREATE TABLE IF NOT EXISTS public.contas_bancarias ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + login TEXT, + nome TEXT NOT NULL, + banco TEXT, + tipo TEXT DEFAULT 'corrente', + saldo_inicial NUMERIC DEFAULT 0, + ativo BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Criar tabela de logs de importação +CREATE TABLE IF NOT EXISTS public.logs_importacao ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + login TEXT, + conta_bancaria_id UUID REFERENCES public.contas_bancarias(id) ON DELETE SET NULL, + nome_arquivo TEXT NOT NULL, + tipo_arquivo TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'processando', + total_registros INTEGER DEFAULT 0, + importados INTEGER DEFAULT 0, + duplicados INTEGER DEFAULT 0, + erros INTEGER DEFAULT 0, + valor_total NUMERIC DEFAULT 0, + detalhes_erro TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +-- Adicionar coluna de origem e hash nas transações (se não existir) +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='transacoes' AND column_name='origem') THEN + ALTER TABLE public.transacoes ADD COLUMN origem TEXT DEFAULT 'manual'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='transacoes' AND column_name='hash_unico') THEN + ALTER TABLE public.transacoes ADD COLUMN hash_unico TEXT; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name='transacoes' AND column_name='conta_bancaria_id') THEN + ALTER TABLE public.transacoes ADD COLUMN conta_bancaria_id UUID REFERENCES public.contas_bancarias(id) ON DELETE SET NULL; + END IF; +END $$; + +-- Criar índices para performance +CREATE INDEX IF NOT EXISTS idx_transacoes_hash_unico ON public.transacoes(hash_unico) WHERE hash_unico IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_transacoes_conta_bancaria ON public.transacoes(conta_bancaria_id) WHERE conta_bancaria_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_contas_bancarias_user ON public.contas_bancarias(user_id); +CREATE INDEX IF NOT EXISTS idx_logs_importacao_user ON public.logs_importacao(user_id); + +-- RLS policies para contas_bancarias +ALTER TABLE public.contas_bancarias ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own contas" ON public.contas_bancarias + FOR SELECT USING (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); + +CREATE POLICY "Users can create their own contas" ON public.contas_bancarias + FOR INSERT WITH CHECK (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); + +CREATE POLICY "Users can update their own contas" ON public.contas_bancarias + FOR UPDATE USING (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); + +CREATE POLICY "Users can delete their own contas" ON public.contas_bancarias + FOR DELETE USING (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); + +-- RLS policies para logs_importacao +ALTER TABLE public.logs_importacao ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their own logs" ON public.logs_importacao + FOR SELECT USING (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); + +CREATE POLICY "Users can create their own logs" ON public.logs_importacao + FOR INSERT WITH CHECK (user_id::text = COALESCE((current_setting('request.jwt.claims'::text, true)::json->>'sub')::text, '')); \ No newline at end of file