import { useCallback, useState } from "react"; import { parse, type ParseResult } from "papaparse"; import { format, parse as parseDate } from "date-fns"; import { useMutation, useQuery } from "convex/react"; import { api } from "../../../convex/_generated/api"; import type { Id } from "../../../convex/_generated/dataModel"; import { categorize, parseCounterpartyFromBuchungstext, parseGermanAmount } from "@convex-lib/categorize"; import { resolveAssignedAndEffective } from "@convex-lib/month"; import { computeDedupHash } from "@convex-lib/comdirectMap"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable"; import { toast } from "sonner"; const HEADER = "Buchungstag;Wertstellung (Valuta);Vorgang;Buchungstext;Umsatz in EUR"; function parseComdirectCsv(text: string): Array> { const lines = text.split(/\r?\n/); const rows: Array> = []; let inBlock = false; for (const line of lines) { if (line.trim() === HEADER) { inBlock = true; continue; } if (!inBlock) continue; if ( !line.trim() || line.startsWith("Alter Kontostand") || line.startsWith("Neuer Kontostand") || line.startsWith("Umsätze") || line.startsWith("Keine Umsätze") ) { inBlock = false; continue; } const parsed: ParseResult = parse(line, { delimiter: ";", quoteChar: '"' }); const fields = parsed.data[0]; if (!fields || fields.length < 5) continue; rows.push({ buchungstag: fields[0], valuta: fields[1], vorgang: fields[2], buchungstext: fields[3], betrag: fields[4], }); } return rows; } export function CsvImportWizard() { const accounts = useQuery(api.accounts.list); const settings = useQuery(api.settings.get); const categories = useQuery(api.categories.list); const commitRows = useMutation(api.imports.commitRows); const [step, setStep] = useState<"upload" | "preview" | "done">("upload"); const [filename, setFilename] = useState(""); const [accountId, setAccountId] = useState(); const [rows, setRows] = useState([]); const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null); const processFile = useCallback( async (file: File) => { const buffer = await file.arrayBuffer(); const text = new TextDecoder("iso-8859-1").decode(buffer); const parsed = parseComdirectCsv(text); const ownNames = settings?.ownNames ?? []; const salaryShift = settings?.salaryShift ?? { enabled: true, categoryNames: ["Gehalt & Besoldung"], dayThreshold: 25, }; const preview: PreviewRow[] = []; for (const row of parsed) { const isPending = row.buchungstag.trim().toLowerCase() === "offen"; let bookingDate: string | undefined; if (!isPending) { const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date()); bookingDate = format(d, "yyyy-MM-dd"); } const amount = parseGermanAmount(row.betrag); const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext); const categoryName = categorize(rawText, amount, row.vorgang, ownNames); const { assignedMonth, effectiveMonth } = resolveAssignedAndEffective( bookingDate, amount, categoryName, salaryShift, ); const dedupHash = await computeDedupHash({ accountId, bookingDate, amount, description, vorgang: row.vorgang, }); preview.push({ bookingDate, valueDate: row.valuta ? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd") : undefined, description, counterparty, amount, vorgang: row.vorgang, isPending, rawText, categoryName, assignedMonth, effectiveMonth, dedupHash, }); } setRows(preview); setFilename(file.name); setStep("preview"); }, [settings, accountId], ); const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (file) void processFile(file); }; const handleCommit = async () => { if (!accountId) { toast.error("Bitte Konto wählen"); return; } const res = await commitRows({ filename, source: "comdirect-csv", accountId: accountId as Id<"accounts">, rows: rows.map((r) => ({ bookingDate: r.bookingDate, valueDate: r.valueDate, description: r.description, counterparty: r.counterparty, amount: r.amount, vorgang: r.vorgang, isPending: r.isPending, rawText: r.rawText, categoryName: r.categoryName, assignedMonth: r.assignedMonth, effectiveMonth: r.effectiveMonth, dedupHash: r.dedupHash, categoryId: categories?.find((c) => c.name === r.categoryName)?._id, })), }); setResult({ imported: res.importedCount, skipped: res.skippedCount }); setStep("done"); toast.success(`${res.importedCount} importiert, ${res.skippedCount} übersprungen`); }; if (step === "upload") { return ( CSV-Import (comdirect)
e.preventDefault()} onDrop={handleDrop} >

comdirect-CSV hier ablegen (ISO-8859-1, Semikolon)

e.target.files?.[0] && void processFile(e.target.files[0])} />
); } if (step === "preview") { return (
); } return (

Import abgeschlossen: {result?.imported} Buchungen importiert, {result?.skipped} übersprungen.

); }