import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useSettings } from "../settings"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "../components/Icon"; import CategorySelect from "../components/CategorySelect"; import DataTable from "../components/DataTable"; import { gruppenFuer } from "../kategorien"; import { formatMoney } from "../format"; // Eine Uhrzeit im Wert ist unkritisch – parse_date im Backend schneidet sie ab. // Die Varianten mit Uhrzeit stehen trotzdem hier, damit die Bank-Vorlagen im // Dropdown ihren echten Eintrag zeigen statt eines leeren Feldes. const DATE_FORMATS = [ { v: "%d.%m.%Y", l: "TT.MM.JJJJ" }, { v: "%Y-%m-%d", l: "JJJJ-MM-TT" }, { v: "%d/%m/%Y", l: "TT/MM/JJJJ" }, { v: "%m/%d/%Y", l: "MM/TT/JJJJ" }, { v: "%d.%m.%y", l: "TT.MM.JJ" }, { v: "%Y-%m-%d %H:%M:%S", l: "JJJJ-MM-TT SS:MM:SS" }, { v: "%Y-%m-%d %H:%M:%S.%f", l: "JJJJ-MM-TT SS:MM:SS.s" }, { v: "%d.%m.%Y %H:%M:%S", l: "TT.MM.JJJJ SS:MM:SS" }, ]; function defaultConfig() { return { delimiter: ";", decimal_sep: ",", date_format: "%d.%m.%Y", encoding: "utf-8", has_header: true, skip_rows: 0, amount_mode: "signed", column_map: {}, filter_column: null, filter_value: "", }; } export default function Import() { const { t } = useTranslation(); const { formatDate } = useSettings(); const confirm = useConfirm(); const toast = useToast(); const fileRef = useRef(null); const [accounts, setAccounts] = useState([]); const [groups, setGroups] = useState([]); const [templates, setTemplates] = useState([]); const [presets, setPresets] = useState([]); const [batches, setBatches] = useState([]); const [mode, setMode] = useState("csv"); // "csv" | "doc" const [accountId, setAccountId] = useState(""); const [file, setFile] = useState(null); const [config, setConfig] = useState(defaultConfig()); const [raw, setRaw] = useState(null); const [rows, setRows] = useState(null); const [recon, setRecon] = useState(null); const [docText, setDocText] = useState(""); const [docPages, setDocPages] = useState([]); const [busy, setBusy] = useState(false); const [saveName, setSaveName] = useState(""); const accountById = useMemo(() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]); async function loadStamm() { try { const [a, g, tp, pr, b] = await Promise.all([ api.listAccounts(), api.listCategories(), api.importTemplates(), api.importPresets(), api.importBatches(), ]); setAccounts(a); setGroups(g); setTemplates(tp); setPresets(pr); setBatches(b); if (a[0] && !accountId) setAccountId(String(a[0].id)); } catch (e) { toast(e.message, "error"); } } useEffect(() => { loadStamm(); }, []); // CSV: Spaltenvorschau automatisch erzeugen, sobald Datei/Format bereitstehen – // kein separater „Datei einlesen"-Klick mehr nötig. useEffect(() => { if (mode === "csv" && file) loadRaw(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [file, mode, config.delimiter, config.encoding, config.has_header, config.skip_rows]); function setCfg(patch) { setConfig((c) => ({ ...c, ...patch })); } function setCol(field, value) { setConfig((c) => ({ ...c, column_map: { ...c.column_map, [field]: value === "" ? null : Number(value) } })); } function applyConfig(src) { setConfig({ delimiter: src.delimiter, decimal_sep: src.decimal_sep, date_format: src.date_format, encoding: src.encoding, has_header: src.has_header, skip_rows: src.skip_rows, amount_mode: src.amount_mode, column_map: src.column_map || {}, filter_column: src.filter_column ?? null, filter_value: src.filter_value || "", }); } // Ein zusammengeführtes Vorlagen-Dropdown: Werte typ-getaggt "preset:i" / "tpl:id". function applySource(value) { if (!value) return; const [kind, id] = value.split(":"); if (kind === "preset") { const p = presets[Number(id)]; if (p) { applyConfig(p); setSaveName(p.name); } } else if (kind === "tpl") { const tpl = templates.find((x) => String(x.id) === String(id)); if (tpl) { applyConfig(tpl); setSaveName(tpl.name); } // Name vorbelegen -> Speichern überschreibt } } function baseForm() { const fd = new FormData(); fd.append("file", file); fd.append("delimiter", config.delimiter); fd.append("encoding", config.encoding); fd.append("has_header", config.has_header ? "true" : "false"); fd.append("skip_rows", String(config.skip_rows || 0)); return fd; } async function loadRaw() { if (!file) { toast(t("import.needFile"), "error"); return; } setBusy(true); try { setRaw(await api.importRaw(baseForm())); setRows(null); setRecon(null); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function loadPreview() { if (!file || !accountId) { toast(t("import.needFileAccount"), "error"); return; } setBusy(true); try { const fd = baseForm(); fd.append("account_id", accountId); fd.append("decimal_sep", config.decimal_sep); fd.append("date_format", config.date_format); fd.append("amount_mode", config.amount_mode); fd.append("column_map", JSON.stringify(config.column_map)); if (config.filter_column != null && config.filter_value) { fd.append("filter_column", String(config.filter_column)); fd.append("filter_value", config.filter_value); } const res = await api.importPreview(fd); setRows(res.rows.map((r) => ({ ...r }))); setRecon(res.reconciliation || null); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function loadDoc() { if (!file || !accountId) { toast(t("import.needFileAccount"), "error"); return; } setBusy(true); try { const fd = new FormData(); fd.append("file", file); fd.append("account_id", accountId); const res = await api.documentPreview(fd); setRows(res.rows.map((r) => ({ ...r }))); setRecon(null); setDocText(res.text || ""); setDocPages(res.pages || []); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function commit() { setBusy(true); try { const batch = await api.importCommit({ account_id: Number(accountId), template_id: null, filename: file?.name || "", rows, }); toast(t("import.imported", { n: batch.imported_count })); setRows(null); setRecon(null); setRaw(null); setFile(null); setDocText(""); setDocPages([]); if (fileRef.current) fileRef.current.value = ""; loadStamm(); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function saveTemplate() { const name = saveName.trim(); if (!name) { toast(t("import.templateName"), "error"); return; } const existing = templates.find((tp) => tp.name.toLowerCase() === name.toLowerCase()); if (existing && !(await confirm({ title: t("import.saveTemplate"), message: t("import.overwriteConfirm", { name: existing.name }), confirmLabel: t("common.save"), }))) return; try { if (existing) await api.updateImportTemplate(existing.id, { name, ...config }); else await api.createImportTemplate({ name, ...config }); setTemplates(await api.importTemplates()); toast(t("settings.saved")); } catch (e) { toast(e.message, "error"); } } async function entfernen(b) { if (!(await confirm({ title: t("import.removeEntry"), message: t("import.removeEntryConfirm", { file: b.filename || "—" }), danger: true, confirmLabel: t("common.delete"), }))) return; try { await api.removeImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } } async function undo(b) { if (!(await confirm({ title: t("import.undo"), message: t("import.undoConfirm", { file: b.filename }), danger: true, confirmLabel: t("import.undo") }))) return; try { await api.undoImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } } const colOptions = useMemo(() => { const n = raw ? Math.max(raw.header?.length || 0, ...(raw.rows || []).map((r) => r.length), 0) : 0; return Array.from({ length: n }, (_, i) => ({ i, label: `${i}${raw?.header?.[i] ? ` · ${raw.header[i]}` : ""}`, })); }, [raw]); const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]); const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]); const includeCount = rows ? rows.filter((r) => r.include).length : 0; // Aktualisiert eine Zeile über ihren stabilen Erkennungs-Index (die Anzeige in // der Tabelle kann sortiert/gefiltert sein, der Index bleibt gleich). function updateRow(rowIndex, patch) { setRows((rs) => rs.map((r) => (r.index === rowIndex ? { ...r, ...patch } : r))); } const acc = accountById[Number(accountId)]; const reviewColumns = useMemo(() => [ { key: "include", header: "", width: 42, fixed: true, render: (r) => ( updateRow(r.index, { include: e.target.checked })} /> ), }, { key: "date", header: t("transactions.date"), width: 112, sortValue: (r) => r.date || "", render: (r) => (r.date ? {formatDate(r.date)} : {t("import.badDate")}), }, { key: "amount", header: t("transactions.amount"), align: "num", width: 120, sortValue: (r) => r.amount_cents || 0, render: (r) => ( {formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")} ), }, { key: "type", header: t("import.typeCol"), width: 128, filterText: (r) => t(`transactions.${r.type}`), render: (r) => ( ), }, { key: "payee", header: t("import.payeeCol"), grow: true, min: 200, filterText: (r) => r.payee || r.purpose || "", render: (r) => ( {r.payee || r.purpose || ""} {r.is_duplicate && {t("import.duplicate")}} {r.is_counterpart && {t("import.counterpart")}} {r.needs_target && {t("import.needsTarget")}} {r.skip_reason === "state" && ( {r.state || t("import.skipState")} )} {r.skip_reason === "currency" && ( {r.row_currency || t("import.skipCurrency")} )} ), }, { key: "category", header: t("transactions.category"), width: 210, min: 160, render: (r) => (r.type === "transfer" ? ( ) : ( updateRow(r.index, { category_id: id != null ? Number(id) : null })} allowNone noneLabel={t("transactions.uncategorized")} placeholder={t("transactions.uncategorized")} /> )), }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [t, formatDate, acc, accounts, accountId, incomeGroups, expenseGroups]); const historyColumns = useMemo(() => [ { key: "file", header: t("import.file"), grow: true, min: 180, filterText: (b) => b.filename || "", render: (b) => ( {b.filename || "—"}{" "} {accountById[b.account_id]?.name} ), }, { key: "date", header: t("transactions.date"), width: 150, sortValue: (b) => b.created_at, render: (b) => {formatDate(b.created_at)}, }, { key: "count", header: t("import.importedCount", { n: "" }).trim(), width: 110, align: "num", sortValue: (b) => b.imported_count, render: (b) => {t("import.importedCount", { n: b.imported_count })}, }, { key: "action", header: "", width: 120, fixed: true, render: (b) => (b.status === "committed" ? ( ) : ( {t("import.undone")} )), }, // eslint-disable-next-line react-hooks/exhaustive-deps ], [t, formatDate, accountById]); return (

