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:
@@ -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; }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+86
-47
@@ -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() {
|
||||
<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)}>
|
||||
<option value="">{t("import.templateManual")}</option>
|
||||
{presets.map((p, i) => <option key={i} value={i}>{p.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{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>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="grow">
|
||||
<span>{t("import.templateAny")}</span>
|
||||
<select defaultValue="" onChange={(e) => applySource(e.target.value)}>
|
||||
<option value="">{t("import.templateManual")}</option>
|
||||
{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 && (
|
||||
<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>
|
||||
<summary className="muted">{t("import.extractedText")}</summary>
|
||||
<pre className="doc-text">{docText}</pre>
|
||||
</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")}
|
||||
|
||||
Reference in New Issue
Block a user