Add bank statement import flow

Implement initial structure for bank statement import:
- add parsing services for OFX, CSV, XLSX, and groundwork for PDF
- create types, utilities (hashing, categorization, normalization)
- build import hooks/services UI scaffolding (upload, wizard, preview)
- add accounts and import logs tables scaffolding and related service hooks
- integrate duplicate detection and category mapping
- wire up parsing container and import flow with toast feedback

X-Lovable-Edit-ID: edt-e54ea264-ca3c-43e3-afea-c078875db0b2
This commit is contained in:
gpt-engineer-app[bot] 2025-11-25 22:52:26 +00:00
commit e7b163eb2d
23 changed files with 2070 additions and 2 deletions

111
package-lock.json generated
View File

@ -49,6 +49,7 @@
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -61,6 +62,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.177.0", "three": "^0.177.0",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"xlsx": "^0.18.5",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },
@ -3421,6 +3423,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "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": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@ -3714,6 +3725,19 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4204,6 +4237,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4885,6 +4930,15 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@ -6106,6 +6160,12 @@
"dev": true, "dev": true,
"license": "BlueOak-1.0.0" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6893,6 +6953,18 @@
"node": ">=0.10.0" "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": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@ -7496,6 +7568,24 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "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": { "node_modules/yaml": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz",

View File

@ -52,6 +52,7 @@
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"papaparse": "^5.5.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-day-picker": "^8.10.1", "react-day-picker": "^8.10.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -64,6 +65,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"three": "^0.177.0", "three": "^0.177.0",
"vaul": "^0.9.3", "vaul": "^0.9.3",
"xlsx": "^0.18.5",
"zod": "^3.23.8", "zod": "^3.23.8",
"zustand": "^4.4.7" "zustand": "^4.4.7"
}, },

View File

