Merge category picker improvements

This commit is contained in:
Matthias
2026-06-23 11:46:35 +02:00
5 changed files with 343 additions and 60 deletions

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the cramped inline category Select in the transactions table with a polished popover picker that preserves row height, supports search, and groups categories.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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<void>;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [pendingId, setPendingId] = useState<Id<"categories"> | null>(null);
const searchRef = useRef<HTMLInputElement>(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 (
<Popover.Root
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}
>
<Popover.Trigger asChild>
<button
type="button"
className="group inline-flex h-7 max-w-[180px] items-center gap-1 rounded px-1 text-left text-xs transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={
selectedCategory
? `Kategorie ändern: ${selectedCategory.name}`
: "Kategorie auswählen"
}
>
{selectedCategory ? (
<Badge
className="max-w-[155px] whitespace-nowrap border-none px-2 py-0.5 text-[11px] leading-4 shadow-none"
style={{ backgroundColor: selectedCategory.color, color: "#fff" }}
>
<span className="min-w-0 truncate">{selectedCategory.name}</span>
</Badge>
) : (
<span className="max-w-[145px] truncate text-muted-foreground">Kategorie...</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-70 group-focus-visible:opacity-70 group-data-[state=open]:opacity-70" />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={6}
className="z-50 w-[min(320px,calc(100vw-2rem))] rounded-md border bg-popover p-2 text-popover-foreground shadow-lg"
onOpenAutoFocus={(event) => {
event.preventDefault();
searchRef.current?.focus();
}}
>
<div className="relative mb-2">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Kategorie suchen..."
className="h-8 pl-8 text-sm"
/>
</div>
<div className="max-h-80 overflow-y-auto pr-1">
{groupedCategories.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
Keine Kategorie gefunden
</div>
) : (
groupedCategories.map((group) => (
<div key={group.key} className="pb-1 last:pb-0">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{group.label}
</div>
<div className="space-y-0.5">
{group.options.map((category) => {
const selected = category._id === value;
const pending = category._id === pendingId;
return (
<button
key={category._id}
type="button"
disabled={pendingId !== null}
onClick={() => void handleSelect(category._id)}
className={cn(
"flex h-8 w-full items-center gap-2 rounded-sm px-2 text-left text-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-60",
selected && "bg-accent/70",
)}
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="min-w-0 flex-1 truncate">{category.name}</span>
{pending ? (
<span className="text-xs text-muted-foreground">Speichert</span>
) : (
selected && <Check className="h-4 w-4 shrink-0" />
)}
</button>
);
})}
</div>
</div>
))
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -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]],
},
]);
});
});

View File

@@ -0,0 +1,56 @@
export type CategoryPickerOption = {
_id: string;
name: string;
kind: "einnahme" | "ausgabe";
block?: "wiederkehrend" | "variabel";
color: string;
sortOrder: number;
};
export type CategoryPickerGroup<T extends CategoryPickerOption = CategoryPickerOption> = {
key: "income" | "fixed-expenses" | "variable-expenses";
label: string;
options: T[];
};
const GROUPS: Array<Pick<CategoryPickerGroup, "key" | "label">> = [
{ key: "income", label: "Einnahmen" },
{ key: "fixed-expenses", label: "Fixe Ausgaben" },
{ key: "variable-expenses", label: "Variable Ausgaben" },
];
export function searchCategoryOptions<T extends CategoryPickerOption>(
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<T extends CategoryPickerOption>(
categories: readonly T[],
): Array<CategoryPickerGroup<T>> {
const groups = new Map<CategoryPickerGroup["key"], T[]>(
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";
}

View File

@@ -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<Id<"categories">, Category>;
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void;
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null;
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
>
{cat ? (
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
{cat.name}
</Badge>
) : (
<span className="text-muted-foreground">Kategorie</span>
)}
</button>
);
}
return (
<Select
defaultOpen
value={tx.categoryId ?? "none"}
onValueChange={(v) => {
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
setOpen(false);
}}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<SelectTrigger className="h-7 w-[130px]">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories?.map((c) => (
<SelectItem key={c._id} value={c._id}>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: c.color }}
/>
{c.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<CategoryPicker
categories={categories}
value={tx.categoryId}
onSelect={(categoryId) => 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() {
<CategoryCell
tx={row.original}
categories={categories}
categoryMap={categoryMap}
onUpdate={handleUpdateCategory}
/>
),
@@ -382,7 +332,6 @@ export function TransactionsPage() {
],
[
categories,
categoryMap,
accountMap,
handleUpdateCategory,
handleEdit,