feat: Import-UX runder + PDF-Seiten als Bild

- 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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-20 17:22:00 +02:00
co-authored by Claude Opus 4.8
parent 5f88660bcc
commit 39bf7ea11c
4 changed files with 123 additions and 47 deletions
+21
View File
@@ -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; }
+8
View File
@@ -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",
+8
View File
@@ -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",
+75 -36
View File
@@ -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) {
// 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);
if (tpl) { applyConfig(tpl); setSaveName(tpl.name); } // Name vorbelegen -> Speichern überschreibt
}
function applyPreset(idx) {
const p = presets[Number(idx)];
if (p) applyConfig(p);
}
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() {
<h1>{t("import.title")}</h1>
<div className="seg" style={{ width: 240, marginBottom: 0 }}>
<button type="button" className={`seg-btn${mode === "csv" ? " active" : ""}`}
onClick={() => { setMode("csv"); setRows(null); setRaw(null); setDocText(""); }}>{t("import.modeCsv")}</button>
onClick={() => { setMode("csv"); setRows(null); setRaw(null); setDocText(""); setDocPages([]); }}>{t("import.modeCsv")}</button>
<button type="button" className={`seg-btn${mode === "doc" ? " active" : ""}`}
onClick={() => { setMode("doc"); setRows(null); setRaw(null); setDocText(""); }}>{t("import.modeDoc")}</button>
onClick={() => { setMode("doc"); setRows(null); setRaw(null); setDocText(""); setDocPages([]); }}>{t("import.modeDoc")}</button>
</div>
</div>
@@ -312,7 +332,7 @@ export default function Import() {
<span>{t("import.file")}</span>
<input ref={fileRef} type="file"
accept={mode === "doc" ? ".pdf,application/pdf,image/*" : ".csv,text/csv,text/plain"}
onChange={(e) => { 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([]); }} />
</label>
</div>
{mode === "doc" && (
@@ -324,24 +344,22 @@ export default function Import() {
</>
)}
<div className="row" style={{ display: mode === "csv" ? undefined : "none" }}>
{presets.length > 0 && (
<label className="grow">
<span>{t("import.preset")}</span>
<select defaultValue="" onChange={(e) => applyPreset(e.target.value)}>
<span>{t("import.templateAny")}</span>
<select defaultValue="" onChange={(e) => applySource(e.target.value)}>
<option value="">{t("import.templateManual")}</option>
{presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}
</select>
</label>
{presets.length > 0 && (
<optgroup label={t("import.presetGroup")}>
{presets.map((p, i) => <option key={`p${i}`} value={`preset:${i}`}>{p.name}</option>)}
</optgroup>
)}
{templates.length > 0 && (
<label className="grow">
<span>{t("import.template")}</span>
<select defaultValue="" onChange={(e) => applyTemplate(e.target.value)}>
<option value="">{t("import.templateManual")}</option>
{templates.map((tp) => <option key={tp.id} value={tp.id}>{tp.name}</option>)}
<optgroup label={t("import.ownGroup")}>
{templates.map((tp) => <option key={`t${tp.id}`} value={`tpl:${tp.id}`}>{tp.name}</option>)}
</optgroup>
)}
</select>
</label>
)}
</div>
</div>
@@ -400,19 +418,27 @@ export default function Import() {
<span>{t("import.hasHeader")}</span>
</label>
</div>
<button className="btn" onClick={loadRaw} disabled={busy || !file}>
<Icon name="search" size={15} /> {t("import.readFile")}
</button>
<p className="muted small">{t("import.autoPreviewNote")}</p>
</div>
)}
{/* Extrahierter Text (PDF/Bild) */}
{mode === "doc" && docText && (
{/* Beleg-Vorschau: Seiten als Bild + extrahierter Text (PDF/Bild) */}
{mode === "doc" && (docPages.length > 0 || docText) && (
<div className="card">
<details>
<h2>{t("import.belegPreview")}</h2>
{docPages.length > 0 && (
<div className="doc-pages">
{docPages.map((src, i) => (
<img key={i} src={src} alt={`Seite ${i + 1}`} className="doc-page" loading="lazy" />
))}
</div>
)}
{docText && (
<details style={{ marginTop: "var(--sp-3)" }}>
<summary className="muted">{t("import.extractedText")}</summary>
<pre className="doc-text">{docText}</pre>
</details>
)}
</div>
)}
@@ -436,13 +462,12 @@ export default function Import() {
<div className="map-grid">
{[
["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]) => (
<label key={field}>
<span>{label}</span>
@@ -454,6 +479,20 @@ export default function Import() {
))}
</div>
<details className="import-more">
<summary className="muted small">{t("import.moreColumns")}</summary>
<p className="muted small">{t("import.ibanHint")}</p>
<div className="map-grid">
<label>
<span>{t("import.colIban")}</span>
<select value={config.column_map.counterparty_iban ?? ""} onChange={(e) => setCol("counterparty_iban", e.target.value)}>
<option value="">—</option>
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
</select>
</label>
</div>
</details>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
<button className="btn primary" onClick={loadPreview} disabled={busy}>
<Icon name="chevron-right" size={15} /> {t("import.makePreview")}