@ -29,6 +29,7 @@ import Categorias from "./pages/Categorias";
import AdminFAQ from "./pages/AdminFAQ"; import AdminFAQ from "./pages/AdminFAQ";
import Assinatura from "./pages/Assinatura"; import Assinatura from "./pages/Assinatura";
import AvisosContas from "./pages/AvisosContas"; import AvisosContas from "./pages/AvisosContas";
import ImportarExtrato from "./pages/ImportarExtrato";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -81,6 +82,7 @@ function App() {
<Route path="/admin/faq" element={<AdminFAQ />} /> <Route path="/admin/faq" element={<AdminFAQ />} />
<Route path="/assinatura" element={<Assinatura />} /> <Route path="/assinatura" element={<Assinatura />} />
<Route path="/avisos-contas" element={<AvisosContas />} /> <Route path="/avisos-contas" element={<AvisosContas />} />
<Route path="/importar-extrato" element={<ImportarExtrato />} />
</Route> </Route>
{/* Rota 404 */} {/* Rota 404 */}

View File

@ -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 (
<Card className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">Selecione a Conta Bancária</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={onNovaConta}
disabled={isLoading}
>
<Plus className="w-4 h-4 mr-2" />
Nova Conta
</Button>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Conta</label>
<Select value={value} onValueChange={onChange} disabled={isLoading || contas.length === 0}>
<SelectTrigger>
<SelectValue placeholder="Selecione uma conta bancária" />
</SelectTrigger>
<SelectContent>
{contas.map((conta) => (
<SelectItem key={conta.id} value={conta.id}>
<div className="flex items-center gap-2">
<span className="font-medium">{conta.nome}</span>
{conta.banco && (
<span className="text-muted-foreground text-sm">
({conta.banco})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{contas.length === 0 && (
<p className="text-sm text-muted-foreground">
Você ainda não tem contas cadastradas. Clique em "Nova Conta" para adicionar.
</p>
)}
</div>
</Card>
);
};

View File

@ -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<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(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<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFile(file);
}
};
const handleFile = (file: File) => {
setSelectedFile(file);
onFileSelect(file);
};
return (
<div className="space-y-4">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center transition-all
${isDragging ? 'border-primary bg-primary/5' : 'border-border'}
${isProcessing ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-primary'}
`}
onClick={() => !isProcessing && inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept=".ofx,.csv,.xlsx,.xls"
onChange={handleFileInput}
className="hidden"
disabled={isProcessing}
/>
<Upload className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
{selectedFile ? (
<div className="space-y-2">
<div className="flex items-center justify-center gap-2 text-foreground">
<File className="w-5 h-5" />
<span className="font-medium">{selectedFile.name}</span>
</div>
<p className="text-sm text-muted-foreground">
{(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
) : (
<>
<p className="text-lg font-medium text-foreground mb-2">
Arraste seu extrato aqui
</p>
<p className="text-sm text-muted-foreground mb-4">
ou clique para selecionar
</p>
<p className="text-xs text-muted-foreground">
Formatos aceitos: OFX, CSV, XLSX, XLS (máx. 10MB)
</p>
</>
)}
</div>
{selectedFile && !isProcessing && (
<Button
variant="outline"
className="w-full"
onClick={() => {
setSelectedFile(null);
if (inputRef.current) {
inputRef.current.value = '';
}
}}
>
Selecionar outro arquivo
</Button>
)}
</div>
);
};

View File

@ -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<ContaBancaria>) => Promise<void>;
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Nova Conta Bancária</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="nome">Nome da Conta *</Label>
<Input
id="nome"
placeholder="Ex: Conta Corrente Principal"
value={nome}
onChange={(e) => setNome(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="banco">Banco</Label>
<Select value={banco} onValueChange={setBanco}>
<SelectTrigger>
<SelectValue placeholder="Selecione o banco" />
</SelectTrigger>
<SelectContent>
{bancos.map((b) => (
<SelectItem key={b} value={b}>
{b}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tipo">Tipo de Conta</Label>
<Select value={tipo} onValueChange={setTipo}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="corrente">Conta Corrente</SelectItem>
<SelectItem value="poupanca">Poupança</SelectItem>
<SelectItem value="salario">Conta Salário</SelectItem>
<SelectItem value="pagamento">Conta Pagamento</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="saldo">Saldo Inicial</Label>
<Input
id="saldo"
type="number"
step="0.01"
placeholder="0.00"
value={saldoInicial}
onChange={(e) => setSaldoInicial(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={!nome || isSaving}>
{isSaving ? 'Salvando...' : 'Criar Conta'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -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 (
<div className="space-y-6">
{/* Resumo */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total de Transações</p>
<p className="text-2xl font-bold">{transacoes.length}</p>
</div>
<Copy className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-xs text-muted-foreground mt-2">
{novas.length} novas | {duplicadas.length} duplicadas
</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Entradas</p>
<p className="text-2xl font-bold text-green-600">{formatCurrency(totalEntradas)}</p>
</div>
<TrendingUp className="w-8 h-8 text-green-600" />
</div>
<p className="text-xs text-muted-foreground mt-2">
{novas.filter(t => t.tipo === 'entrada').length} transações
</p>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Saídas</p>
<p className="text-2xl font-bold text-red-600">{formatCurrency(totalSaidas)}</p>
</div>
<TrendingDown className="w-8 h-8 text-red-600" />
</div>
<p className="text-xs text-muted-foreground mt-2">
{novas.filter(t => t.tipo === 'saida').length} transações
</p>
</Card>
</div>
{/* Alerta de duplicatas */}
{duplicadas.length > 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{duplicadas.length} transações duplicadas foram encontradas e serão ignoradas na importação.
</AlertDescription>
</Alert>
)}
{/* Tabela de transações */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Transações para Importar</h3>
<div className="max-h-96 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Data</TableHead>
<TableHead>Descrição</TableHead>
<TableHead>Categoria</TableHead>
<TableHead>Tipo</TableHead>
<TableHead className="text-right">Valor</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transacoes.map((transacao) => (
<TableRow
key={transacao.hash_unico}
className={transacao.isDuplicada ? 'opacity-50' : ''}
>
<TableCell className="whitespace-nowrap">
{formatDate(transacao.data)}
</TableCell>
<TableCell className="max-w-xs truncate">
{transacao.descricao}
</TableCell>
<TableCell>
{transacao.isDuplicada ? (
<span className="text-sm text-muted-foreground">
{transacao.categoria}
</span>
) : (
<Select
value={transacao.categoria}
onValueChange={(value) => onCategoriaChange(transacao.hash_unico, value)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{categorias.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</TableCell>
<TableCell>
<Badge variant={transacao.tipo === 'entrada' ? 'default' : 'destructive'}>
{transacao.tipo === 'entrada' ? 'Entrada' : 'Saída'}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
<span className={transacao.tipo === 'entrada' ? 'text-green-600' : 'text-red-600'}>
{formatCurrency(transacao.valor)}
</span>
</TableCell>
<TableCell>
{transacao.isDuplicada ? (
<Badge variant="secondary">Duplicada</Badge>
) : (
<Badge variant="outline">Nova</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</Card>
</div>
);
};

View File

@ -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 <CheckCircle2 className="w-12 h-12 text-green-600" />;
if (log.status === 'erro') return <XCircle className="w-12 h-12 text-red-600" />;
return <AlertCircle className="w-12 h-12 text-yellow-600" />;
};
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{getIcon()}
<DialogTitle className="text-center text-xl">
{getTitulo()}
</DialogTitle>
</div>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Total de registros:</span>
<span className="font-semibold">{log.total_registros}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Importadas:</span>
<span className="font-semibold text-green-600">{log.importados}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Duplicadas (ignoradas):</span>
<span className="font-semibold text-yellow-600">{log.duplicados}</span>
</div>
{log.erros > 0 && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Erros:</span>
<span className="font-semibold text-red-600">{log.erros}</span>
</div>
)}
<div className="flex justify-between items-center pt-3 border-t">
<span className="text-muted-foreground">Valor total importado:</span>
<span className="font-bold text-lg">{formatCurrency(log.valor_total)}</span>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="w-full sm:w-auto">
Fechar
</Button>
<Button onClick={handleVerTransacoes} className="w-full sm:w-auto">
Ver Transações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -14,12 +14,14 @@ import {
BarChart3, BarChart3,
Bell, Bell,
HelpCircle, HelpCircle,
Crown Crown,
FileUp
} from 'lucide-react'; } from 'lucide-react';
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: Home }, { name: 'Dashboard', href: '/dashboard', icon: Home },
{ name: 'Transações', href: '/transacoes', icon: Receipt }, { 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: 'Cartões de Crédito', href: '/cartoes', icon: CreditCard },
{ name: 'Metas', href: '/metas', icon: Target }, { name: 'Metas', href: '/metas', icon: Target },
{ name: 'Calendário', href: '/calendario', icon: Calendar }, { name: 'Calendário', href: '/calendario', icon: Calendar },

View File

@ -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<ContaBancaria[]>([]);
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<ContaBancaria>) => {
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<ContaBancaria>) => {
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
};
};

View File

@ -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<TransacaoImportada[]>([]);
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
};
};

View File

@ -859,6 +859,45 @@ export type Database = {
} }
Relationships: [] 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: { contas_recorrentes: {
Row: { Row: {
ativo: boolean 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: { metas: {
Row: { Row: {
ano: number ano: number
@ -2061,12 +2159,15 @@ export type Database = {
transacoes: { transacoes: {
Row: { Row: {
categoria: string | null categoria: string | null
conta_bancaria_id: string | null
created_at: string created_at: string
detalhes: string | null detalhes: string | null
estabelecimento: string | null estabelecimento: string | null
grupo_id: string | null grupo_id: string | null
hash_unico: string | null
id: number id: number
login: string | null login: string | null
origem: string | null
quando: string | null quando: string | null
tipo: string | null tipo: string | null
user: string | null user: string | null
@ -2074,12 +2175,15 @@ export type Database = {
} }
Insert: { Insert: {
categoria?: string | null categoria?: string | null
conta_bancaria_id?: string | null
created_at?: string created_at?: string
detalhes?: string | null detalhes?: string | null
estabelecimento?: string | null estabelecimento?: string | null
grupo_id?: string | null grupo_id?: string | null
hash_unico?: string | null
id?: number id?: number
login?: string | null login?: string | null
origem?: string | null
quando?: string | null quando?: string | null
tipo?: string | null tipo?: string | null
user?: string | null user?: string | null
@ -2087,18 +2191,29 @@ export type Database = {
} }
Update: { Update: {
categoria?: string | null categoria?: string | null
conta_bancaria_id?: string | null
created_at?: string created_at?: string
detalhes?: string | null detalhes?: string | null
estabelecimento?: string | null estabelecimento?: string | null
grupo_id?: string | null grupo_id?: string | null
hash_unico?: string | null
id?: number id?: number
login?: string | null login?: string | null
origem?: string | null
quando?: string | null quando?: string | null
tipo?: string | null tipo?: string | null
user?: string | null user?: string | null
valor?: number | null valor?: number | null
} }
Relationships: [] Relationships: [
{
foreignKeyName: "transacoes_conta_bancaria_id_fkey"
columns: ["conta_bancaria_id"]
isOneToOne: false
referencedRelation: "contas_bancarias"
referencedColumns: ["id"]
},
]
} }
units: { units: {
Row: { Row: {

View File

@ -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<string>('');
const [arquivo, setArquivo] = useState<File | null>(null);
const [novaContaDialogOpen, setNovaContaDialogOpen] = useState(false);
const [resultadoDialog, setResultadoDialog] = useState(false);
const [logImportacao, setLogImportacao] = useState<LogImportacao | null>(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 (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 p-6">
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={handleVoltar}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-3xl font-bold">Importar Extrato Bancário</h1>
<p className="text-muted-foreground">
Importe seus extratos em OFX, CSV ou XLSX
</p>
</div>
</div>
</div>
{/* Steps indicator */}
<Card className="p-6">
<div className="flex items-center justify-between">
<div className={`flex items-center gap-3 ${step >= 1 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 1 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}>
1
</div>
<span className="font-medium">Selecionar Arquivo</span>
</div>
<div className="flex-1 h-0.5 bg-border mx-4" />
<div className={`flex items-center gap-3 ${step >= 2 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 2 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}>
2
</div>
<span className="font-medium">Revisar Dados</span>
</div>
<div className="flex-1 h-0.5 bg-border mx-4" />
<div className={`flex items-center gap-3 ${step >= 3 ? 'text-primary' : 'text-muted-foreground'}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${step >= 3 ? 'border-primary bg-primary text-primary-foreground' : 'border-muted'}`}>
3
</div>
<span className="font-medium">Confirmar</span>
</div>
</div>
</Card>
{/* Step 1: Upload */}
{step === 1 && (
<div className="space-y-6">
<ContaBancariaSelect
contas={contas}
value={contaSelecionada}
onChange={handleContaChange}
onNovaConta={() => setNovaContaDialogOpen(true)}
isLoading={isLoading}
/>
{contaSelecionada && (
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<UploadIcon className="w-5 h-5 text-primary" />
Upload do Extrato
</h3>
<FileUpload onFileSelect={handleFileSelect} isProcessing={isProcessing} />
</Card>
)}
</div>
)}
{/* Step 2: Preview */}
{step === 2 && transacoes.length > 0 && (
<div className="space-y-6">
<PreviewTransacoes
transacoes={transacoes}
onCategoriaChange={atualizarCategoria}
/>
<Card className="p-6">
<div className="flex justify-between items-center">
<Button variant="outline" onClick={handleVoltar}>
Voltar
</Button>
<Button
onClick={handleImportar}
disabled={isImporting || novasTransacoes.length === 0}
size="lg"
>
<FileCheck className="w-5 h-5 mr-2" />
{isImporting ? 'Importando...' : `Importar ${novasTransacoes.length} Transações`}
</Button>
</div>
</Card>
</div>
)}
</div>
{/* Dialogs */}
<NovaContaDialog
open={novaContaDialogOpen}
onOpenChange={setNovaContaDialogOpen}
onSave={handleNovaConta}
/>
<ResultadoImportacaoDialog
open={resultadoDialog}
onOpenChange={setResultadoDialog}
log={logImportacao}
/>
</div>
);
};
export default ImportarExtrato;

View File

@ -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<ContaBancaria[]> {
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<ContaBancaria>): Promise<ContaBancaria> {
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<ContaBancaria>): Promise<ContaBancaria> {
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<void> {
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;
}
}

View File

@ -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<TransacaoImportada[]> {
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;
}

View File

@ -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<TransacaoImportada[]> {
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);
}

View File

@ -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<TransacaoImportada[]> {
const texto = await file.text();
const transacoes: TransacaoImportada[] = [];
try {
// Regex para extrair transações do OFX
const transacoesRegex = /<STMTTRN>([\s\S]*?)<\/STMTTRN>/g;
let match;
while ((match = transacoesRegex.exec(texto)) !== null) {
const transacaoXml = match[1];
// Extrair campos
const dataMatch = transacaoXml.match(/<DTPOSTED>(\d{8})/);
const valorMatch = transacaoXml.match(/<TRNAMT>([-\d.]+)/);
const descricaoMatch = transacaoXml.match(/<MEMO>(.*?)</) || transacaoXml.match(/<NAME>(.*?)</);
const tipoMatch = transacaoXml.match(/<TRNTYPE>(\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.');
}
}

View File

@ -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<TransacaoImportada[]> {
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);
}

View File

@ -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<TransacaoImportada[]> {
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<LogImportacao> {
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<LogImportacao[]> {
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[]) || [];
}

View File

@ -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';

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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, ''));