552 lines
17 KiB
TypeScript
552 lines
17 KiB
TypeScript
import {
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEventHandler,
|
||
} from "react";
|
||
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
|
||
import {
|
||
flexRender,
|
||
getCoreRowModel,
|
||
useReactTable,
|
||
type ColumnDef,
|
||
} from "@tanstack/react-table";
|
||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||
import { api } from "../../convex/_generated/api";
|
||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||
import { useFilters } from "@/context/FilterContext";
|
||
import { useAccountFilterId } from "@/components/layout/AccountFilter";
|
||
import { CategoryFilter } from "@/components/layout/CategoryFilter";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
||
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
||
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
||
import { toast } from "sonner";
|
||
import {
|
||
getVisibleSelectionState,
|
||
selectedTransactionsTotal,
|
||
toggleVisibleSelection,
|
||
} from "./transactionsSelection";
|
||
import { getResetTransactionFilterState } from "@/lib/transactionFilterReset";
|
||
import { RotateCcw } from "lucide-react";
|
||
|
||
type Tx = Doc<"transactions">;
|
||
type Category = Doc<"categories">;
|
||
const EMPTY_TRANSACTIONS: Tx[] = [];
|
||
|
||
/* ── Memoized cell components ────────────────────────────────────── */
|
||
|
||
const RowCheckbox = memo(function RowCheckbox({
|
||
checked,
|
||
indeterminate = false,
|
||
disabled = false,
|
||
ariaLabel,
|
||
onToggle,
|
||
}: {
|
||
checked: boolean;
|
||
indeterminate?: boolean;
|
||
disabled?: boolean;
|
||
ariaLabel: string;
|
||
onToggle: ChangeEventHandler<HTMLInputElement>;
|
||
}) {
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
|
||
useEffect(() => {
|
||
if (ref.current) ref.current.indeterminate = indeterminate;
|
||
}, [indeterminate]);
|
||
|
||
return (
|
||
<input
|
||
ref={ref}
|
||
type="checkbox"
|
||
checked={checked}
|
||
disabled={disabled}
|
||
aria-label={ariaLabel}
|
||
onChange={onToggle}
|
||
className="h-4 w-4 rounded border-border accent-primary"
|
||
/>
|
||
);
|
||
});
|
||
|
||
const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) {
|
||
return <>{name ?? "–"}</>;
|
||
});
|
||
|
||
const CategoryCell = memo(function CategoryCell({
|
||
tx,
|
||
categories,
|
||
categoryMap,
|
||
onUpdate,
|
||
}: {
|
||
tx: Tx;
|
||
categories: Category[] | undefined;
|
||
categoryMap: Map<Id<"categories">, Category>;
|
||
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null;
|
||
|
||
if (!open) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(true)}
|
||
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
|
||
>
|
||
{cat ? (
|
||
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
|
||
{cat.name}
|
||
</Badge>
|
||
) : (
|
||
<span className="text-muted-foreground">Kategorie…</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Select
|
||
defaultOpen
|
||
value={tx.categoryId ?? "none"}
|
||
onValueChange={(v) => {
|
||
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
|
||
setOpen(false);
|
||
}}
|
||
onOpenChange={(o) => {
|
||
if (!o) setOpen(false);
|
||
}}
|
||
>
|
||
<SelectTrigger className="h-7 w-[130px]">
|
||
<SelectValue placeholder="Kategorie" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories?.map((c) => (
|
||
<SelectItem key={c._id} value={c._id}>
|
||
<span className="inline-flex items-center gap-1.5">
|
||
<span
|
||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||
style={{ backgroundColor: c.color }}
|
||
/>
|
||
{c.name}
|
||
</span>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
);
|
||
});
|
||
|
||
const AssignedCell = memo(function AssignedCell({
|
||
assignedMonth,
|
||
bookingMonth,
|
||
}: {
|
||
assignedMonth: string | undefined;
|
||
bookingMonth: string | undefined;
|
||
}) {
|
||
if (!assignedMonth || assignedMonth === bookingMonth) return null;
|
||
return <Badge variant="outline">{formatMonth(assignedMonth)}</Badge>;
|
||
});
|
||
|
||
const ActionsCell = memo(function ActionsCell({
|
||
tx,
|
||
onEdit,
|
||
onAssign,
|
||
onRemove,
|
||
}: {
|
||
tx: Tx;
|
||
onEdit: (tx: Tx) => void;
|
||
onAssign: (tx: Tx) => void;
|
||
onRemove: (id: Id<"transactions">) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex gap-1">
|
||
<Button size="sm" variant="ghost" onClick={() => onEdit(tx)}>
|
||
Bearbeiten
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => onAssign(tx)}>
|
||
Monat…
|
||
</Button>
|
||
<Button size="sm" variant="ghost" onClick={() => onRemove(tx._id)}>
|
||
Löschen
|
||
</Button>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
/* ── Page ────────────────────────────────────────────────────────── */
|
||
|
||
export function TransactionsPage() {
|
||
const [search, setSearch] = useState("");
|
||
const [type, setType] = useState<"all" | "einnahme" | "ausgabe">("all");
|
||
const [pendingOnly, setPendingOnly] = useState(false);
|
||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||
const [editTx, setEditTx] = useState<Tx | null>(null);
|
||
const [assignTx, setAssignTx] = useState<Tx | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
|
||
const {
|
||
categoryIds,
|
||
from,
|
||
to,
|
||
monthBasis,
|
||
setPreset,
|
||
setAccountId,
|
||
setCategoryIds,
|
||
setMonthBasis,
|
||
} = useFilters();
|
||
const accountId = useAccountFilterId();
|
||
|
||
const categories = useQuery(api.categories.list);
|
||
const accounts = useQuery(api.accounts.list);
|
||
const removeTx = useMutation(api.transactions.remove);
|
||
const updateTx = useMutation(api.transactions.update);
|
||
const bulkCategory = useMutation(api.transactions.bulkSetCategory);
|
||
|
||
const { results, status, loadMore } = usePaginatedQuery(
|
||
api.transactions.list,
|
||
{
|
||
search: search || undefined,
|
||
from,
|
||
to,
|
||
accountId,
|
||
basis: monthBasis,
|
||
type: type === "all" ? undefined : type,
|
||
pendingOnly: pendingOnly || undefined,
|
||
categoryIds:
|
||
categoryIds.length > 0
|
||
? (categoryIds.filter((id) => id !== "__none__") as Id<"categories">[])
|
||
: undefined,
|
||
withoutCategory: categoryIds.includes("__none__") || undefined,
|
||
},
|
||
{ initialNumItems: 50 },
|
||
);
|
||
|
||
const categoryMap = useMemo(
|
||
() => new Map(categories?.map((c) => [c._id, c])),
|
||
[categories],
|
||
);
|
||
const accountMap = useMemo(
|
||
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||
[accounts],
|
||
);
|
||
const visibleTransactions = results ?? EMPTY_TRANSACTIONS;
|
||
const visibleSelectionState = useMemo(
|
||
() => getVisibleSelectionState(visibleTransactions, rowSelection),
|
||
[visibleTransactions, rowSelection],
|
||
);
|
||
const selectedTotal = useMemo(
|
||
() => selectedTransactionsTotal(visibleTransactions, rowSelection),
|
||
[visibleTransactions, rowSelection],
|
||
);
|
||
|
||
const handleUpdateCategory = useCallback(
|
||
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||
void updateTx({ id, categoryId });
|
||
},
|
||
[updateTx],
|
||
);
|
||
const handleEdit = useCallback((tx: Tx) => setEditTx(tx), []);
|
||
const handleAssign = useCallback((tx: Tx) => setAssignTx(tx), []);
|
||
const handleRemove = useCallback(
|
||
async (id: Id<"transactions">) => {
|
||
if (confirm("Transaktion löschen?")) {
|
||
await removeTx({ id });
|
||
toast.success("Gelöscht");
|
||
}
|
||
},
|
||
[removeTx],
|
||
);
|
||
const handleToggleAllVisible = useCallback(
|
||
(checked: boolean) => {
|
||
setRowSelection((selection) =>
|
||
toggleVisibleSelection(selection, visibleTransactions, checked),
|
||
);
|
||
},
|
||
[visibleTransactions],
|
||
);
|
||
const handleResetFilters = useCallback(() => {
|
||
const reset = getResetTransactionFilterState();
|
||
|
||
setPreset(reset.global.preset);
|
||
setAccountId(reset.global.accountId);
|
||
setCategoryIds([...reset.global.categoryIds]);
|
||
setMonthBasis(reset.global.monthBasis);
|
||
setSearch(reset.page.search);
|
||
setType(reset.page.type);
|
||
setPendingOnly(reset.page.pendingOnly);
|
||
setRowSelection(reset.page.rowSelection);
|
||
}, [setAccountId, setCategoryIds, setMonthBasis, setPreset]);
|
||
|
||
const selectedIds = useMemo(
|
||
() =>
|
||
visibleTransactions
|
||
.filter((tx) => rowSelection[tx._id])
|
||
.map((tx) => tx._id),
|
||
[visibleTransactions, rowSelection],
|
||
);
|
||
|
||
const columns = useMemo<ColumnDef<Tx>[]>(
|
||
() => [
|
||
{
|
||
id: "select",
|
||
header: () => (
|
||
<RowCheckbox
|
||
checked={visibleSelectionState.allSelected}
|
||
indeterminate={
|
||
visibleSelectionState.someSelected && !visibleSelectionState.allSelected
|
||
}
|
||
disabled={visibleTransactions.length === 0}
|
||
ariaLabel={
|
||
visibleSelectionState.allSelected
|
||
? "Alle sichtbaren Transaktionen abwählen"
|
||
: "Alle sichtbaren Transaktionen auswählen"
|
||
}
|
||
onToggle={(event) => handleToggleAllVisible(event.target.checked)}
|
||
/>
|
||
),
|
||
cell: ({ row }) => (
|
||
<RowCheckbox
|
||
checked={row.getIsSelected()}
|
||
ariaLabel="Transaktion auswählen"
|
||
onToggle={(event) => row.getToggleSelectedHandler()(event)}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "bookingDate",
|
||
header: "Datum",
|
||
cell: ({ row }) =>
|
||
row.original.isPending ? "offen" : formatDate(row.original.bookingDate),
|
||
},
|
||
{ accessorKey: "description", header: "Beschreibung" },
|
||
{ accessorKey: "counterparty", header: "Gegenpartei" },
|
||
{
|
||
id: "account",
|
||
header: "Konto",
|
||
cell: ({ row }) => (
|
||
<AccountCell
|
||
name={row.original.accountId ? accountMap.get(row.original.accountId) : undefined}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: "category",
|
||
header: "Kategorie",
|
||
cell: ({ row }) => (
|
||
<CategoryCell
|
||
tx={row.original}
|
||
categories={categories}
|
||
categoryMap={categoryMap}
|
||
onUpdate={handleUpdateCategory}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "amount",
|
||
header: () => <span className="float-right">Betrag</span>,
|
||
cell: ({ row }) => (
|
||
<span className={`float-right font-medium ${amountClass(row.original.amount)}`}>
|
||
{formatAmount(row.original.amount)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
id: "assigned",
|
||
header: "Zuordnung",
|
||
cell: ({ row }) => {
|
||
const bookingMonth = row.original.bookingDate?.slice(0, 7);
|
||
return (
|
||
<AssignedCell assignedMonth={row.original.assignedMonth} bookingMonth={bookingMonth} />
|
||
);
|
||
},
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: "",
|
||
cell: ({ row }) => (
|
||
<ActionsCell
|
||
tx={row.original}
|
||
onEdit={handleEdit}
|
||
onAssign={handleAssign}
|
||
onRemove={handleRemove}
|
||
/>
|
||
),
|
||
},
|
||
],
|
||
[
|
||
categories,
|
||
categoryMap,
|
||
accountMap,
|
||
handleUpdateCategory,
|
||
handleEdit,
|
||
handleAssign,
|
||
handleRemove,
|
||
visibleSelectionState,
|
||
visibleTransactions.length,
|
||
handleToggleAllVisible,
|
||
],
|
||
);
|
||
|
||
const table = useReactTable({
|
||
data: visibleTransactions,
|
||
columns,
|
||
state: { rowSelection },
|
||
onRowSelectionChange: setRowSelection,
|
||
getRowId: (row) => row._id,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
});
|
||
|
||
/* ── Virtualization ── */
|
||
const parentRef = useRef<HTMLDivElement>(null);
|
||
const rows = table.getRowModel().rows;
|
||
|
||
const rowVirtualizer = useVirtualizer({
|
||
count: rows.length,
|
||
getScrollElement: () => parentRef.current,
|
||
estimateSize: () => 44,
|
||
overscan: 10,
|
||
});
|
||
|
||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||
const totalSize = rowVirtualizer.getTotalSize();
|
||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||
const paddingBottom =
|
||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||
|
||
/* ── Auto-load more when scrolling near the end ── */
|
||
useEffect(() => {
|
||
if (status !== "CanLoadMore") return;
|
||
const last = virtualRows[virtualRows.length - 1];
|
||
if (last && last.index >= rows.length - 10) {
|
||
loadMore(50);
|
||
}
|
||
}, [virtualRows, rows.length, status, loadMore]);
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-8rem)] min-h-0 flex-col gap-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Input
|
||
placeholder="Suche…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="max-w-xs"
|
||
/>
|
||
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
|
||
<SelectTrigger className="w-[140px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Alle Typen</SelectItem>
|
||
<SelectItem value="einnahme">Einnahmen</SelectItem>
|
||
<SelectItem value="ausgabe">Ausgaben</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<CategoryFilter />
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={pendingOnly}
|
||
onChange={(e) => setPendingOnly(e.target.checked)}
|
||
/>
|
||
Nur offene
|
||
</label>
|
||
<Button variant="outline" onClick={handleResetFilters}>
|
||
<RotateCcw className="h-4 w-4" />
|
||
Zurücksetzen
|
||
</Button>
|
||
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
|
||
{visibleSelectionState.someSelected && (
|
||
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm">
|
||
<span className="font-medium">
|
||
{visibleSelectionState.selectedCount} ausgewählt
|
||
</span>
|
||
<span className="text-muted-foreground">Summe</span>
|
||
<span className={`font-semibold tabular-nums ${amountClass(selectedTotal)}`}>
|
||
{formatAmount(selectedTotal)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{selectedIds.length > 0 && categories && (
|
||
<Select
|
||
onValueChange={async (categoryId) => {
|
||
await bulkCategory({
|
||
ids: selectedIds,
|
||
categoryId: categoryId as Id<"categories">,
|
||
});
|
||
toast.success("Kategorie zugewiesen");
|
||
setRowSelection({});
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="Bulk-Kategorie" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{categories.map((c) => (
|
||
<SelectItem key={c._id} value={c._id}>
|
||
{c.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
)}
|
||
</div>
|
||
|
||
<div className="min-h-0 flex-1 rounded-md border">
|
||
<div ref={parentRef} className="relative h-full w-full overflow-auto">
|
||
<table className="w-full caption-bottom text-sm">
|
||
<TableHeader>
|
||
{table.getHeaderGroups().map((hg) => (
|
||
<TableRow key={hg.id}>
|
||
{hg.headers.map((h) => (
|
||
<TableHead key={h.id} className="sticky top-0 z-10 bg-background">
|
||
{flexRender(h.column.columnDef.header, h.getContext())}
|
||
</TableHead>
|
||
))}
|
||
</TableRow>
|
||
))}
|
||
</TableHeader>
|
||
<TableBody>
|
||
{paddingTop > 0 && <tr style={{ height: paddingTop }} />}
|
||
{virtualRows.map((virtualRow) => {
|
||
const row = rows[virtualRow.index];
|
||
return (
|
||
<tr
|
||
key={row.id}
|
||
data-index={virtualRow.index}
|
||
ref={(node: HTMLTableRowElement | null) =>
|
||
rowVirtualizer.measureElement(node)
|
||
}
|
||
className="border-b transition-colors hover:bg-muted/50"
|
||
>
|
||
{row.getVisibleCells().map((cell) => (
|
||
<TableCell key={cell.id}>
|
||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
</TableCell>
|
||
))}
|
||
</tr>
|
||
);
|
||
})}
|
||
{paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
|
||
</TableBody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||
<TransactionFormDialog
|
||
open={!!editTx}
|
||
onOpenChange={(o) => !o && setEditTx(null)}
|
||
transaction={editTx ?? undefined}
|
||
/>
|
||
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
|
||
</div>
|
||
);
|
||
}
|