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
+64 -49
View File
@@ -9,6 +9,7 @@ import IconChip from "../components/IconChip";
import Money from "../components/Money";
import Modal from "../components/Modal";
import CategorySelect from "../components/CategorySelect";
import DataTable from "../components/DataTable";
import { centsToInput, parseAmountToCents, todayIso } from "../format";
const TYPES = ["expense", "income", "transfer"];
@@ -193,6 +194,62 @@ export default function Transactions() {
};
}
const txnColumns = useMemo(() => [
{
key: "date", header: t("transactions.date"), width: 112,
sortValue: (tx) => tx.date,
render: (tx) => <span className="nowrap">{formatDate(tx.date)}</span>,
},
{
key: "category", header: t("transactions.category"), grow: true, min: 200,
filterText: (tx) => rowLabel(tx).title,
sortValue: (tx) => rowLabel(tx).title,
render: (tx) => {
const l = rowLabel(tx);
return (
<div className="named">
<IconChip name={l.icon} color={l.color} size={26} />
<div className="named-text">
<div className="ellipsis">{l.title}</div>
{l.sub && <div className="named-sub ellipsis">{l.sub}</div>}
</div>
</div>
);
},
},
{
key: "account", header: t("transactions.account"), width: 150,
filterText: (tx) => accountById[tx.account_id]?.name || "",
render: (tx) => accountById[tx.account_id]?.name || "",
},
{
key: "amount", header: t("transactions.amount"), align: "num", width: 150,
sortValue: (tx) => tx.amount_base_cents,
render: (tx) => {
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
);
},
},
{
key: "actions", header: "", width: 96, fixed: true,
render: (tx) => (
<div className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</div>
),
},
// eslint-disable-next-line react-hooks/exhaustive-deps
], [accountById, categoryById, baseCurrency, formatDate, t]);
return (
<div>
<div className="page-head">
@@ -220,55 +277,13 @@ export default function Transactions() {
</div>
<div className="card">
{txns.length === 0 ? (
<div className="empty-state">{t("transactions.empty")}</div>
) : (
<table className="table">
<thead>
<tr>
<th>{t("transactions.date")}</th>
<th>{t("transactions.category")}</th>
<th>{t("transactions.account")}</th>
<th style={{ textAlign: "right" }}>{t("transactions.amount")}</th>
<th></th>
</tr>
</thead>
<tbody>
{txns.map((tx) => {
const l = rowLabel(tx);
const acc = accountById[tx.account_id];
const sign = tx.type === "income" ? "pos" : tx.type === "expense" ? "neg" : null;
return (
<tr key={tx.id}>
<td className="nowrap">{formatDate(tx.date)}</td>
<td>
<div className="named">
<IconChip name={l.icon} color={l.color} size={28} />
<div className="named-text">
<div>{l.title}</div>
{l.sub && <div className="named-sub">{l.sub}</div>}
</div>
</div>
</td>
<td className="nowrap">{acc?.name}</td>
<td style={{ textAlign: "right" }}>
<Money cents={tx.amount_cents} currency={acc?.currency || "EUR"}
baseCents={tx.amount_base_cents} baseCurrency={baseCurrency} sign={sign} />
</td>
<td className="nowrap" style={{ textAlign: "right" }}>
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
<Icon name="edit" size={14} />
</button>
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
<Icon name="trash" size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<DataTable
id="transactions"
rows={txns}
getRowKey={(tx) => tx.id}
empty={t("transactions.empty")}
columns={txnColumns}
/>
</div>
</>
)}