From 39bf7ea11ca324c2fc11c723d50d6335c2a77c20 Mon Sep 17 00:00:00 2001 From: Guido Date: Thu, 20 Aug 2026 17:22:00 +0200 Subject: [PATCH] feat: Import-UX runder + PDF-Seiten als Bild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bank-Vorlagen und eigene Vorlagen in EINEM Dropdown (optgroups, typ-getaggte Werte preset:/tpl:) - Speichern überschreibt bei gleichem Namen (Rückfrage); geladene Vorlage belegt den Namen vor - Spaltenvorschau wird automatisch erzeugt (kein „Datei einlesen"-Klick mehr) - Gegen-IBAN in „Weitere Spalten (optional)" verschoben + erklärt; Grundzuordnung zeigt nur Datum/Betrag/Empfänger/Zweck - Beleg-Modus zeigt die hochgeladenen PDF-Seiten als Bild + extrahierten Text Co-Authored-By: Claude Opus 4.8 --- web/src/finance.css | 21 +++++++ web/src/locales/de.json | 8 +++ web/src/locales/en.json | 8 +++ web/src/pages/Import.jsx | 133 +++++++++++++++++++++++++-------------- 4 files changed, 123 insertions(+), 47 deletions(-) diff --git a/web/src/finance.css b/web/src/finance.css index 6210c3c..c868b50 100644 --- a/web/src/finance.css +++ b/web/src/finance.css @@ -656,6 +656,27 @@ tr.row-dup { border-radius: var(--radius-sm); margin-top: var(--sp-2); } +/* Beleg-Seiten als Bildstreifen (horizontal scrollbar). */ +.doc-pages { + display: flex; + gap: var(--sp-3); + overflow-x: auto; + padding-bottom: var(--sp-2); +} +.doc-page { + height: 440px; + width: auto; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: #fff; +} +.import-more { + margin-top: var(--sp-3); +} +.import-more > summary { + cursor: pointer; +} /* DataTable-Zellen einzeilig halten, damit „…" greift (Excel-artig). */ .datatable td { white-space: nowrap; vertical-align: middle; } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 75a517f..29cb451 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -266,7 +266,15 @@ "file": "Datei", "preset": "Bank-Vorlage (vorgefertigt)", "template": "Eigene Vorlage", + "templateAny": "Vorlage", + "presetGroup": "Bank-Vorlagen", + "ownGroup": "Eigene Vorlagen", "templateManual": "Manuell einstellen", + "overwriteConfirm": "Vorlage „{{name}}“ mit den aktuellen Einstellungen überschreiben?", + "autoPreviewNote": "Die Spaltenvorschau erscheint automatisch, sobald Datei und Format passen.", + "moreColumns": "Weitere Spalten (optional)", + "ibanHint": "IBAN der Gegenseite – nur nötig, wenn dein Export eine Spalte mit der IBAN des anderen Kontos enthält; dient der automatischen Transfer-Erkennung.", + "belegPreview": "Beleg", "delimiter": "Trennzeichen", "decimal": "Dezimaltrenner", "dateFormat": "Datumsformat", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b980450..733d269 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -266,7 +266,15 @@ "file": "File", "preset": "Bank preset", "template": "My template", + "templateAny": "Template", + "presetGroup": "Bank templates", + "ownGroup": "My templates", "templateManual": "Configure manually", + "overwriteConfirm": "Overwrite template “{{name}}” with the current settings?", + "autoPreviewNote": "The column preview appears automatically once the file and format are set.", + "moreColumns": "More columns (optional)", + "ibanHint": "Counterparty IBAN – only needed if your export has a column with the other account's IBAN; used for automatic transfer detection.", + "belegPreview": "Document", "delimiter": "Delimiter", "decimal": "Decimal separator", "dateFormat": "Date format", diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index d325559..592a9a0 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -50,6 +50,7 @@ export default function Import() { const [raw, setRaw] = useState(null); const [rows, setRows] = useState(null); const [docText, setDocText] = useState(""); + const [docPages, setDocPages] = useState([]); const [busy, setBusy] = useState(false); const [saveName, setSaveName] = useState(""); @@ -66,6 +67,13 @@ export default function Import() { } 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) } })); @@ -78,13 +86,17 @@ export default function Import() { amount_mode: src.amount_mode, column_map: src.column_map || {}, }); } - function applyTemplate(id) { - const tpl = templates.find((x) => String(x.id) === String(id)); - if (tpl) applyConfig(tpl); - } - function applyPreset(idx) { - const p = presets[Number(idx)]; - if (p) applyConfig(p); + // 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() { @@ -131,6 +143,7 @@ export default function Import() { const res = await api.documentPreview(fd); setRows(res.rows.map((r) => ({ ...r }))); setDocText(res.text || ""); + setDocPages(res.pages || []); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } @@ -144,17 +157,24 @@ export default function Import() { rows, }); toast(t("import.imported", { n: batch.imported_count })); - setRows(null); setRaw(null); setFile(null); + setRows(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() { - if (!saveName.trim()) { toast(t("import.templateName"), "error"); return; } + 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 { - await api.createImportTemplate({ name: saveName.trim(), ...config }); - setSaveName(""); + 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"); } @@ -292,9 +312,9 @@ export default function Import() {

{t("import.title")}

+ onClick={() => { setMode("csv"); setRows(null); setRaw(null); setDocText(""); setDocPages([]); }}>{t("import.modeCsv")} + onClick={() => { setMode("doc"); setRows(null); setRaw(null); setDocText(""); setDocPages([]); }}>{t("import.modeDoc")}
@@ -312,7 +332,7 @@ export default function Import() { {t("import.file")} { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); setDocText(""); }} /> + onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); setDocText(""); setDocPages([]); }} /> {mode === "doc" && ( @@ -324,24 +344,22 @@ export default function Import() { )}
- {presets.length > 0 && ( - - )} - {templates.length > 0 && ( - - )} +
@@ -400,19 +418,27 @@ export default function Import() { {t("import.hasHeader")} - +

{t("import.autoPreviewNote")}

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

{t("import.belegPreview")}

+ {docPages.length > 0 && ( +
+ {docPages.map((src, i) => ( + {`Seite + ))} +
+ )} + {docText && ( +
+ {t("import.extractedText")} +
{docText}
+
+ )}
)} @@ -436,13 +462,12 @@ export default function Import() {
{[ - ["date", t("import.colDate"), true], + ["date", t("import.colDate")], ...(config.amount_mode === "debit_credit" - ? [["debit", t("import.colDebit"), true], ["credit", t("import.colCredit"), true]] - : [["amount", t("import.colAmount"), true]]), - ["payee", t("import.colPayee"), false], - ["purpose", t("import.colPurpose"), false], - ["counterparty_iban", t("import.colIban"), false], + ? [["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")}

+
+ +
+
+