Add effective-date transaction filtering and bulk selection
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: TASK-4
|
||||
title: Add selection total to transactions
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:21'
|
||||
updated_date: '2026-06-15 19:24'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 4000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add table selection controls on the transactions page so visible/filtered rows can be selected in bulk and the sum of selected transaction amounts is shown.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 A select-all control can select and clear all currently visible filtered transactions
|
||||
- [x] #2 Individual row selection continues to work alongside the select-all control
|
||||
- [x] #3 The UI displays the count and formatted euro total of selected transactions
|
||||
- [x] #4 Automated tests cover bulk selection and selected-total behavior
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Inspect current transaction table selection behavior
|
||||
2. Add failing coverage for select-all and selected sum
|
||||
3. Implement visible-row bulk selection and selected total UI
|
||||
4. Run focused tests/build and update acceptance criteria
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added failing Vitest coverage for visible bulk selection and selected totals, then implemented transactionsSelection helpers and wired TransactionsPage header checkbox plus selected count/sum. Focused test and build pass. Global lint currently fails on pre-existing unrelated lint errors in Convex/generated/UI files.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: TASK-5
|
||||
title: Make transaction filters combinable
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:32'
|
||||
updated_date: '2026-06-15 19:34'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 5000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Fix the transactions page so global timeframe, account, and month-basis filters are applied together with page-level filters like search, type, category, and pending-only.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Changing the date range filters the transactions list
|
||||
- [x] #2 Account, month basis, search, type, category, and pending-only filters combine instead of overriding each other
|
||||
- [x] #3 Automated tests cover combined transaction filtering
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Trace current filter data flow from shared controls to transactions query
|
||||
2. Add failing Convex test for combined date/account/category/search filtering
|
||||
3. Extend transactions list query args and wire TransactionsPage to shared filters
|
||||
4. Run focused tests, full tests/build, and update task notes
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause: TransactionsPage did not pass global from/to/account/monthBasis filters into api.transactions.list, and transactions.list ignored date filters on the search-index path. Added red Convex tests covering combined filters and effective-month basis, then added basis/date filtering in the query and wired global filters from the page. Focused tests and build pass.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
id: TASK-6
|
||||
title: Add transaction filter reset button
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-15 19:35'
|
||||
updated_date: '2026-06-15 19:37'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 6000
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add a reset button on the transactions page that clears the active filter combination and returns global plus page-level transaction filters to their defaults.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 A reset button is available on the transactions page filter bar
|
||||
- [x] #2 Clicking reset clears search, type, category, account, pending-only, and row selection
|
||||
- [x] #3 Clicking reset returns date range and month basis to their defaults
|
||||
- [x] #4 Automated tests cover the reset defaults
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added a transactions toolbar reset button using shared reset defaults. The button clears global filters (current month, all accounts, no categories, effective month basis), clears page filters (search, type, pending-only), and clears row selection. Added Vitest coverage for reset defaults. Focused tests and build pass.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
189
convex/transactions.test.ts
Normal file
189
convex/transactions.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import { convexTest } from "convex-test";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { api } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
delete modules["./transactions.test.ts"];
|
||||
|
||||
describe("transactions.list", () => {
|
||||
test("combines search, date range, account, category, and type filters", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Filter User",
|
||||
email: "filter@example.com",
|
||||
});
|
||||
const giroAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const otherAccountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Depot",
|
||||
type: "investment",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const groceryCategoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Lebensmittel & Supermarkt",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#ef4444",
|
||||
sortOrder: 1,
|
||||
isSystem: false,
|
||||
});
|
||||
const restaurantCategoryId = await ctx.db.insert("categories", {
|
||||
userId,
|
||||
name: "Restaurant",
|
||||
kind: "ausgabe",
|
||||
block: "variabel",
|
||||
color: "#f97316",
|
||||
sortOrder: 2,
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
const matchingId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-15",
|
||||
description: "LIDL SAGT DANKE",
|
||||
amount: -18.37,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-05-29",
|
||||
description: "LIDL OLD MONTH",
|
||||
amount: -21.92,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-05",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: otherAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-16",
|
||||
description: "LIDL OTHER ACCOUNT",
|
||||
amount: -99,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: restaurantCategoryId,
|
||||
bookingDate: "2026-06-17",
|
||||
description: "LIDL RESTAURANT",
|
||||
amount: -12,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId: giroAccountId,
|
||||
categoryId: groceryCategoryId,
|
||||
bookingDate: "2026-06-18",
|
||||
description: "LIDL REFUND",
|
||||
amount: 5,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
|
||||
return { userId, giroAccountId, groceryCategoryId, matchingId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const result = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
search: "LIDL",
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
accountId: seeded.giroAccountId as Id<"accounts">,
|
||||
categoryIds: [seeded.groceryCategoryId as Id<"categories">],
|
||||
type: "ausgabe",
|
||||
basis: "booking",
|
||||
});
|
||||
|
||||
expect(result.page.map((tx) => tx._id)).toEqual([seeded.matchingId]);
|
||||
});
|
||||
|
||||
test("filters by effective month when the global basis is assignment month", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
|
||||
const seeded = await t.run(async (ctx) => {
|
||||
const userId = await ctx.db.insert("users", {
|
||||
name: "Basis User",
|
||||
email: "basis@example.com",
|
||||
});
|
||||
const accountId = await ctx.db.insert("accounts", {
|
||||
userId,
|
||||
name: "Girokonto",
|
||||
type: "checking",
|
||||
openingBalance: 0,
|
||||
currency: "EUR",
|
||||
isArchived: false,
|
||||
});
|
||||
const shiftedId = await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-05-29",
|
||||
description: "Salary shifted into June",
|
||||
amount: 2500,
|
||||
isPending: false,
|
||||
assignedMonth: "2026-06",
|
||||
effectiveMonth: "2026-06",
|
||||
});
|
||||
await ctx.db.insert("transactions", {
|
||||
userId,
|
||||
accountId,
|
||||
bookingDate: "2026-05-20",
|
||||
description: "May only",
|
||||
amount: -15,
|
||||
isPending: false,
|
||||
effectiveMonth: "2026-05",
|
||||
});
|
||||
|
||||
return { userId, shiftedId };
|
||||
});
|
||||
|
||||
const asUser = t.withIdentity({
|
||||
subject: `${seeded.userId}|test-session`,
|
||||
tokenIdentifier: `test:${seeded.userId}`,
|
||||
});
|
||||
|
||||
const effectiveResult = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
basis: "effective",
|
||||
});
|
||||
const bookingResult = await asUser.query(api.transactions.list, {
|
||||
paginationOpts: { cursor: null, numItems: 20 },
|
||||
from: "2026-06-01",
|
||||
to: "2026-06-30",
|
||||
basis: "booking",
|
||||
});
|
||||
|
||||
expect(effectiveResult.page.map((tx) => tx._id)).toEqual([seeded.shiftedId]);
|
||||
expect(bookingResult.page).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ export const list = query({
|
||||
categoryIds: v.optional(v.array(v.id("categories"))),
|
||||
withoutCategory: v.optional(v.boolean()),
|
||||
accountId: v.optional(v.id("accounts")),
|
||||
basis: v.optional(v.union(v.literal("effective"), v.literal("booking"))),
|
||||
type: v.optional(v.union(v.literal("einnahme"), v.literal("ausgabe"))),
|
||||
pendingOnly: v.optional(v.boolean()),
|
||||
},
|
||||
@@ -51,6 +52,9 @@ export const list = query({
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
const basis = args.basis ?? "booking";
|
||||
const fromMonth = args.from?.slice(0, 7);
|
||||
const toMonth = args.to?.slice(0, 7);
|
||||
|
||||
let q;
|
||||
if (args.search) {
|
||||
@@ -60,21 +64,77 @@ export const list = query({
|
||||
sq.search("description", args.search!).eq("userId", userId),
|
||||
);
|
||||
} else {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (iq) => {
|
||||
if (args.from && args.to) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||
}
|
||||
if (args.from) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||
}
|
||||
if (args.to) {
|
||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.order("desc");
|
||||
if (basis === "effective") {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_effmonth", (iq) => {
|
||||
if (fromMonth && toMonth) {
|
||||
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth).lte("effectiveMonth", toMonth);
|
||||
}
|
||||
if (fromMonth) {
|
||||
return iq.eq("userId", userId).gte("effectiveMonth", fromMonth);
|
||||
}
|
||||
if (toMonth) {
|
||||
return iq.eq("userId", userId).lte("effectiveMonth", toMonth);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.order("desc");
|
||||
} else {
|
||||
q = ctx.db
|
||||
.query("transactions")
|
||||
.withIndex("by_user_booking", (iq) => {
|
||||
if (args.from && args.to) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from).lte("bookingDate", args.to);
|
||||
}
|
||||
if (args.from) {
|
||||
return iq.eq("userId", userId).gte("bookingDate", args.from);
|
||||
}
|
||||
if (args.to) {
|
||||
return iq.eq("userId", userId).lte("bookingDate", args.to);
|
||||
}
|
||||
return iq.eq("userId", userId);
|
||||
})
|
||||
.order("desc");
|
||||
}
|
||||
}
|
||||
|
||||
if (args.search) {
|
||||
if (basis === "effective") {
|
||||
if (fromMonth) {
|
||||
const fallbackFrom = `${fromMonth}-01`;
|
||||
q = q.filter((f) =>
|
||||
f.or(
|
||||
f.gte(f.field("effectiveMonth"), fromMonth),
|
||||
f.and(
|
||||
f.eq(f.field("effectiveMonth"), undefined),
|
||||
f.gte(f.field("bookingDate"), fallbackFrom),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (toMonth) {
|
||||
const fallbackTo = `${toMonth}-31`;
|
||||
q = q.filter((f) =>
|
||||
f.or(
|
||||
f.lte(f.field("effectiveMonth"), toMonth),
|
||||
f.and(
|
||||
f.eq(f.field("effectiveMonth"), undefined),
|
||||
f.lte(f.field("bookingDate"), fallbackTo),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (args.from) {
|
||||
const from = args.from;
|
||||
q = q.filter((f) => f.gte(f.field("bookingDate"), from));
|
||||
}
|
||||
if (args.to) {
|
||||
const to = args.to;
|
||||
q = q.filter((f) => f.lte(f.field("bookingDate"), to));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.pendingOnly) {
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -158,7 +158,7 @@ importers:
|
||||
version: 7.1.1(eslint@10.5.0(jiti@2.7.0))
|
||||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2(eslint@10.5.0(jiti@2.7.0))
|
||||
version: 0.5.3(eslint@10.5.0(jiti@2.7.0))
|
||||
globals:
|
||||
specifier: ^17.6.0
|
||||
version: 17.6.0
|
||||
@@ -1655,8 +1655,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
eslint-plugin-react-refresh@0.5.2:
|
||||
resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==}
|
||||
eslint-plugin-react-refresh@0.5.3:
|
||||
resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==}
|
||||
peerDependencies:
|
||||
eslint: ^9 || ^10
|
||||
|
||||
@@ -3828,7 +3828,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-react-refresh@0.5.2(eslint@10.5.0(jiti@2.7.0)):
|
||||
eslint-plugin-react-refresh@0.5.3(eslint@10.5.0(jiti@2.7.0)):
|
||||
dependencies:
|
||||
eslint: 10.5.0(jiti@2.7.0)
|
||||
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
minimumReleaseAgeExclude:
|
||||
- '@vitest/expect@4.1.9'
|
||||
- '@vitest/mocker@4.1.9'
|
||||
- '@vitest/pretty-format@4.1.9'
|
||||
- '@vitest/runner@4.1.9'
|
||||
- '@vitest/snapshot@4.1.9'
|
||||
- '@vitest/spy@4.1.9'
|
||||
- '@vitest/utils@4.1.9'
|
||||
- vitest@4.1.9
|
||||
|
||||
29
src/lib/transactionFilterReset.ts
Normal file
29
src/lib/transactionFilterReset.ts
Normal 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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
25
src/pages/transactionFilterReset.test.ts
Normal file
25
src/pages/transactionFilterReset.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
48
src/pages/transactionsSelection.test.ts
Normal file
48
src/pages/transactionsSelection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
src/pages/transactionsSelection.ts
Normal file
49
src/pages/transactionsSelection.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user