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:
commit
e7b163eb2d
111
package-lock.json
generated
111
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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() {
|
||||
<Route path="/admin/faq" element={<AdminFAQ />} />
|
||||
<Route path="/assinatura" element={<Assinatura />} />
|
||||
<Route path="/avisos-contas" element={<AvisosContas />} />
|
||||
<Route path="/importar-extrato" element={<ImportarExtrato />} />
|
||||
</Route>
|
||||
|
||||
{/* Rota 404 */}
|
||||
|
||||
69
src/components/importacao/ContaBancariaSelect.tsx
Normal file
69
src/components/importacao/ContaBancariaSelect.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
111
src/components/importacao/FileUpload.tsx
Normal file
111
src/components/importacao/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
135
src/components/importacao/NovaContaDialog.tsx
Normal file
135
src/components/importacao/NovaContaDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
175
src/components/importacao/PreviewTransacoes.tsx
Normal file
175
src/components/importacao/PreviewTransacoes.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
94
src/components/importacao/ResultadoImportacaoDialog.tsx
Normal file
94
src/components/importacao/ResultadoImportacaoDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 },
|
||||
|
||||
99
src/hooks/useContasBancarias.ts
Normal file
99
src/hooks/useContasBancarias.ts
Normal 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
|
||||
};
|
||||
};
|
||||
129
src/hooks/useImportacaoExtrato.ts
Normal file
129
src/hooks/useImportacaoExtrato.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
194
src/pages/ImportarExtrato.tsx
Normal file
194
src/pages/ImportarExtrato.tsx
Normal 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;
|
||||
97
src/services/contasBancariasService.ts
Normal file
97
src/services/contasBancariasService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
63
src/services/importacao/index.ts
Normal file
63
src/services/importacao/index.ts
Normal 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;
|
||||
}
|
||||
124
src/services/importacao/parseCSV.ts
Normal file
124
src/services/importacao/parseCSV.ts
Normal 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);
|
||||
}
|
||||
48
src/services/importacao/parseOFX.ts
Normal file
48
src/services/importacao/parseOFX.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
115
src/services/importacao/parseXLSX.ts
Normal file
115
src/services/importacao/parseXLSX.ts
Normal 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);
|
||||
}
|
||||
132
src/services/importacaoService.ts
Normal file
132
src/services/importacaoService.ts
Normal 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[]) || [];
|
||||
}
|
||||
48
src/types/importacaoTypes.ts
Normal file
48
src/types/importacaoTypes.ts
Normal 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';
|
||||
97
src/utils/categorizacaoAutomatica.ts
Normal file
97
src/utils/categorizacaoAutomatica.ts
Normal 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();
|
||||
}
|
||||
26
src/utils/hashGenerator.ts
Normal file
26
src/utils/hashGenerator.ts
Normal 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);
|
||||
}
|
||||
@ -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, ''));
|
||||
Loading…
Reference in New Issue
Block a user