diff --git a/backlog/tasks/task-4 - Add-selection-total-to-transactions.md b/backlog/tasks/task-4 - Add-selection-total-to-transactions.md new file mode 100644 index 0000000..0055cd7 --- /dev/null +++ b/backlog/tasks/task-4 - Add-selection-total-to-transactions.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/backlog/tasks/task-5 - Make-transaction-filters-combinable.md b/backlog/tasks/task-5 - Make-transaction-filters-combinable.md new file mode 100644 index 0000000..bc768aa --- /dev/null +++ b/backlog/tasks/task-5 - Make-transaction-filters-combinable.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Plan + + +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 + + +## Implementation Notes + + +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. + diff --git a/backlog/tasks/task-6 - Add-transaction-filter-reset-button.md b/backlog/tasks/task-6 - Add-transaction-filter-reset-button.md new file mode 100644 index 0000000..663744e --- /dev/null +++ b/backlog/tasks/task-6 - Add-transaction-filter-reset-button.md @@ -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 + + +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. + + +## Acceptance Criteria + +- [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 + + +## Implementation Notes + + +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. + diff --git a/convex/transactions.test.ts b/convex/transactions.test.ts new file mode 100644 index 0000000..0826038 --- /dev/null +++ b/convex/transactions.test.ts @@ -0,0 +1,189 @@ +/// + +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([]); + }); +}); diff --git a/convex/transactions.ts b/convex/transactions.ts index ab7d621..9cbaad9 100644 --- a/convex/transactions.ts +++ b/convex/transactions.ts @@ -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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fa6099..e328f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5ed0b5a..7b7d34f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/lib/transactionFilterReset.ts b/src/lib/transactionFilterReset.ts new file mode 100644 index 0000000..bf83500 --- /dev/null +++ b/src/lib/transactionFilterReset.ts @@ -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 }, + }, + }; +} diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index 1a4db5e..b2a5c9b 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -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; }) { - return ; + const ref = useRef(null); + + useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate; + }, [indeterminate]); + + return ( + + ); }); const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) { @@ -152,7 +191,17 @@ export function TransactionsPage() { const [assignTx, setAssignTx] = useState(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[]>( () => [ { id: "select", - header: () => null, + header: () => ( + handleToggleAllVisible(event.target.checked)} + /> + ), cell: ({ row }) => ( 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 + + {visibleSelectionState.someSelected && ( +
+ + {visibleSelectionState.selectedCount} ausgewählt + + Summe + + {formatAmount(selectedTotal)} + +
+ )} {selectedIds.length > 0 && categories && (