Changes
This commit is contained in:
parent
d978fd4d15
commit
86396dfd1a
111
package-lock.json
generated
111
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
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,
|
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 },
|
||||||
|
|||||||
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: []
|
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: {
|
||||||
|
|||||||
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