Files
certain/web/src/pages/Import.jsx
T
GuidoandClaude Opus 5 84605cbc0f fix: Wartende Telefon-Dateien stehen jetzt oben
Die Karte "Vom Telefon empfangen" sass unter dem gesamten Import-Assistenten
kurz vor dem Verlauf – wer eine Datei geschickt hatte, fand sie nicht und
sah eine leere Seite. Eine wartende Datei ist das Erste, was man tun will,
also steht sie jetzt ueber Schritt 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:37:33 +02:00

849 lines
36 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 AccountSelect from "../components/AccountSelect";
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);
// Dateien, die per Kurzbefehl vom Telefon hereinkamen.
const [uploads, setUploads] = useState([]);
// Ergebnis der automatischen Formaterkennung.
const [erkannt, setErkannt] = useState(null);
const [quelle, setQuelle] = useState(""); // "preset:i" | "tpl:id" | ""
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() {
api.listUploads().then(setUploads).catch(() => {});
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) {
setQuelle(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
}
}
// Beim Kontowechsel die Einstellung vorschlagen, mit der zuletzt in DIESES
// Konto importiert wurde. Ohne das waehlt man jedes Mal neu – und liest im
// schlimmsten Fall den Revolut-Auszug mit der Raiffeisen-Vorlage ein.
useEffect(() => {
if (!accountId || !batches.length) return;
const letzter = batches.find(
(b) => String(b.account_id) === String(accountId) && (b.template_id || b.preset));
if (!letzter) return;
if (letzter.template_id && templates.some((x) => x.id === letzter.template_id)) {
applySource(`tpl:${letzter.template_id}`);
return;
}
const i = presets.findIndex((p) => p.name === letzter.preset);
if (i >= 0) applySource(`preset:${i}`);
}, [accountId, batches, templates, presets]);
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 [art, id] = quelle.split(":");
const batch = await api.importCommit({
account_id: Number(accountId),
template_id: art === "tpl" ? Number(id) : null,
preset: art === "preset" ? presets[Number(id)]?.name || null : 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"); }
}
// Nach einer Reihe von Versuchen stehen schnell zwanzig rueckgaengig gemachte
// Importe im Verlauf. Die einzeln wegzuklicken ist Fleissarbeit ohne Nutzen.
async function alleErledigtenEntfernen() {
const weg = batches.filter((b) => b.status === "undone");
if (!weg.length) return;
if (!(await confirm({
title: t("import.removeAllUndone"),
message: t("import.removeAllUndoneConfirm", { n: weg.length }),
danger: true, confirmLabel: t("common.delete"),
}))) return;
try {
for (const b of weg) await api.removeImportBatch(b.id);
loadStamm();
} catch (e) { toast(e.message, "error"); }
}
// Die abgelieferte Datei so behandeln, als haette man sie hier ausgewaehlt –
// damit gilt der ganze normale Weg samt Vorschau und Saldo-Abgleich.
// Format aus der Datei ableiten, damit niemand Spalten von Hand zuordnet.
// Das war die haeufigste Fehlerquelle: falsches Datumsformat = keine Zeile
// gueltig, falscher Dezimaltrenner = Betraege um Faktor 100 daneben.
async function formatErkennen(f) {
setErkannt(null);
try {
const daten = new FormData();
daten.append("file", f);
const r = await api.detectFormat(daten);
setErkannt(r);
if (r.ok && r.config) {
const c = r.config;
setCfg({
delimiter: c.delimiter, decimal_sep: c.decimal_sep, date_format: c.date_format,
encoding: c.encoding, has_header: c.has_header, skip_rows: c.skip_rows,
amount_mode: c.amount_mode,
column_map: Object.fromEntries(
Object.entries(c.column_map).filter(([, v]) => v !== null && v !== undefined)),
});
}
} catch (e) {
setErkannt({ ok: false, grund: e.message });
}
}
async function uploadUebernehmen(u) {
try {
const antwort = await fetch(`/api/imports/uploads/${u.id}/content`, { credentials: "include" });
if (!antwort.ok) throw new Error(await antwort.text());
const blob = await antwort.blob();
setAccountId(String(u.account_id));
setFile(new File([blob], u.filename, { type: "text/csv" }));
setRaw(null);
setRows(null);
setMode("csv");
const datei = new File([blob], u.filename, { type: "text/csv" });
formatErkennen(datei);
toast(t("import.phoneFileLoaded", { name: u.filename }));
} catch (e) {
toast(e.message, "error");
}
}
async function uploadVerwerfen(u) {
if (!(await confirm({
title: t("import.discardPhoneFile"),
message: t("import.discardPhoneFileConfirm", { name: u.filename }),
danger: true, confirmLabel: t("common.delete"),
}))) return;
try { await api.deleteUpload(u.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]);
// Spalten, die die Datei in wenige Gruppen teilen (Revolut: „Produkt").
const filterSpalten = useMemo(
() => colOptions.filter((c) => raw?.filter_values?.[String(c.i)]),
[colOptions, 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 }}
title={r.duplicate_reason === "inbox" ? t("import.dupInboxHint") : undefined}>
{r.duplicate_reason === "inbox" ? t("import.dupInbox") : 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: 196, 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">
<button className="btn sm ghost" title={t("import.removeEntry")}
onClick={() => entfernen(b)}>
<Icon name="trash" size={14} /> {t("import.removeEntry")}
</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>
{uploads.length > 0 && (
<div className="card">
<div className="card-head">
<Icon name="smartphone" size={18} />
<h2>{t("import.fromPhone")}</h2>
</div>
<p className="muted small">{t("import.fromPhoneHint")}</p>
{uploads.map((u) => (
<div className="mail-source" key={u.id}>
<div><strong>{u.filename}</strong></div>
<div className="muted small">
{accountById[u.account_id]?.name || "—"} · {Math.round(u.size_bytes / 1024)} KB
{u.created_at ? ` · ${formatDate(u.created_at.slice(0, 10))}` : ""}
</div>
<div className="mail-actions">
<button className="btn sm primary" onClick={() => uploadUebernehmen(u)}>
<Icon name="download" size={14} /> {t("import.usePhoneFile")}
</button>
<button className="btn sm danger" onClick={() => uploadVerwerfen(u)}>
<Icon name="trash" size={14} />
</button>
</div>
</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>
<AccountSelect accounts={accounts} value={accountId}
onChange={(id) => setAccountId(id != null ? String(id) : "")} />
</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) => {
const f = e.target.files?.[0] || null;
setFile(f); setRaw(null); setRows(null); setDocText(""); setDocPages([]);
if (f) formatErkennen(f);
}} />
</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 value={quelle} 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>
{/* Revolut legt alle Produkte in EINE Datei. Ohne Filter zaehlen die
Bewegungen doppelt – deshalb steht der Filter offen, sobald die
Datei eine Spalte mit wenigen wiederkehrenden Werten hat. */}
{filterSpalten.length > 0 && (
<div className="alert" style={{ marginTop: "var(--sp-3)", display: "block" }}>
<strong>{t("import.filterTitle")}</strong>
<p className="muted small">{t("import.filterHint")}</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),
filter_value: "",
})}>
<option value="">{t("import.filterNone")}</option>
{filterSpalten.map((c) => <option key={c.i} value={c.i}>{c.label}</option>)}
</select>
</label>
<label>
<span>{t("import.filterValue")}</span>
<select value={config.filter_value}
disabled={config.filter_column == null}
onChange={(e) => setCfg({ filter_value: e.target.value })}>
<option value="">{t("import.filterNone")}</option>
{(raw?.filter_values?.[String(config.filter_column)] || []).map((w) => (
<option key={w.value} value={w.value}>
{t("import.filterValueOption", { value: w.value, count: w.count })}
</option>
))}
</select>
</label>
</div>
</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>
</details>
{erkannt && (
<div className={erkannt.ok ? "alert" : "alert error"}
style={{ display: "block", marginBottom: "var(--sp-3)" }}>
<strong>
{erkannt.ok
? (erkannt.preset
? t("import.detectedBank", { name: erkannt.preset })
: t("import.detectedGeneric"))
: t("import.detectFailed")}
</strong>
<p className="muted small" style={{ margin: "4px 0 0" }}>
{erkannt.begruendung || erkannt.grund}
</p>
{erkannt.ok && erkannt.sicherheit !== "hoch" && (
<p className="muted small" style={{ margin: "4px 0 0" }}>
{t("import.detectUncertain")}
</p>
)}
</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>
<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">
<div className="card-head">
<h2>{t("import.history")}</h2>
{batches.some((b) => b.status === "undone") && (
<button className="btn sm ghost" style={{ marginLeft: "auto" }} onClick={alleErledigtenEntfernen}>
<Icon name="trash" size={14} />{" "}
{t("import.removeAllUndone")} ({batches.filter((b) => b.status === "undone").length})
</button>
)}
</div>
<DataTable
id="import-history"
rows={batches}
getRowKey={(b) => b.id}
rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")}
columns={historyColumns}
/>
</div>
)}
</div>
);
}