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 { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format";
|
||||||
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog";
|
||||||
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
|
||||||
|
import { CategoryPicker } from "@/components/transactions/CategoryPicker";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
getVisibleSelectionState,
|
getVisibleSelectionState,
|
||||||
@@ -82,64 +83,18 @@ const AccountCell = memo(function AccountCell({ name }: { name: string | undefin
|
|||||||
const CategoryCell = memo(function CategoryCell({
|
const CategoryCell = memo(function CategoryCell({
|
||||||
tx,
|
tx,
|
||||||
categories,
|
categories,
|
||||||
categoryMap,
|
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
tx: Tx;
|
tx: Tx;
|
||||||
categories: Category[] | undefined;
|
categories: Category[] | undefined;
|
||||||
categoryMap: Map<Id<"categories">, Category>;
|
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => Promise<void>;
|
||||||
onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => 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 (
|
return (
|
||||||
<Select
|
<CategoryPicker
|
||||||
defaultOpen
|
categories={categories}
|
||||||
value={tx.categoryId ?? "none"}
|
value={tx.categoryId}
|
||||||
onValueChange={(v) => {
|
onSelect={(categoryId) => onUpdate(tx._id, categoryId)}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,10 +183,6 @@ export function TransactionsPage() {
|
|||||||
{ initialNumItems: 50 },
|
{ initialNumItems: 50 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryMap = useMemo(
|
|
||||||
() => new Map(categories?.map((c) => [c._id, c])),
|
|
||||||
[categories],
|
|
||||||
);
|
|
||||||
const accountMap = useMemo(
|
const accountMap = useMemo(
|
||||||
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
() => new Map(accounts?.map((a) => [a._id, a.name])),
|
||||||
[accounts],
|
[accounts],
|
||||||
@@ -247,8 +198,8 @@ export function TransactionsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUpdateCategory = useCallback(
|
const handleUpdateCategory = useCallback(
|
||||||
(id: Id<"transactions">, categoryId: Id<"categories">) => {
|
async (id: Id<"transactions">, categoryId: Id<"categories">) => {
|
||||||
void updateTx({ id, categoryId });
|
await updateTx({ id, categoryId });
|
||||||
},
|
},
|
||||||
[updateTx],
|
[updateTx],
|
||||||
);
|
);
|
||||||
@@ -343,7 +294,6 @@ export function TransactionsPage() {
|
|||||||
<CategoryCell
|
<CategoryCell
|
||||||
tx={row.original}
|
tx={row.original}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryMap={categoryMap}
|
|
||||||
onUpdate={handleUpdateCategory}
|
onUpdate={handleUpdateCategory}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -382,7 +332,6 @@ export function TransactionsPage() {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
categories,
|
categories,
|
||||||
categoryMap,
|
|
||||||
accountMap,
|
accountMap,
|
||||||
handleUpdateCategory,
|
handleUpdateCategory,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
|
|||||||
Reference in New Issue
Block a user