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 AccountSelect from "../components/AccountSelect"; 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); // Dateien, die per Kurzbefehl vom Telefon hereinkamen. const [uploads, setUploads] = useState([]); const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | "" 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() { api.listUploads().then(setUploads).catch(() => {}); 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) { setQuelle(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 } } // Beim Kontowechsel die Einstellung vorschlagen, mit der zuletzt in DIESES // Konto importiert wurde. Ohne das waehlt man jedes Mal neu – und liest im // schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein. useEffect(() => { if (!accountId || !batches.length) return; const letzter = batches.find( (b) => String(b.account_id) === String(accountId) && (b.template_id || b.preset)); if (!letzter) return; if (letzter.template_id && templates.some((x) => x.id === letzter.template_id)) { applySource(`tpl:${letzter.template_id}`); return; } const i = presets.findIndex((p) => p.name === letzter.preset); if (i >= 0) applySource(`preset:${i}`); }, [accountId, batches, templates, presets]); 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 [art, id] = quelle.split(":"); const batch = await api.importCommit({ account_id: Number(accountId), template_id: art === "tpl" ? Number(id) : null, preset: art === "preset" ? presets[Number(id)]?.name || null : 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"); } } // Nach einer Reihe von Versuchen stehen schnell zwanzig rueckgaengig gemachte // Importe im Verlauf. Die einzeln wegzuklicken ist Fleissarbeit ohne Nutzen. async function alleErledigtenEntfernen() { const weg = batches.filter((b) => b.status === "undone"); if (!weg.length) return; if (!(await confirm({ title: t("import.removeAllUndone"), message: t("import.removeAllUndoneConfirm", { n: weg.length }), danger: true, confirmLabel: t("common.delete"), }))) return; try { for (const b of weg) await api.removeImportBatch(b.id); loadStamm(); } catch (e) { toast(e.message, "error"); } } // Die abgelieferte Datei so behandeln, als haette man sie hier ausgewaehlt – // damit gilt der ganze normale Weg samt Vorschau und Saldo-Abgleich. async function uploadUebernehmen(u) { try { const antwort = await fetch(`/api/imports/uploads/${u.id}/content`, { credentials: "include" }); if (!antwort.ok) throw new Error(await antwort.text()); const blob = await antwort.blob(); setAccountId(String(u.account_id)); setFile(new File([blob], u.filename, { type: "text/csv" })); setRaw(null); setRows(null); setMode("csv"); toast(t("import.phoneFileLoaded", { name: u.filename })); } catch (e) { toast(e.message, "error"); } } async function uploadVerwerfen(u) { if (!(await confirm({ title: t("import.discardPhoneFile"), message: t("import.discardPhoneFileConfirm", { name: u.filename }), danger: true, confirmLabel: t("common.delete"), }))) return; try { await api.deleteUpload(u.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]); // Spalten, die die Datei in wenige Gruppen teilen (Revolut: „Produkt"). const filterSpalten = useMemo( () => colOptions.filter((c) => raw?.filter_values?.[String(c.i)]), [colOptions, 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 && ( {r.duplicate_reason === "inbox" ? t("import.dupInbox") : 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: 196, fixed: true, render: (b) => (b.status === "committed" ? ( ) : ( )), }, // 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]) => ( ))}
{/* Revolut legt alle Produkte in EINE Datei. Ohne Filter zaehlen die Bewegungen doppelt – deshalb steht der Filter offen, sobald die Datei eine Spalte mit wenigen wiederkehrenden Werten hat. */} {filterSpalten.length > 0 && (
{t("import.filterTitle")}

{t("import.filterHint")}

)}
{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")}

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. */}
)} {uploads.length > 0 && (

{t("import.fromPhone")}

{t("import.fromPhoneHint")}

{uploads.map((u) => (
{u.filename}
{accountById[u.account_id]?.name || "—"} · {Math.round(u.size_bytes / 1024)} KB {u.created_at ? ` · ${formatDate(u.created_at.slice(0, 10))}` : ""}
))}
)} {/* Verlauf */} {batches.length > 0 && (

{t("import.history")}

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