feat: Phase 4 – CSV-Import (Templates, Prüfansicht, Duplikat/Transfer-Erkennung, Undo)

Backend:
- Modelle + Migration 0005: import_templates, import_batches, categorization_rules;
  transactions um import_batch_id + import_hash erweitert.
- services/csv_import: Datei parsen (Trennzeichen, Dezimal-/Datumsformat, Kodierung,
  Header/Skip, Vorzeichen vs. Soll/Haben), Vorschau mit Duplikat-Erkennung (Hash),
  interner-Transfer-Erkennung via IBAN, lernender Auto-Kategorisierung; Buchen mit
  Batch und Undo.
- routers/imports: Templates (CRUD), Roh-Vorschau, Prüf-Vorschau, Buchen, Verlauf,
  Rückgängig. pytest: Parsing, Duplikate, Transfer, Buchen, Undo, Lernen.

Frontend:
- Import-Seite: Konto+Datei, Format/Spaltenzuordnung (mit Roh-Vorschau),
  editierbare Prüfansicht (Kategorie/Typ/Zielkonto, Duplikate markiert),
  Buchen, Import-Verlauf mit Undo; Vorlagen speichern; i18n DE/EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 20:06:50 +02:00
co-authored by Claude Opus 4.8
parent a31ba0c785
commit 1381967aae
14 changed files with 1366 additions and 2 deletions
+3
View File
@@ -11,6 +11,7 @@ import Accounts from "./pages/Accounts";
import Categories from "./pages/Categories";
import Transactions from "./pages/Transactions";
import Budget from "./pages/Budget";
import Import from "./pages/Import";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
@@ -46,6 +47,7 @@ function Sidebar() {
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
<NavItem to="/import" icon="download" label={t("nav.import")} />
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
{isAdmin && (
<>
@@ -131,6 +133,7 @@ export default function App() {
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
<Route path="/import" element={<Protected wide><Import /></Protected>} />
<Route path="/settings" element={<Protected><Settings /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} />
+11
View File
@@ -186,4 +186,15 @@ export const api = {
cardBalances: () => request("/dashboard/balances"),
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
// CSV-Import
importTemplates: () => request("/imports/templates"),
createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }),
updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }),
deleteImportTemplate: (id) => request(`/imports/templates/${id}`, { method: "DELETE" }),
importRaw: (formData) => request("/imports/raw", { method: "POST", formData }),
importPreview: (formData) => request("/imports/preview", { method: "POST", formData }),
importCommit: (body) => request("/imports/commit", { method: "POST", body }),
importBatches: () => request("/imports/batches"),
undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }),
};
+34
View File
@@ -526,3 +526,37 @@ tr.budget-total-row td {
border-right-color: var(--border-strong);
border-bottom-color: var(--border-strong);
}
/* ---------- CSV-Import ---------- */
.table-scroll {
overflow-x: auto;
}
.raw-table {
font-size: 0.8rem;
}
.raw-table th,
.raw-table td {
white-space: nowrap;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.raw-table th {
color: var(--muted);
font-weight: 600;
}
.map-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: var(--sp-3);
margin-top: var(--sp-3);
}
.map-grid label {
margin-bottom: 0;
}
tr.row-invalid {
opacity: 0.45;
}
tr.row-dup {
opacity: 0.55;
}
+47
View File
@@ -195,5 +195,52 @@
"incomeLabel": "Bezeichnung",
"incomeCategory": "Kategorie",
"targetAccount": "Zielkonto"
},
"import": {
"title": "CSV-Import",
"step1": "1 · Konto & Datei",
"step2": "2 · Format",
"step3": "3 · Spalten zuordnen",
"step4": "4 · Prüfen & buchen",
"account": "Zielkonto",
"file": "CSV-Datei",
"template": "Vorlage",
"templateManual": "Manuell einstellen",
"delimiter": "Trennzeichen",
"decimal": "Dezimaltrenner",
"dateFormat": "Datumsformat",
"encoding": "Kodierung",
"amountMode": "Betragsspalten",
"skipRows": "Zeilen überspringen",
"hasHeader": "Erste Zeile ist Kopfzeile",
"modeSigned": "Ein Betrag (mit Vorzeichen)",
"modeDebitCredit": "Getrennte Soll-/Haben-Spalten",
"modeAbsExpense": "Ein Betrag (immer Ausgabe)",
"readFile": "Datei einlesen",
"makePreview": "Vorschau erstellen",
"saveTemplate": "Als Vorlage speichern",
"templateName": "Vorlagenname",
"colDate": "Datum",
"colAmount": "Betrag",
"colDebit": "Soll (Ausgang)",
"colCredit": "Haben (Eingang)",
"colPayee": "Empfänger",
"colPurpose": "Verwendungszweck",
"colIban": "Gegen-IBAN",
"makePreviewHint": "Ordne mindestens Datum und Betrag zu.",
"commit": "{{n}} Buchungen übernehmen",
"imported": "{{n}} Buchungen importiert.",
"importedCount": "{{n}} gebucht",
"typeCol": "Typ",
"payeeCol": "Empfänger / Zweck",
"badDate": "Datum?",
"duplicate": "Duplikat",
"chooseAccount": "Konto wählen",
"history": "Import-Verlauf",
"undo": "Rückgängig",
"undoConfirm": "Alle Buchungen aus {{file}} wieder entfernen?",
"undone": "rückgängig gemacht",
"needFile": "Bitte zuerst eine CSV-Datei wählen.",
"needFileAccount": "Bitte Konto und Datei wählen."
}
}
+47
View File
@@ -195,5 +195,52 @@
"incomeLabel": "Label",
"incomeCategory": "Category",
"targetAccount": "Target account"
},
"import": {
"title": "CSV import",
"step1": "1 · Account & file",
"step2": "2 · Format",
"step3": "3 · Map columns",
"step4": "4 · Review & import",
"account": "Target account",
"file": "CSV file",
"template": "Template",
"templateManual": "Configure manually",
"delimiter": "Delimiter",
"decimal": "Decimal separator",
"dateFormat": "Date format",
"encoding": "Encoding",
"amountMode": "Amount columns",
"skipRows": "Skip rows",
"hasHeader": "First row is a header",
"modeSigned": "Single amount (signed)",
"modeDebitCredit": "Separate debit/credit columns",
"modeAbsExpense": "Single amount (always expense)",
"readFile": "Read file",
"makePreview": "Build preview",
"saveTemplate": "Save as template",
"templateName": "Template name",
"colDate": "Date",
"colAmount": "Amount",
"colDebit": "Debit (out)",
"colCredit": "Credit (in)",
"colPayee": "Payee",
"colPurpose": "Purpose",
"colIban": "Counterparty IBAN",
"makePreviewHint": "Map at least date and amount.",
"commit": "Import {{n}} transactions",
"imported": "Imported {{n}} transactions.",
"importedCount": "{{n}} imported",
"typeCol": "Type",
"payeeCol": "Payee / purpose",
"badDate": "Date?",
"duplicate": "Duplicate",
"chooseAccount": "Choose account",
"history": "Import history",
"undo": "Undo",
"undoConfirm": "Remove all transactions from {{file}}?",
"undone": "undone",
"needFile": "Please choose a CSV file first.",
"needFileAccount": "Please choose an account and a file."
}
}
+397
View File
@@ -0,0 +1,397 @@
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 { formatMoney } from "../format";
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" },
];
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: {},
};
}
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 [batches, setBatches] = useState([]);
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 [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, b] = await Promise.all([
api.listAccounts(), api.listCategories(), api.importTemplates(), api.importBatches(),
]);
setAccounts(a); setGroups(g); setTemplates(tp); setBatches(b);
if (a[0] && !accountId) setAccountId(String(a[0].id));
} catch (e) { toast(e.message, "error"); }
}
useEffect(() => { loadStamm(); }, []);
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 applyTemplate(id) {
const tpl = templates.find((x) => String(x.id) === String(id));
if (!tpl) return;
setConfig({
delimiter: tpl.delimiter, decimal_sep: tpl.decimal_sep, date_format: tpl.date_format,
encoding: tpl.encoding, has_header: tpl.has_header, skip_rows: tpl.skip_rows,
amount_mode: tpl.amount_mode, column_map: tpl.column_map || {},
});
}
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);
} 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));
const res = await api.importPreview(fd);
setRows(res.rows.map((r) => ({ ...r })));
} 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); setRaw(null); setFile(null);
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; }
try {
await api.createImportTemplate({ name: saveName.trim(), ...config });
setSaveName("");
setTemplates(await api.importTemplates());
toast(t("settings.saved"));
} 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(() => groups.filter((g) => g.kind === "expense"), [groups]);
const incomeGroups = useMemo(() => groups.filter((g) => g.kind === "income"), [groups]);
const includeCount = rows ? rows.filter((r) => r.include).length : 0;
function updateRow(idx, patch) {
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
}
const acc = accountById[Number(accountId)];
return (
<div>
<h1>{t("import.title")}</h1>
{/* Schritt 1: Konto + Datei */}
<div className="card">
<h2>{t("import.step1")}</h2>
<div className="row">
<label className="grow">
<span>{t("import.account")}</span>
<select value={accountId} onChange={(e) => setAccountId(e.target.value)}>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.currency})</option>)}
</select>
</label>
<label className="grow">
<span>{t("import.file")}</span>
<input ref={fileRef} type="file" accept=".csv,text/csv,text/plain"
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); }} />
</label>
</div>
{templates.length > 0 && (
<label>
<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>
)}
</div>
{/* Schritt 2: Format */}
<div className="card">
<h2>{t("import.step2")}</h2>
<div className="row">
<label>
<span>{t("import.delimiter")}</span>
<select value={config.delimiter} onChange={(e) => setCfg({ delimiter: e.target.value })}>
<option value=";">Semikolon ;</option>
<option value=",">Komma ,</option>
<option value={"\t"}>Tab</option>
</select>
</label>
<label>
<span>{t("import.decimal")}</span>
<select value={config.decimal_sep} onChange={(e) => setCfg({ decimal_sep: e.target.value })}>
<option value=",">Komma (1.234,56)</option>
<option value=".">Punkt (1,234.56)</option>
</select>
</label>
<label>
<span>{t("import.dateFormat")}</span>
<select value={config.date_format} onChange={(e) => setCfg({ date_format: e.target.value })}>
{DATE_FORMATS.map((f) => <option key={f.v} value={f.v}>{f.l}</option>)}
</select>
</label>
</div>
<div className="row">
<label>
<span>{t("import.encoding")}</span>
<select value={config.encoding} onChange={(e) => setCfg({ encoding: e.target.value })}>
<option value="utf-8">UTF-8</option>
<option value="latin-1">ISO-8859-1 / Latin-1</option>
<option value="cp1252">Windows-1252</option>
</select>
</label>
<label>
<span>{t("import.amountMode")}</span>
<select value={config.amount_mode} onChange={(e) => setCfg({ amount_mode: e.target.value })}>
<option value="signed">{t("import.modeSigned")}</option>
<option value="debit_credit">{t("import.modeDebitCredit")}</option>
<option value="abs_expense">{t("import.modeAbsExpense")}</option>
</select>
</label>
<label style={{ maxWidth: 160 }}>
<span>{t("import.skipRows")}</span>
<input type="number" min="0" value={config.skip_rows}
onChange={(e) => setCfg({ skip_rows: Math.max(0, Number(e.target.value) || 0) })} />
</label>
<label className="checkline" style={{ alignSelf: "flex-end", marginBottom: 14 }}>
<input type="checkbox" checked={config.has_header}
onChange={(e) => setCfg({ has_header: e.target.checked })} />
<span>{t("import.hasHeader")}</span>
</label>
</div>
<button className="btn" onClick={loadRaw} disabled={busy || !file}>
<Icon name="search" size={15} /> {t("import.readFile")}
</button>
</div>
{/* Schritt 3: Spaltenzuordnung */}
{raw && (
<div className="card">
<h2>{t("import.step3")}</h2>
<div className="table-scroll">
<table className="table raw-table">
<thead>
<tr>{colOptions.map((c) => <th key={c.i}>{c.label}</th>)}</tr>
</thead>
<tbody>
{raw.rows.map((r, ri) => (
<tr key={ri}>{colOptions.map((c) => <td key={c.i}>{r[c.i] ?? ""}</td>)}</tr>
))}
</tbody>
</table>
</div>
<div className="map-grid">
{[
["date", t("import.colDate"), true],
...(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],
].map(([field, label]) => (
<label key={field}>
<span>{label}</span>
<select value={config.column_map[field] ?? ""} onChange={(e) => setCol(field, e.target.value)}>
<option value="">—</option>
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
</select>
</label>
))}
</div>
<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")}
</button>
<div className="input-with-button" style={{ maxWidth: 320 }}>
<input placeholder={t("import.templateName")} value={saveName} onChange={(e) => setSaveName(e.target.value)} />
<button className="btn sm" onClick={saveTemplate}>{t("import.saveTemplate")}</button>
</div>
</div>
</div>
)}
{/* Schritt 4: Prüfansicht */}
{rows && (
<div className="card">
<div className="page-head">
<h2 style={{ margin: 0 }}>{t("import.step4")}</h2>
<button className="btn primary" onClick={commit} disabled={busy || includeCount === 0}>
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
</button>
</div>
<div className="table-scroll">
<table className="table">
<thead>
<tr>
<th></th>
<th>{t("transactions.date")}</th>
<th>{t("transactions.amount")}</th>
<th>{t("import.typeCol")}</th>
<th>{t("import.payeeCol")}</th>
<th>{t("transactions.category")}</th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={idx} className={!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : ""}>
<td>
<input type="checkbox" checked={!!r.include} disabled={!r.valid}
onChange={(e) => updateRow(idx, { include: e.target.checked })} style={{ width: "auto" }} />
</td>
<td className="nowrap">{r.date ? formatDate(r.date) : <span className="amount neg">{t("import.badDate")}</span>}</td>
<td className="nowrap">
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
</td>
<td>
<select value={r.type} onChange={(e) => updateRow(idx, { type: e.target.value })} style={{ minWidth: 110 }}>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
</td>
<td className="ellipsis" style={{ maxWidth: 200 }}>
{r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
</td>
<td>
{r.type === "transfer" ? (
<select value={r.to_account_id || ""} onChange={(e) => updateRow(idx, { to_account_id: e.target.value ? Number(e.target.value) : null })} style={{ minWidth: 150 }}>
<option value="">{t("import.chooseAccount")}</option>
{accounts.filter((a) => a.id !== Number(accountId)).map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
) : (
<select value={r.category_id || ""} onChange={(e) => updateRow(idx, { category_id: e.target.value ? Number(e.target.value) : null })} style={{ minWidth: 150 }}>
<option value="">{t("transactions.uncategorized")}</option>
{(r.type === "income" ? incomeGroups : expenseGroups).map((g) => (
<optgroup key={g.id} label={g.name}>
{g.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</optgroup>
))}
</select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Verlauf */}
{batches.length > 0 && (
<div className="card">
<h2>{t("import.history")}</h2>
<table className="table">
<tbody>
{batches.map((b) => (
<tr key={b.id} className={b.status === "undone" ? "row-dup" : ""}>
<td><strong>{b.filename || "—"}</strong> <span className="muted small">{accountById[b.account_id]?.name}</span></td>
<td className="nowrap">{formatDate(b.created_at)}</td>
<td className="nowrap">{t("import.importedCount", { n: b.imported_count })}</td>
<td style={{ textAlign: "right" }}>
{b.status === "committed" ? (
<button className="btn sm danger" onClick={() => undo(b)}>
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
</button>
) : <span className="muted small">{t("import.undone")}</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}