Add effective-date transaction filtering and bulk selection
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user