feat: Excel-artige DataTable (Spaltenbreite/Sortieren/Filtern) für alle Tabellen

- 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>
This commit is contained in:
Guido
2026-08-19 22:58:01 +02:00
co-authored by Claude Opus 4.8
parent e02ef7eb8f
commit 1d5b23c127
6 changed files with 685 additions and 173 deletions
+120 -79
View File
@@ -6,6 +6,7 @@ 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 = [
@@ -176,12 +177,115 @@ export default function Import() {
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)));
// 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">
@@ -379,65 +483,13 @@ export default function Import() {
<span>{t("import.noneValid")}</span>
</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>
) : (
<CategorySelect
groups={r.type === "income" ? incomeGroups : expenseGroups}
value={r.category_id || ""}
onChange={(id) => updateRow(idx, { category_id: id != null ? Number(id) : null })}
allowNone
noneLabel={t("transactions.uncategorized")}
placeholder={t("transactions.uncategorized")}
minWidth={180}
/>
)}
</td>
</tr>
))}
</tbody>
</table>
</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>
)}
@@ -445,24 +497,13 @@ export default function Import() {
{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>
<DataTable
id="import-history"
rows={batches}
getRowKey={(b) => b.id}
rowClassName={(b) => (b.status === "undone" ? "row-dup" : "")}
columns={historyColumns}
/>
</div>
)}
</div>