Files
certain/web/src/pages/Import.jsx
T
GuidoandClaude Opus 5 d05f4915ad fix: Saldo-Abgleich erklaert die Abweichung verstaendlich
Die Meldung nannte nur Zahlen ("Laut Datei / errechnet / Differenz") und
sagte nicht, was zu tun ist. Sie beschreibt jetzt in Worten, was nicht
aufgeht und ab welcher Zeile beide auseinanderlaufen.

Dazu ein Hinweis auf die haeufigste Ursache, wenn kein Zeilenfilter
gesetzt ist: Revolut legt Giro, Erspartes und Anlagekonto in EINE Datei,
jedes mit eigener Kontostand-Spalte. Ohne Filter springt die Spalte
zwischen den Produkten und der Abgleich kann gar nicht aufgehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:27:14 +02:00

665 lines
28 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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);
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() {
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) {
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() {
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 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); 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"); }
}
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(() => 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) => (
<input type="checkbox" checked={!!r.include} disabled={!r.valid}
onChange={(e) => updateRow(r.index, { include: e.target.checked })} />
),
},
{
key: "date", header: t("transactions.date"), width: 112,
sortValue: (r) => r.date || "",
render: (r) => (r.date
? <span className="nowrap">{formatDate(r.date)}</span>
: <span className="amount neg">{t("import.badDate")}</span>),
},
{
key: "amount", header: t("transactions.amount"), align: "num", width: 120,
sortValue: (r) => r.amount_cents || 0,
render: (r) => (
<span className={`amount ${(r.amount_cents || 0) < 0 ? "neg" : "pos"}`}>
{formatMoney(Math.abs(r.amount_cents || 0), acc?.currency || "EUR")}
</span>
),
},
{
key: "type", header: t("import.typeCol"), width: 128,
filterText: (r) => t(`transactions.${r.type}`),
render: (r) => (
<select value={r.type} onChange={(e) => updateRow(r.index, { type: e.target.value })}>
<option value="expense">{t("transactions.expense")}</option>
<option value="income">{t("transactions.income")}</option>
<option value="transfer">{t("transactions.transfer")}</option>
</select>
),
},
{
key: "payee", header: t("import.payeeCol"), grow: true, min: 200,
filterText: (r) => r.payee || r.purpose || "",
render: (r) => (
<span className="ellipsis" style={{ display: "block" }}>
{r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
{r.needs_target && <span className="badge" style={{ marginLeft: 6 }}>{t("import.needsTarget")}</span>}
{r.skip_reason === "state" && (
<span className="badge" style={{ marginLeft: 6 }}>{r.state || t("import.skipState")}</span>
)}
{r.skip_reason === "currency" && (
<span className="badge" style={{ marginLeft: 6 }}>{r.row_currency || t("import.skipCurrency")}</span>
)}
</span>
),
},
{
key: "category", header: t("transactions.category"), width: 210, min: 160,
render: (r) => (r.type === "transfer" ? (
<select value={r.to_account_id || ""}
onChange={(e) => updateRow(r.index, { to_account_id: e.target.value ? Number(e.target.value) : null })}>
<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>
) : (
<CategorySelect
groups={r.type === "income" ? incomeGroups : expenseGroups}
value={r.category_id || ""}
onChange={(id) => 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) => (
<span className="ellipsis" style={{ display: "block" }}>
<strong>{b.filename || "—"}</strong>{" "}
<span className="muted small">{accountById[b.account_id]?.name}</span>
</span>
),
},
{
key: "date", header: t("transactions.date"), width: 150,
sortValue: (b) => b.created_at,
render: (b) => <span className="nowrap">{formatDate(b.created_at)}</span>,
},
{
key: "count", header: t("import.importedCount", { n: "" }).trim(), width: 110, align: "num",
sortValue: (b) => b.imported_count,
render: (b) => <span className="nowrap">{t("import.importedCount", { n: b.imported_count })}</span>,
},
{
key: "action", header: "", width: 120, fixed: true,
render: (b) => (b.status === "committed" ? (
<button className="btn sm danger" onClick={() => undo(b)}>
<Icon name="refresh-ccw" size={14} /> {t("import.undo")}
</button>
) : (
<span className="nowrap">
<span className="muted small">{t("import.undone")}</span>
<button className="btn sm ghost" title={t("import.removeEntry")}
style={{ marginLeft: 6 }} onClick={() => entfernen(b)}>
<Icon name="trash" size={14} />
</button>
</span>
)),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [t, formatDate, accountById]);
return (
<div>
<div className="page-head">
<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(""); setDocPages([]); }}>{t("import.modeCsv")}</button>
<button type="button" className={`seg-btn${mode === "doc" ? " active" : ""}`}
onClick={() => { setMode("doc"); setRows(null); setRaw(null); setDocText(""); setDocPages([]); }}>{t("import.modeDoc")}</button>
</div>
</div>
{/* 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={mode === "doc" ? ".pdf,application/pdf,image/*" : ".csv,text/csv,text/plain"}
onChange={(e) => { setFile(e.target.files?.[0] || null); setRaw(null); setRows(null); setDocText(""); setDocPages([]); }} />
</label>
</div>
{mode === "doc" && (
<>
<button className="btn primary" onClick={loadDoc} disabled={busy || !file}>
<Icon name="search" size={15} /> {t("import.readDoc")}
</button>
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.docHint")}</p>
</>
)}
<div className="row" style={{ display: mode === "csv" ? undefined : "none" }}>
<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>
{/* Schritt 2: Format (nur CSV) */}
{mode === "csv" && (
<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>
<p className="muted small">{t("import.autoPreviewNote")}</p>
</div>
)}
{/* Beleg-Vorschau: Seiten als Bild + extrahierter Text (PDF/Bild) */}
{mode === "doc" && (docPages.length > 0 || docText) && (
<div className="card">
<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>
)}
{/* Schritt 3: Spaltenzuordnung */}
{raw && (
<div className="card">
<h2>{t("import.step3")}</h2>
<p className="muted small">{t("import.rawHint")}</p>
<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")],
...(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]) => (
<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>
<details className="import-more">
<summary className="muted small">{t("import.moreColumns")}</summary>
<p className="muted small">{t("import.ibanHint")}</p>
<div className="map-grid">
{[
["counterparty_iban", t("import.colIban")],
["fee", t("import.colFee")],
["currency", t("import.colCurrency")],
["state", t("import.colState")],
["balance", t("import.colBalance")],
].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>
<p className="muted small">{t("import.extraColsHint")}</p>
<div className="map-grid">
<label>
<span>{t("import.filterColumn")}</span>
<select value={config.filter_column ?? ""}
onChange={(e) => setCfg({ filter_column: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">{t("import.filterNone")}</option>
{colOptions.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
</select>
</label>
<label>
<span>{t("import.filterValue")}</span>
<input value={config.filter_value}
placeholder={t("import.filterValuePlaceholder")}
onChange={(e) => setCfg({ filter_value: e.target.value })} />
</label>
</div>
<p className="muted small">{t("import.filterHint")}</p>
</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")}
</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>
<p className="muted small" style={{ marginTop: "var(--sp-2)" }}>{t("import.makePreviewNote")}</p>
</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>
<p className="muted small">{t("import.summary", { total: rows.length, n: includeCount })}</p>
{rows.length > 0 && includeCount === 0 && (
<div className="alert error" style={{ marginBottom: "var(--sp-3)" }}>
<Icon name="alert" size={16} />
<span>{t("import.noneValid")}</span>
</div>
)}
{recon?.available && (
<div className={`alert ${recon.ok ? "ok" : "error"}`} style={{ marginBottom: "var(--sp-3)" }}>
<Icon name={recon.ok ? "check" : "alert"} size={16} />
<div>
{recon.ok ? (
<span>{t("import.reconOk", { n: recon.checked })}</span>
) : (
<>
<div>
{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"),
})}
</div>
{recon.first_mismatch && (
<div className="muted small">
{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"),
})}
</div>
)}
{/* Häufigste Ursache: mehrere Konten in einer Datei, kein Zeilenfilter. */}
{config.filter_column == null && (
<div className="muted small">{t("import.reconMixedHint")}</div>
)}
</>
)}
<div className="muted small">
{t("import.reconOpening", {
value: formatMoney(recon.opening_balance_cents, acc?.currency || "EUR"),
})}
</div>
</div>
</div>
)}
<DataTable
id="import-review"
rows={rows}
getRowKey={(r) => 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. */}
<div className="commit-footer">
<button className="btn primary" onClick={commit} disabled={busy || includeCount === 0}>
<Icon name="download" size={15} /> {t("import.commit", { n: includeCount })}
</button>
</div>
</div>
)}
{/* Verlauf */}
{batches.length > 0 && (
<div className="card">
<h2>{t("import.history")}</h2>
<DataTable
id="import-history"
rows={batches}
getRowKey={(b) => b.id}
rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")}
columns={historyColumns}
/>
</div>
)}
</div>
);
}