Add effective-date transaction filtering and bulk selection

This commit is contained in:
2026-06-15 21:38:25 +02:00
parent 1c88d12f0d
commit 238a30ae0c
12 changed files with 668 additions and 28 deletions

View File

@@ -0,0 +1,29 @@
export const DEFAULT_TRANSACTION_FILTER_RESET = {
global: {
preset: "current-month",
accountId: undefined,
categoryIds: [],
monthBasis: "effective",
},
page: {
search: "",
type: "all",
pendingOnly: false,
rowSelection: {},
},
} as const;
export type TransactionFilterResetState = typeof DEFAULT_TRANSACTION_FILTER_RESET;
export function getResetTransactionFilterState(): TransactionFilterResetState {
return {
global: {
...DEFAULT_TRANSACTION_FILTER_RESET.global,
categoryIds: [...DEFAULT_TRANSACTION_FILTER_RESET.global.categoryIds],
},
page: {
...DEFAULT_TRANSACTION_FILTER_RESET.page,
rowSelection: { ...DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection },
},
};
}

View File

@@ -1,4 +1,12 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEventHandler,
} from "react";
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
import {
flexRender,
@@ -10,6 +18,7 @@ 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";
@@ -20,20 +29,50 @@ 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;
onToggle: (e?: unknown) => void;
indeterminate?: boolean;
disabled?: boolean;
ariaLabel: string;
onToggle: ChangeEventHandler<HTMLInputElement>;
}) {
return <input type="checkbox" checked={checked} onChange={onToggle} />;
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 }) {
@@ -152,7 +191,17 @@ export function TransactionsPage() {
const [assignTx, setAssignTx] = useState<Tx | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const { categoryIds } = useFilters();
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);
@@ -164,6 +213,10 @@ export function TransactionsPage() {
api.transactions.list,
{
search: search || undefined,
from,
to,
accountId,
basis: monthBasis,
type: type === "all" ? undefined : type,
pendingOnly: pendingOnly || undefined,
categoryIds:
@@ -183,6 +236,15 @@ export function TransactionsPage() {
() => 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">) => {
@@ -201,21 +263,59 @@ export function TransactionsPage() {
},
[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(
() => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[],
[rowSelection],
() =>
visibleTransactions
.filter((tx) => rowSelection[tx._id])
.map((tx) => tx._id),
[visibleTransactions, rowSelection],
);
const columns = useMemo<ColumnDef<Tx>[]>(
() => [
{
id: "select",
header: () => null,
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()}
onToggle={row.getToggleSelectedHandler()}
ariaLabel="Transaktion auswählen"
onToggle={(event) => row.getToggleSelectedHandler()(event)}
/>
),
},
@@ -288,11 +388,14 @@ export function TransactionsPage() {
handleEdit,
handleAssign,
handleRemove,
visibleSelectionState,
visibleTransactions.length,
handleToggleAllVisible,
],
);
const table = useReactTable({
data: results ?? [],
data: visibleTransactions,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
@@ -354,7 +457,22 @@ export function TransactionsPage() {
/>
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) => {

View File

@@ -0,0 +1,25 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_TRANSACTION_FILTER_RESET,
getResetTransactionFilterState,
} from "@/lib/transactionFilterReset";
describe("transaction filter reset defaults", () => {
test("resets global and page-level transaction filters", () => {
expect(getResetTransactionFilterState()).toEqual({
global: {
preset: "current-month",
accountId: undefined,
categoryIds: [],
monthBasis: "effective",
},
page: {
search: "",
type: "all",
pendingOnly: false,
rowSelection: {},
},
});
expect(DEFAULT_TRANSACTION_FILTER_RESET.page.rowSelection).toEqual({});
});
});

View File

@@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import {
getVisibleSelectionState,
selectedTransactionsTotal,
toggleVisibleSelection,
} from "./transactionsSelection";
const visibleTransactions = [
{ _id: "tx-1", amount: -55.68 },
{ _id: "tx-2", amount: -26.75 },
{ _id: "tx-3", amount: 12 },
];
describe("transactions selection helpers", () => {
test("selects and clears all visible transactions without changing hidden selections", () => {
expect(
toggleVisibleSelection(
{ "hidden-tx": true, "tx-1": true },
visibleTransactions,
true,
),
).toEqual({
"hidden-tx": true,
"tx-1": true,
"tx-2": true,
"tx-3": true,
});
expect(
toggleVisibleSelection(
{ "hidden-tx": true, "tx-1": true, "tx-2": true, "tx-3": true },
visibleTransactions,
false,
),
).toEqual({ "hidden-tx": true });
});
test("computes selected count, all-selected state, and signed total from visible transactions", () => {
const selection = { "hidden-tx": true, "tx-1": true, "tx-3": true };
expect(getVisibleSelectionState(visibleTransactions, selection)).toEqual({
allSelected: false,
someSelected: true,
selectedCount: 2,
});
expect(selectedTransactionsTotal(visibleTransactions, selection)).toBe(-43.68);
});
});

View File

@@ -0,0 +1,49 @@
export type TransactionSelection = Record<string, boolean>;
export type SelectableTransaction = {
_id: string;
amount: number;
};
export function toggleVisibleSelection(
selection: TransactionSelection,
visibleTransactions: SelectableTransaction[],
selectAll: boolean,
): TransactionSelection {
const next = { ...selection };
for (const tx of visibleTransactions) {
if (selectAll) {
next[tx._id] = true;
} else {
delete next[tx._id];
}
}
return next;
}
export function getVisibleSelectionState(
visibleTransactions: SelectableTransaction[],
selection: TransactionSelection,
) {
const selectedCount = visibleTransactions.filter((tx) => selection[tx._id]).length;
return {
allSelected: visibleTransactions.length > 0 && selectedCount === visibleTransactions.length,
someSelected: selectedCount > 0,
selectedCount,
};
}
export function selectedTransactionsTotal(
visibleTransactions: SelectableTransaction[],
selection: TransactionSelection,
): number {
const total = visibleTransactions.reduce(
(sum, tx) => (selection[tx._id] ? sum + tx.amount : sum),
0,
);
return Math.round(total * 100) / 100;
}