- Portierte DataTable-Komponente (aus Vorrania): Spalten per Drag breiter ziehen und verschieben, Klick-Sortierung, Filterzeile je Spalte, Spalten ein-/ausblenden; Breiten/Reihenfolge/Filter pro Tabelle im Browser gemerkt (localStorage) - Zu langer Zellinhalt wird mit „…" abgeschnitten (Titel bleibt sichtbar, Excel-artig) - Umgestellt: Buchungen, Import-Prüfansicht (inkl. interaktiver Zellen), Import- Verlauf, Benutzer - Neue Skeleton-Komponente für den Ladezustand Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
512 lines
20 KiB
React
512 lines
20 KiB
React
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 { 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 [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 [docText, setDocText] = 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(); }, []);
|
|
|
|
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 || {},
|
|
});
|
|
}
|
|
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);
|
|
}
|
|
|
|
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 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 })));
|
|
setDocText(res.text || "");
|
|
} 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;
|
|
|
|
// 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>}
|
|
</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="muted small">{t("import.undone")}</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(""); }}>{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>
|
|
</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(""); }} />
|
|
</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" }}>
|
|
{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>
|
|
)}
|
|
</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>
|
|
<button className="btn" onClick={loadRaw} disabled={busy || !file}>
|
|
<Icon name="search" size={15} /> {t("import.readFile")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Extrahierter Text (PDF/Bild) */}
|
|
{mode === "doc" && docText && (
|
|
<div className="card">
|
|
<details>
|
|
<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"), 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>
|
|
<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>
|
|
)}
|
|
<DataTable
|
|
id="import-review"
|
|
rows={rows}
|
|
getRowKey={(r) => r.index}
|
|
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")}
|
|
columns={reviewColumns}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|