Reuse CategoryPicker in transactions page
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: TASK-13
|
||||
title: Improve transaction category picker
|
||||
status: In Progress
|
||||
assignee: []
|
||||
created_date: '2026-06-16 10:08'
|
||||
updated_date: '2026-06-16 10:15'
|
||||
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 -->
|
||||
- [ ] #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.
|
||||
- [ ] #3 Long category names are truncated cleanly without increasing table row height.
|
||||
- [ ] #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 -->
|
||||
154
src/components/transactions/CategoryPicker.tsx
Normal file
154
src/components/transactions/CategoryPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/transactions/categoryPickerModel.test.ts
Normal file
71
src/components/transactions/categoryPickerModel.test.ts
Normal 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]],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
56
src/components/transactions/categoryPickerModel.ts
Normal file
56
src/components/transactions/categoryPickerModel.ts
Normal 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";
|
||||
}
|
||||
@@ -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 }}
|
||||
<CategoryPicker
|
||||
categories={categories}
|
||||
value={tx.categoryId}
|
||||
onSelect={(categoryId) => onUpdate(tx._id, categoryId)}
|
||||
/>
|
||||
{c.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user