{t("import.title")}

{/* Schritt 1: Konto + Datei */}

{t("import.step1")}

{mode === "doc" && ( <>

{t("import.docHint")}

)}
{/* Schritt 2: Format (nur CSV) */} {mode === "csv" && (

{t("import.step2")}

{t("import.autoPreviewNote")}

)} {/* Beleg-Vorschau: Seiten als Bild + extrahierter Text (PDF/Bild) */} {mode === "doc" && (docPages.length > 0 || docText) && (

{t("import.belegPreview")}

{docPages.length > 0 && (
{docPages.map((src, i) => ( {`Seite ))}
)} {docText && (
{t("import.extractedText")}
{docText}
)}
)} {/* Schritt 3: Spaltenzuordnung */} {raw && (

{t("import.step3")}

{t("import.rawHint")}

{colOptions.map((c) => )} {raw.rows.map((r, ri) => ( {colOptions.map((c) => )} ))}
{c.label}
{r[c.i] ?? ""}
{[ ["date", t("import.colDate")], ...(config.amount_mode === "debit_credit" ? [["debit", t("import.colDebit")], ["credit", t("import.colCredit")]] : [["amount", t("import.colAmount")]]), ["payee", t("import.colPayee")], ["purpose", t("import.colPurpose")], ].map(([field, label]) => ( ))}
{t("import.moreColumns")}

{t("import.ibanHint")}

{[ ["counterparty_iban", t("import.colIban")], ["fee", t("import.colFee")], ["currency", t("import.colCurrency")], ["state", t("import.colState")], ["balance", t("import.colBalance")], ].map(([field, label]) => ( ))}

{t("import.extraColsHint")}

{t("import.filterHint")}

setSaveName(e.target.value)} />

{t("import.makePreviewNote")}

)} {/* Schritt 4: Prüfansicht */} {rows && (

{t("import.step4")}

{t("import.summary", { total: rows.length, n: includeCount })}

{rows.length > 0 && includeCount === 0 && (
{t("import.noneValid")}
)} {recon?.available && (
{recon.ok ? ( {t("import.reconOk", { n: recon.checked })} ) : ( <>
{t("import.reconDiff", { file: formatMoney(recon.final_balance_cents, acc?.currency || "EUR"), app: formatMoney(recon.computed_final_cents, acc?.currency || "EUR"), diff: formatMoney(recon.difference_cents, acc?.currency || "EUR"), })}
{recon.first_mismatch && (
{t("import.reconFirst", { date: recon.first_mismatch.date ? formatDate(recon.first_mismatch.date) : "?", payee: recon.first_mismatch.payee || "—", expected: formatMoney(recon.first_mismatch.expected_cents, acc?.currency || "EUR"), actual: formatMoney(recon.first_mismatch.actual_cents, acc?.currency || "EUR"), })}
)} {/* Häufigste Ursache: mehrere Konten in einer Datei, kein Zeilenfilter. */} {config.filter_column == null && (
{t("import.reconMixedHint")}
)} )}
{t("import.reconOpening", { value: formatMoney(recon.opening_balance_cents, acc?.currency || "EUR"), })}
)} r.index} rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate || r.is_counterpart ? "row-dup" : "")} columns={reviewColumns} /> {/* Zweiter Knopf unter der Liste: bei vielen Zeilen muss man zum Buchen sonst wieder ganz nach oben scrollen. */}
)} {/* Verlauf */} {batches.length > 0 && (

{t("import.history")}

b.id} rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")} columns={historyColumns} />
)}
); }