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:
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import Icon from "./Icon";
|
||||
import { Skeleton } from "./Skeleton";
|
||||
|
||||
/**
|
||||
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
|
||||
* Spalte (Freitext + Dropdown der vorhandenen Werte, über Spalten kombinierbar),
|
||||
* Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung.
|
||||
* Reihenfolge + Breiten merkt sich der Browser (localStorage, je `id`).
|
||||
*
|
||||
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
|
||||
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
|
||||
* fixed:true (kein Reorder/Resize), width, min }.
|
||||
*/
|
||||
const DEFAULT_WIDTH = 150;
|
||||
|
||||
function loadPersisted(id) {
|
||||
try { return JSON.parse(localStorage.getItem(`datatable:${id}`)) || {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
function savePersisted(id, data) {
|
||||
try { localStorage.setItem(`datatable:${id}`, JSON.stringify(data)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export default function DataTable({
|
||||
id, columns, rows, getRowKey, rowClassName,
|
||||
empty = "Nichts gefunden.", loading = false, toolbarExtra = null,
|
||||
onVisibleRows = null, onRowClick = null,
|
||||
}) {
|
||||
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
|
||||
|
||||
const [order, setOrder] = useState(() => {
|
||||
const p = loadPersisted(id);
|
||||
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
|
||||
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
|
||||
return [...saved, ...rest];
|
||||
});
|
||||
const schluessel = columns.map((c) => c.key).join("|");
|
||||
useEffect(() => {
|
||||
setOrder((alt) => {
|
||||
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
|
||||
return fehlend.length ? [...alt, ...fehlend] : alt;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schluessel]);
|
||||
|
||||
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
|
||||
const [filters, setFilters] = useState(() => {
|
||||
const saved = loadPersisted(id).filters || {};
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (!colByKey[k] || !v) continue;
|
||||
out[k] = { text: v.text || "", values: Array.isArray(v.values) ? new Set(v.values) : null };
|
||||
}
|
||||
return out;
|
||||
}); // { key: { text, values:Set|null } }
|
||||
const [sort, setSort] = useState(() => {
|
||||
const s = loadPersisted(id).sort;
|
||||
return s && s.key && colByKey[s.key] ? s : null;
|
||||
}); // { key, dir }
|
||||
const [filterShown, setFilterShown] = useState(true);
|
||||
const [openFilter, setOpenFilter] = useState(null);
|
||||
const [hidden, setHidden] = useState(
|
||||
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
|
||||
);
|
||||
const [chooserOpen, setChooserOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const filtersSer = {};
|
||||
for (const [k, v] of Object.entries(filters)) {
|
||||
if (!v || (!v.text && v.values == null)) continue;
|
||||
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
|
||||
}
|
||||
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
|
||||
}, [id, order, widths, hidden, filters, sort]);
|
||||
|
||||
const wrapRef = useRef(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return undefined;
|
||||
const ro = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
|
||||
ro.observe(el);
|
||||
setContainerWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const allOrdered = order.map((k) => colByKey[k]).filter(Boolean);
|
||||
const orderedCols = allOrdered.filter((c) => !hidden.has(c.key));
|
||||
|
||||
const chooserBtn = useRef(null);
|
||||
const chooserPop = useRef(null);
|
||||
const [chooserPos, setChooserPos] = useState(null);
|
||||
useLayoutEffect(() => {
|
||||
if (chooserOpen && chooserBtn.current) {
|
||||
const r = chooserBtn.current.getBoundingClientRect();
|
||||
setChooserPos({
|
||||
left: Math.min(r.left, window.innerWidth - 240),
|
||||
top: r.bottom + 4,
|
||||
maxHeight: Math.max(180, window.innerHeight - r.bottom - 24),
|
||||
});
|
||||
}
|
||||
}, [chooserOpen]);
|
||||
useEffect(() => {
|
||||
if (!chooserOpen) return undefined;
|
||||
function onDoc(e) {
|
||||
if (chooserBtn.current?.contains(e.target) || chooserPop.current?.contains(e.target)) return;
|
||||
setChooserOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [chooserOpen]);
|
||||
|
||||
function toggleHidden(key) {
|
||||
setHidden((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) { n.delete(key); return n; }
|
||||
if (allOrdered.filter((c) => !n.has(c.key) && c.key !== key).length === 0) return s;
|
||||
n.add(key);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
const isGrow = (c) => c.grow && widths[c.key] == null;
|
||||
const fixedWidth = (c) => widths[c.key] ?? c.width ?? DEFAULT_WIDTH;
|
||||
const growCols = orderedCols.filter(isGrow);
|
||||
const sumFixed = orderedCols.filter((c) => !isGrow(c)).reduce((s, c) => s + fixedWidth(c), 0);
|
||||
const remaining = containerWidth - sumFixed - 2;
|
||||
const widthOf = (c) => (isGrow(c)
|
||||
? Math.max(c.min ?? 160, growCols.length ? remaining / growCols.length : 160)
|
||||
: fixedWidth(c));
|
||||
const totalWidth = Math.round(orderedCols.reduce((s, c) => s + widthOf(c), 0));
|
||||
|
||||
const rowValues = (c, r) => (c.filterValues ? c.filterValues(r) : [String(c.filterText(r) ?? "")]);
|
||||
|
||||
const distinctByKey = useMemo(() => {
|
||||
const m = {};
|
||||
for (const c of columns) {
|
||||
if (!c.filterText && !c.filterValues) continue;
|
||||
const set = new Set();
|
||||
for (const r of rows) for (const v of rowValues(c, r)) set.add(String(v));
|
||||
m[c.key] = [...set].sort((a, b) => a.localeCompare(b, "de"));
|
||||
}
|
||||
return m;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columns, rows]);
|
||||
|
||||
const flat = useMemo(() => {
|
||||
let out = rows.filter((r) => orderedCols.every((c) => {
|
||||
const f = filters[c.key];
|
||||
if (!f || (!c.filterText && !c.filterValues)) return true;
|
||||
if (f.values && !rowValues(c, r).some((v) => f.values.has(String(v)))) return false;
|
||||
if (f.text && c.filterText
|
||||
&& !String(c.filterText(r) ?? "").toLowerCase().includes(f.text.toLowerCase())) return false;
|
||||
return true;
|
||||
}));
|
||||
if (sort) {
|
||||
const c = colByKey[sort.key];
|
||||
const val = (r) => (c?.sortValue ? c.sortValue(r) : (c?.filterText?.(r) ?? ""));
|
||||
out = [...out].sort((a, b) => {
|
||||
const va = val(a); const vb = val(b);
|
||||
const cmp = (typeof va === "number" && typeof vb === "number")
|
||||
? va - vb : String(va).localeCompare(String(vb), "de");
|
||||
return sort.dir === "desc" ? -cmp : cmp;
|
||||
});
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rows, order, filters, sort]);
|
||||
|
||||
const displayRows = useMemo(() => flat.map((row) => ({ row })), [flat]);
|
||||
|
||||
const onVisibleRef = useRef(onVisibleRows);
|
||||
onVisibleRef.current = onVisibleRows;
|
||||
useEffect(() => {
|
||||
onVisibleRef.current?.(displayRows.map((d) => d.row));
|
||||
}, [displayRows]);
|
||||
|
||||
function toggleSort(c) {
|
||||
if (!c.sortValue && !c.filterText) return;
|
||||
setSort((s) => {
|
||||
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
|
||||
if (s.dir === "asc") return { key: c.key, dir: "desc" };
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Resize (Pointer) ---
|
||||
const resizing = useRef(null);
|
||||
function startResize(e, c) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const startW = widthOf(c);
|
||||
setWidths((old) => {
|
||||
const next = { ...old };
|
||||
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
|
||||
return next;
|
||||
});
|
||||
resizing.current = { key: c.key, startX: e.clientX, startW };
|
||||
window.addEventListener("pointermove", onResizeMove);
|
||||
window.addEventListener("pointerup", endResize);
|
||||
}
|
||||
function onResizeMove(e) {
|
||||
const r = resizing.current; if (!r) return;
|
||||
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
|
||||
setWidths((old) => ({ ...old, [r.key]: w }));
|
||||
}
|
||||
function endResize() {
|
||||
resizing.current = null;
|
||||
window.removeEventListener("pointermove", onResizeMove);
|
||||
window.removeEventListener("pointerup", endResize);
|
||||
}
|
||||
|
||||
// --- Reorder (HTML5 Drag) ---
|
||||
const dragKey = useRef(null);
|
||||
const [dropKey, setDropKey] = useState(null);
|
||||
function onDragStart(e, c) {
|
||||
if (resizing.current) { e.preventDefault(); return; }
|
||||
dragKey.current = c.key;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
function onDragOver(e, c) { e.preventDefault(); if (c.key !== dropKey) setDropKey(c.key); }
|
||||
function onDrop(e, c) {
|
||||
e.preventDefault();
|
||||
const from = dragKey.current; const to = c.key;
|
||||
dragKey.current = null; setDropKey(null);
|
||||
if (!from || from === to) return;
|
||||
setOrder((ord) => {
|
||||
const next = ord.filter((k) => k !== from);
|
||||
next.splice(next.indexOf(to), 0, from);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function resetColumns() {
|
||||
setOrder(columns.map((c) => c.key));
|
||||
setWidths({});
|
||||
setHidden(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dt-root">
|
||||
<div className="dt-toolbar">
|
||||
<button type="button" className={`btn sm ${filterShown ? "" : "ghost"}`}
|
||||
onClick={() => setFilterShown((v) => !v)}>
|
||||
<Icon name="search" size={14} />Filter
|
||||
</button>
|
||||
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
|
||||
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
|
||||
onClick={() => setChooserOpen((o) => !o)}>
|
||||
<Icon name="settings" size={14} />Spalten
|
||||
</button>
|
||||
<button type="button" className="btn sm ghost" onClick={resetColumns}>Spalten zurücksetzen</button>
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
{chooserOpen && chooserPos && createPortal(
|
||||
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
|
||||
<div className="dt-pop-list" style={{ maxHeight: chooserPos.maxHeight }}>
|
||||
{allOrdered.map((c) => (
|
||||
<label key={c.key} className="dt-pop-opt">
|
||||
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
|
||||
<span>{c.label || c.header || c.key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<div className="table-wrap" ref={wrapRef}>
|
||||
<table className="table datatable" style={{ width: totalWidth ? `${totalWidth}px` : "100%" }}>
|
||||
<colgroup>
|
||||
{orderedCols.map((c) => <col key={c.key} style={{ width: `${Math.round(widthOf(c))}px` }} />)}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{orderedCols.map((c) => (
|
||||
<th key={c.key}
|
||||
className={`dt-th ${c.align === "num" ? "num" : ""} ${dropKey === c.key ? "drop" : ""} ${sort?.key === c.key ? "sorted" : ""}`}
|
||||
draggable={!c.fixed}
|
||||
onDragStart={(e) => onDragStart(e, c)}
|
||||
onDragOver={(e) => onDragOver(e, c)}
|
||||
onDrop={(e) => onDrop(e, c)}>
|
||||
<span className="dt-th-label" onClick={() => toggleSort(c)}>
|
||||
<span className="dt-th-text">{c.header}</span>
|
||||
{sort?.key === c.key && <span className="dt-sort">{sort.dir === "asc" ? "▲" : "▼"}</span>}
|
||||
</span>
|
||||
{!c.fixed && <span className="dt-resize" onPointerDown={(e) => startResize(e, c)} />}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{filterShown && (
|
||||
<tr className="dt-filter">
|
||||
{orderedCols.map((c) => (
|
||||
<td key={c.key}>
|
||||
{(c.filterText || c.filterValues) ? (
|
||||
<FilterCell
|
||||
col={c}
|
||||
filter={filters[c.key]}
|
||||
distinct={distinctByKey[c.key] || []}
|
||||
optionLabel={c.filterOptionLabel}
|
||||
hasText={Boolean(c.filterText)}
|
||||
open={openFilter === c.key}
|
||||
onToggleOpen={setOpenFilter}
|
||||
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
|
||||
/>
|
||||
) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayRows.map(({ row }) => (
|
||||
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}
|
||||
onClick={onRowClick ? (e) => onRowClick(row, e) : undefined}
|
||||
style={onRowClick ? { cursor: "pointer" } : undefined}>
|
||||
{orderedCols.map((c) => (
|
||||
<td key={c.key} className={c.align === "num" ? "num" : ""}>{c.render(row)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{displayRows.length === 0 && (
|
||||
loading && rows.length === 0 ? (
|
||||
Array.from({ length: 8 }, (_, r) => (
|
||||
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
|
||||
{orderedCols.map((c, ci) => (
|
||||
<td key={c.key} className={c.align === "num" ? "num" : ""}>
|
||||
<Skeleton
|
||||
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
|
||||
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td className="empty" colSpan={orderedCols.length}>
|
||||
{loading ? "Wird geladen…" : empty}
|
||||
</td></tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Freitext-Feld + Dropdown der vorhandenen Werte (eine Spalte). */
|
||||
function FilterCell({ col, filter, distinct, onChange, open, onToggleOpen, optionLabel, hasText = true }) {
|
||||
const btnRef = useRef(null);
|
||||
const popRef = useRef(null);
|
||||
const [pos, setPos] = useState(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const text = filter?.text || "";
|
||||
const values = filter?.values || null; // null = alle erlaubt
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect();
|
||||
setPos({ left: Math.min(r.left, window.innerWidth - 260), top: r.bottom + 4 });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function onDoc(e) {
|
||||
if (btnRef.current?.contains(e.target) || popRef.current?.contains(e.target)) return;
|
||||
onToggleOpen(null);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open, onToggleOpen]);
|
||||
|
||||
const shown = distinct.filter((v) => v.toLowerCase().includes(search.toLowerCase()));
|
||||
const isChecked = (v) => values == null || values.has(v);
|
||||
const active = Boolean(text) || (values != null && values.size !== distinct.length);
|
||||
|
||||
function toggleValue(v) {
|
||||
const cur = values == null ? new Set(distinct) : new Set(values);
|
||||
if (cur.has(v)) cur.delete(v); else cur.add(v);
|
||||
onChange({ text, values: cur.size === distinct.length ? null : cur });
|
||||
}
|
||||
function setAll(on) { onChange({ text, values: on ? null : new Set() }); }
|
||||
|
||||
return (
|
||||
<div className="dt-filter-cell">
|
||||
{hasText && (
|
||||
<input className="dt-filter-input" placeholder="Filter…" value={text}
|
||||
onChange={(e) => onChange({ text: e.target.value, values })} />
|
||||
)}
|
||||
<button type="button" ref={btnRef} className={`dt-filter-btn ${active ? "active" : ""}`}
|
||||
title="Werte auswählen" onClick={() => onToggleOpen(open ? null : col.key)}>▾</button>
|
||||
{open && pos && createPortal(
|
||||
<div ref={popRef} className="dt-pop" style={{ left: pos.left, top: pos.top }}>
|
||||
<input className="dt-pop-search" placeholder="Suchen…" value={search}
|
||||
onChange={(e) => setSearch(e.target.value)} autoFocus />
|
||||
<div className="dt-pop-actions">
|
||||
<button type="button" className="link-btn" onClick={() => setAll(true)}>Alle</button>
|
||||
<button type="button" className="link-btn" onClick={() => setAll(false)}>Keine</button>
|
||||
</div>
|
||||
<div className="dt-pop-list">
|
||||
{shown.map((v) => (
|
||||
<label key={v} className="dt-pop-opt">
|
||||
<input type="checkbox" checked={isChecked(v)} onChange={() => toggleValue(v)} />
|
||||
<span>{optionLabel ? optionLabel(v) : (v === "" ? "(Leere)" : v)}</span>
|
||||
</label>
|
||||
))}
|
||||
{shown.length === 0 && <div className="muted small">Keine Werte.</div>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
|
||||
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
|
||||
* durchgehend aria-hidden – Screenreader sollen keine leeren Balken vorlesen.
|
||||
*/
|
||||
|
||||
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
|
||||
export function Skeleton({ w, h = 14, radius, className = "", style }) {
|
||||
return (
|
||||
<span
|
||||
className={`skeleton ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
style={{ width: w, height: h, borderRadius: radius, ...style }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -581,6 +581,17 @@ tr.row-dup {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* DataTable-Zellen einzeilig halten, damit „…" greift (Excel-artig). */
|
||||
.datatable td { white-space: nowrap; vertical-align: middle; }
|
||||
.datatable td .named-text { min-width: 0; }
|
||||
/* Interaktive Zellen (Import-Prüfansicht) füllen ihre Spalte, statt auf 60% zu
|
||||
schrumpfen (globale .table td select/input-Regel überschreiben). */
|
||||
.datatable td select,
|
||||
.datatable td input[inputmode],
|
||||
.datatable td input[type="text"] { max-width: none; width: 100%; }
|
||||
.datatable td .catsel { width: 100%; }
|
||||
.datatable td input[type="checkbox"] { width: auto; }
|
||||
|
||||
/* ---------- Kategorie-Auswahl (auf-/zuklappbarer Baum, Ersatz für <select>) ---------- */
|
||||
.catsel {
|
||||
width: 100%;
|
||||
|
||||
+119
-78
@@ -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}
|
||||
<DataTable
|
||||
id="import-review"
|
||||
rows={rows}
|
||||
getRowKey={(r) => r.index}
|
||||
rowClassName={(r) => (!r.valid ? "row-invalid" : r.is_duplicate ? "row-dup" : "")}
|
||||
columns={reviewColumns}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
+58
-45
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, ApiError } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { useToast } from "../toast";
|
||||
import Icon from "../components/Icon";
|
||||
import DataTable from "../components/DataTable";
|
||||
|
||||
function randomPassword(len = 12) {
|
||||
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789";
|
||||
@@ -95,6 +96,55 @@ export default function Users() {
|
||||
}
|
||||
}
|
||||
|
||||
const userColumns = useMemo(() => [
|
||||
{
|
||||
key: "name", header: t("users.username"), grow: true, min: 180,
|
||||
filterText: (u) => u.display_name || u.username,
|
||||
render: (u) => (
|
||||
<span className="ellipsis" style={{ display: "block" }}>
|
||||
<strong>{u.display_name || u.username}</strong>
|
||||
{u.display_name && <span className="muted small"> · {u.username}</span>}
|
||||
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "role", header: t("users.role"), width: 120,
|
||||
filterText: (u) => (u.role === "admin" ? t("common.admin") : t("common.user")),
|
||||
render: (u) => <span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>,
|
||||
},
|
||||
{
|
||||
key: "status", header: t("users.status"), width: 120,
|
||||
filterText: (u) => (u.is_active ? t("users.active") : t("users.inactive")),
|
||||
render: (u) => (
|
||||
<span className={`badge ${u.is_active ? "ok" : ""}`}>
|
||||
{u.is_active ? t("users.active") : t("users.inactive")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions", header: t("common.actions"), width: 132, fixed: true,
|
||||
render: (u) => (
|
||||
<div className="nowrap" style={{ textAlign: "right" }}>
|
||||
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
|
||||
<Icon name="key" size={15} />
|
||||
</button>
|
||||
{u.id !== self?.id && (
|
||||
<>
|
||||
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
|
||||
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
|
||||
</button>
|
||||
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
], [t, self]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("users.title")}</h1>
|
||||
@@ -145,50 +195,13 @@ export default function Users() {
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("users.username")}</th>
|
||||
<th>{t("users.role")}</th>
|
||||
<th>{t("users.status")}</th>
|
||||
<th style={{ textAlign: "right" }}>{t("common.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<strong>{u.display_name || u.username}</strong>
|
||||
{u.display_name && <span className="muted small"> · {u.username}</span>}
|
||||
{u.id === self?.id && <span className="muted small"> {t("users.you")}</span>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="badge">{u.role === "admin" ? t("common.admin") : t("common.user")}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${u.is_active ? "ok" : ""}`}>
|
||||
{u.is_active ? t("users.active") : t("users.inactive")}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: "right", whiteSpace: "nowrap" }}>
|
||||
<button className="btn sm ghost" title={t("users.resetPassword")} onClick={() => passwortZuruecksetzen(u)}>
|
||||
<Icon name="key" size={15} />
|
||||
</button>
|
||||
{u.id !== self?.id && (
|
||||
<>
|
||||
<button className="btn sm ghost" title={u.is_active ? t("users.inactive") : t("users.active")} onClick={() => aktivUmschalten(u)}>
|
||||
<Icon name={u.is_active ? "eye-off" : "eye"} size={15} />
|
||||
</button>
|
||||
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(u)}>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<DataTable
|
||||
id="users"
|
||||
rows={users}
|
||||
getRowKey={(u) => u.id}
|
||||
empty={t("users.title")}
|
||||
columns={userColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user