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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user