diff --git a/backlog/tasks/task-13 - Improve-transaction-category-picker.md b/backlog/tasks/task-13 - Improve-transaction-category-picker.md new file mode 100644 index 0000000..5eac260 --- /dev/null +++ b/backlog/tasks/task-13 - Improve-transaction-category-picker.md @@ -0,0 +1,53 @@ +--- +id: TASK-13 +title: Improve transaction category picker +status: Done +assignee: [] +created_date: '2026-06-16 10:08' +updated_date: '2026-06-23 09:45' +labels: [] +dependencies: [] +priority: high +ordinal: 13000 +--- + +## Description + + +Replace the cramped inline category Select in the transactions table with a polished popover picker that preserves row height, supports search, and groups categories. + + +## Acceptance Criteria + +- [x] #1 Clicking a transaction category badge opens a compact popover instead of the current narrow Select. +- [x] #2 The picker supports client-side category search and groups income, fixed expense, and variable expense categories. +- [x] #3 Long category names are truncated cleanly without increasing table row height. +- [x] #4 Selecting a category updates the transaction, closes the popover, and reports failures with a toast. +- [x] #5 The project builds successfully. + + +## Implementation Plan + + +1. Extract category grouping/search helpers and cover them with tests +2. Replace inline category Select with a stable Popover picker +3. Wire async update handling and failure toast +4. Verify build and relevant tests + + +## Implementation Notes + + +Implemented CategoryPicker popover with search, grouped category options, selected-state checkmark, truncation, and async update error toast. +Verification: categoryPickerModel test passes; npm run build passes. +Lint: changed files pass with one existing TanStack React Compiler warning in TransactionsPage; full npm run lint still fails on pre-existing unrelated project errors. +Manual browser verification is still open because the in-app browser redirects to /login without the user session. + +Final verification: npx vitest run src/components/transactions/categoryPickerModel.test.ts passed (2 tests); npm run build passed; targeted eslint on changed files exited 0 with the existing TanStack useReactTable compiler warning only. + + +## Final Summary + + +Shipped a reusable inline CategoryPicker popover for transaction category changes, with grouped searchable categories, truncation for long labels, async update handling, and a regression test for grouping/search behavior. + diff --git a/src/components/transactions/CategoryPicker.tsx b/src/components/transactions/CategoryPicker.tsx new file mode 100644 index 0000000..f0ff37e --- /dev/null +++ b/src/components/transactions/CategoryPicker.tsx @@ -0,0 +1,154 @@ +import { useMemo, useRef, useState } from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { Check, ChevronDown, Search } from "lucide-react"; +import type { Doc, Id } from "../../../convex/_generated/dataModel"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { groupCategoryOptions, searchCategoryOptions } from "./categoryPickerModel"; +import { toast } from "sonner"; + +type Category = Doc<"categories">; + +export function CategoryPicker({ + categories, + value, + onSelect, +}: { + categories: Category[] | undefined; + value: Id<"categories"> | undefined; + onSelect: (categoryId: Id<"categories">) => Promise; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [pendingId, setPendingId] = useState | null>(null); + const searchRef = useRef(null); + + const selectedCategory = useMemo( + () => categories?.find((category) => category._id === value), + [categories, value], + ); + const groupedCategories = useMemo( + () => groupCategoryOptions(searchCategoryOptions(categories ?? [], query)), + [categories, query], + ); + + const handleSelect = async (categoryId: Id<"categories">) => { + if (categoryId === value) { + setOpen(false); + return; + } + + setPendingId(categoryId); + try { + await onSelect(categoryId); + setOpen(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Kategorie konnte nicht gespeichert werden"); + } finally { + setPendingId(null); + } + }; + + return ( + { + setOpen(nextOpen); + if (!nextOpen) setQuery(""); + }} + > + + + + + { + event.preventDefault(); + searchRef.current?.focus(); + }} + > +
+ + setQuery(event.target.value)} + placeholder="Kategorie suchen..." + className="h-8 pl-8 text-sm" + /> +
+ +
+ {groupedCategories.length === 0 ? ( +
+ Keine Kategorie gefunden +
+ ) : ( + groupedCategories.map((group) => ( +
+
+ {group.label} +
+
+ {group.options.map((category) => { + const selected = category._id === value; + const pending = category._id === pendingId; + + return ( + + ); + })} +
+
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/components/transactions/categoryPickerModel.test.ts b/src/components/transactions/categoryPickerModel.test.ts new file mode 100644 index 0000000..b436666 --- /dev/null +++ b/src/components/transactions/categoryPickerModel.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { groupCategoryOptions, searchCategoryOptions } from "./categoryPickerModel"; + +const categories = [ + { + _id: "income-1", + name: "Gehalt & Besoldung", + kind: "einnahme", + color: "#22c55e", + sortOrder: 1, + }, + { + _id: "fixed-1", + name: "Miete & Wohnen", + kind: "ausgabe", + block: "wiederkehrend", + color: "#6366f1", + sortOrder: 10, + }, + { + _id: "variable-1", + name: "Restaurant, Lieferdienst & Gastro", + kind: "ausgabe", + block: "variabel", + color: "#dc2626", + sortOrder: 37, + }, + { + _id: "fallback-1", + name: "Nicht zugeordnetes Einkommen", + kind: "einnahme", + color: "#6ee7b7", + sortOrder: 99, + }, +] as const; + +describe("categoryPickerModel", () => { + it("groups category options by income, fixed expenses, and variable expenses", () => { + const groups = groupCategoryOptions(categories); + + expect(groups).toEqual([ + { + key: "income", + label: "Einnahmen", + options: [categories[0], categories[3]], + }, + { + key: "fixed-expenses", + label: "Fixe Ausgaben", + options: [categories[1]], + }, + { + key: "variable-expenses", + label: "Variable Ausgaben", + options: [categories[2]], + }, + ]); + }); + + it("filters category options by name before grouping them", () => { + const filtered = searchCategoryOptions(categories, "liefer"); + + expect(groupCategoryOptions(filtered)).toEqual([ + { + key: "variable-expenses", + label: "Variable Ausgaben", + options: [categories[2]], + }, + ]); + }); +}); diff --git a/src/components/transactions/categoryPickerModel.ts b/src/components/transactions/categoryPickerModel.ts new file mode 100644 index 0000000..c2a68c7 --- /dev/null +++ b/src/components/transactions/categoryPickerModel.ts @@ -0,0 +1,56 @@ +export type CategoryPickerOption = { + _id: string; + name: string; + kind: "einnahme" | "ausgabe"; + block?: "wiederkehrend" | "variabel"; + color: string; + sortOrder: number; +}; + +export type CategoryPickerGroup = { + key: "income" | "fixed-expenses" | "variable-expenses"; + label: string; + options: T[]; +}; + +const GROUPS: Array> = [ + { key: "income", label: "Einnahmen" }, + { key: "fixed-expenses", label: "Fixe Ausgaben" }, + { key: "variable-expenses", label: "Variable Ausgaben" }, +]; + +export function searchCategoryOptions( + categories: readonly T[], + query: string, +): T[] { + const normalizedQuery = query.trim().toLocaleLowerCase("de-DE"); + + if (!normalizedQuery) return [...categories]; + + return categories.filter((category) => + category.name.toLocaleLowerCase("de-DE").includes(normalizedQuery), + ); +} + +export function groupCategoryOptions( + categories: readonly T[], +): Array> { + const groups = new Map( + GROUPS.map((group) => [group.key, []]), + ); + + for (const category of categories) { + groups.get(getCategoryGroupKey(category))?.push(category); + } + + return GROUPS.map((group) => ({ + ...group, + options: groups.get(group.key) ?? [], + })).filter((group) => group.options.length > 0); +} + +function getCategoryGroupKey(category: CategoryPickerOption): CategoryPickerGroup["key"] { + if (category.kind === "einnahme") return "income"; + if (category.block === "wiederkehrend") return "fixed-expenses"; + return "variable-expenses"; +} diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx index b2a5c9b..c7de79d 100644 --- a/src/pages/TransactionsPage.tsx +++ b/src/pages/TransactionsPage.tsx @@ -28,6 +28,7 @@ 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 { CategoryPicker } from "@/components/transactions/CategoryPicker"; import { toast } from "sonner"; import { getVisibleSelectionState, @@ -82,64 +83,18 @@ const AccountCell = memo(function AccountCell({ name }: { name: string | undefin const CategoryCell = memo(function CategoryCell({ tx, categories, - categoryMap, onUpdate, }: { tx: Tx; categories: Category[] | undefined; - categoryMap: Map, Category>; - onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void; + onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => Promise; }) { - const [open, setOpen] = useState(false); - const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null; - - if (!open) { - return ( - - ); - } - return ( - + onUpdate(tx._id, categoryId)} + /> ); }); @@ -228,10 +183,6 @@ export function TransactionsPage() { { 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], @@ -247,8 +198,8 @@ export function TransactionsPage() { ); const handleUpdateCategory = useCallback( - (id: Id<"transactions">, categoryId: Id<"categories">) => { - void updateTx({ id, categoryId }); + async (id: Id<"transactions">, categoryId: Id<"categories">) => { + await updateTx({ id, categoryId }); }, [updateTx], ); @@ -343,7 +294,6 @@ export function TransactionsPage() { ), @@ -382,7 +332,6 @@ export function TransactionsPage() { ], [ categories, - categoryMap, accountMap, handleUpdateCategory, handleEdit,