feat: add category management page (create/edit/delete)

The backend already had full category CRUD but nothing in the UI exposed
it — categories could only be viewed inside charts. Adds a Categories nav
item and page with icon/color pickers, grouped by expense/income, backed
by new create/update/delete mutations. Also expands icons.tsx's ICON_MAP,
which was missing 14 icon keys already in use by real category data and
silently falling back to a wallet glyph everywhere they render.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ZProger
2026-08-24 23:43:06 +03:00
parent 6cc532378b
commit 9f5ea00227
13 changed files with 512 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "aurum-frontend",
"private": true,
"version": "0.22.0",
"version": "0.23.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -9,6 +9,7 @@ import { AccountsPage } from "@/pages/AccountsPage";
import { AdvicePage } from "@/pages/AdvicePage";
import { BudgetPage } from "@/pages/BudgetPage";
import { CashFlowPage } from "@/pages/CashFlowPage";
import { CategoriesPage } from "@/pages/CategoriesPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { NetWorthPage } from "@/pages/NetWorthPage";
@@ -46,6 +47,7 @@ export default function App() {
<Route path="/net-worth" element={<NetWorthPage />} />
<Route path="/roi" element={<RoiPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/cash-flow" element={<CashFlowPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/reports" element={<ReportsPage />} />

View File

@@ -1,6 +1,18 @@
import { api } from "@/api/client";
import type { Category } from "@/types";
import type { Category, CategoryInput, CategoryUpdateInput } from "@/types";
export function fetchCategories() {
return api.get<Category[]>("/categories");
}
export function createCategory(input: CategoryInput) {
return api.post<Category>("/categories", input);
}
export function updateCategory(id: number, input: CategoryUpdateInput) {
return api.patch<Category>(`/categories/${id}`, input);
}
export function deleteCategory(id: number) {
return api.delete<void>(`/categories/${id}`);
}

View File

@@ -0,0 +1,54 @@
import { useTranslation } from "@/lib/i18n";
import { cn } from "@/lib/utils";
interface CategoryColorPickerProps {
value: string;
onChange: (color: string) => void;
}
// The same 8-slot colorblind-safe categorical palette the seeded default
// categories and every chart in the app already use — see
// backend/app/db/seed.py and index.css's --series-* tokens.
const PRESET_COLORS = [
"#2a78d6",
"#eb6834",
"#1baf7a",
"#eda100",
"#e87ba4",
"#008300",
"#4a3aa7",
"#e34948",
"#898781",
];
export function CategoryColorPicker({ value, onChange }: CategoryColorPickerProps) {
const { t } = useTranslation();
const normalized = value.toLowerCase();
return (
<div className="flex flex-wrap items-center gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={color}
aria-pressed={normalized === color}
onClick={() => onChange(color)}
className={cn(
"h-7 w-7 rounded-full border-2 transition-transform",
normalized === color ? "scale-110 border-text-primary" : "border-transparent hover:scale-105"
)}
style={{ backgroundColor: color }}
/>
))}
<input
type="color"
aria-label={t("category.form.customColorLabel")}
title={t("category.form.customColorLabel")}
value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#898781"}
onChange={(event) => onChange(event.target.value)}
className="h-7 w-7 cursor-pointer rounded-md border border-border bg-transparent p-0"
/>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from "react";
import { Dialog } from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button";
import { Input, Label, Select } from "@/components/ui/Input";
import { CategoryColorPicker } from "@/components/categories/CategoryColorPicker";
import { CategoryIconPicker } from "@/components/categories/CategoryIconPicker";
import { useCreateCategory, useUpdateCategory } from "@/hooks/useCategories";
import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { Category, CategoryKind } from "@/types";
interface CategoryFormModalProps {
open: boolean;
onClose: () => void;
category?: Category | null;
// Preselects kind for a new category — irrelevant once editing, since kind
// can't change after creation (see backend CategoryUpdate schema).
defaultKind: CategoryKind;
}
const KINDS: CategoryKind[] = ["expense", "income"];
function emptyForm(kind: CategoryKind) {
return { name: "", kind, icon: "wallet", color: "#2a78d6" };
}
export function CategoryFormModal({ open, onClose, category, defaultKind }: CategoryFormModalProps) {
const { t } = useTranslation();
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
const [form, setForm] = useState(emptyForm(defaultKind));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setForm(
category
? { name: category.name, kind: category.kind, icon: category.icon ?? "wallet", color: category.color }
: emptyForm(defaultKind)
);
setError(null);
}, [open, category, defaultKind]);
const isSaving = createCategory.isPending || updateCategory.isPending;
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
setError(null);
try {
if (category) {
await updateCategory.mutateAsync({
id: category.id,
input: { name: form.name, icon: form.icon, color: form.color },
});
} else {
await createCategory.mutateAsync(form);
}
onClose();
} catch {
setError(t("category.form.saveError"));
}
}
return (
<Dialog open={open} onClose={onClose} title={category ? t("category.form.editTitle") : t("category.form.newTitle")}>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<Label htmlFor="category-name">{t("category.form.nameLabel")}</Label>
<Input
id="category-name"
required
placeholder={t("category.form.namePlaceholder")}
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
/>
</div>
<div>
<Label htmlFor="category-kind">{t("category.form.kindLabel")}</Label>
<Select
id="category-kind"
value={form.kind}
disabled={Boolean(category)}
onChange={(event) => setForm((prev) => ({ ...prev, kind: event.target.value as CategoryKind }))}
>
{KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`category.kind.${kind}` as TranslationKey)}
</option>
))}
</Select>
</div>
<div>
<Label>{t("category.form.iconLabel")}</Label>
<CategoryIconPicker value={form.icon} onChange={(icon) => setForm((prev) => ({ ...prev, icon }))} />
</div>
<div>
<Label>{t("category.form.colorLabel")}</Label>
<CategoryColorPicker value={form.color} onChange={(color) => setForm((prev) => ({ ...prev, color }))} />
</div>
{error && <p className="text-sm text-danger">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? t("common.saving") : t("common.save")}
</Button>
</div>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,36 @@
import { CATEGORY_ICON_OPTIONS, getCategoryIcon } from "@/lib/icons";
import { cn } from "@/lib/utils";
interface CategoryIconPickerProps {
value: string;
onChange: (icon: string) => void;
}
export function CategoryIconPicker({ value, onChange }: CategoryIconPickerProps) {
return (
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-10">
{CATEGORY_ICON_OPTIONS.map((icon) => {
const Icon = getCategoryIcon(icon);
const selected = icon === value;
return (
<button
key={icon}
type="button"
aria-label={icon}
aria-pressed={selected}
onClick={() => onChange(icon)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-lg border transition-colors",
selected
? "border-series-1 bg-series-1/10 text-series-1"
: "border-border text-text-secondary hover:bg-surface-2"
)}
>
<Icon size={16} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Pencil, Trash2 } from "lucide-react";
import { getCategoryIcon } from "@/lib/icons";
import { useTranslation } from "@/lib/i18n";
import type { Category } from "@/types";
interface CategoryListProps {
items: Category[];
onEdit: (category: Category) => void;
onDelete: (category: Category) => void;
}
export function CategoryList({ items, onEdit, onDelete }: CategoryListProps) {
const { t } = useTranslation();
if (items.length === 0) {
return <p className="py-6 text-center text-sm text-text-muted">{t("category.empty")}</p>;
}
return (
<ul className="divide-y divide-gridline">
{items.map((category) => {
const Icon = getCategoryIcon(category.icon);
return (
<li key={category.id} className="flex items-center gap-3 py-2.5">
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${category.color}1a`, color: category.color }}
>
<Icon size={15} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm font-medium text-text-primary">
<span className="truncate">{category.name}</span>
{category.is_default && (
<span className="shrink-0 rounded bg-surface-2 px-1 py-0.5 text-[10px] leading-none text-text-muted">
{t("category.defaultBadge")}
</span>
)}
</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
aria-label={t("common.edit")}
onClick={() => onEdit(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<Pencil size={15} />
</button>
<button
type="button"
aria-label={t("common.delete")}
title={category.is_default ? t("category.defaultCannotDelete") : undefined}
disabled={category.is_default}
onClick={() => onDelete(category)}
className="rounded-md p-1.5 text-text-muted hover:bg-surface-2 hover:text-danger disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-text-muted"
>
<Trash2 size={15} />
</button>
</span>
</li>
);
})}
</ul>
);
}

View File

@@ -1,6 +1,46 @@
import { useQuery } from "@tanstack/react-query";
import { fetchCategories } from "@/api/categories";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createCategory, deleteCategory, fetchCategories, updateCategory } from "@/api/categories";
import type { CategoryInput, CategoryUpdateInput } from "@/types";
export function useCategories() {
return useQuery({ queryKey: ["categories"], queryFn: fetchCategories });
}
// Category name/color/icon are denormalized into several read models
// (transactions, dashboard, reports, budgets, recurring templates) — any
// create/update/delete has to refresh all of them, same set an account
// mutation invalidates in useAccounts.ts.
function invalidateCategoryConsumers(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["categories"] });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["dashboard-summary"] });
queryClient.invalidateQueries({ queryKey: ["category-spending-report"] });
queryClient.invalidateQueries({ queryKey: ["category-ranking"] });
queryClient.invalidateQueries({ queryKey: ["budgets"] });
queryClient.invalidateQueries({ queryKey: ["budget-status"] });
queryClient.invalidateQueries({ queryKey: ["recurring"] });
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CategoryInput) => createCategory(input),
onSuccess: () => invalidateCategoryConsumers(queryClient),
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: number; input: CategoryUpdateInput }) => updateCategory(id, input),
onSuccess: () => invalidateCategoryConsumers(queryClient),
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteCategory(id),
onSuccess: () => invalidateCategoryConsumers(queryClient),
});
}

View File

@@ -10,6 +10,7 @@ const ru = {
"nav.roi": "Доходность",
"nav.transactions": "Транзакции",
"nav.accounts": "Счета",
"nav.categories": "Категории",
"nav.cashFlow": "Движение денег",
"nav.reports": "Отчёты",
"nav.budget": "Бюджет",
@@ -300,6 +301,24 @@ const ru = {
"account.form.typeLabel": "Тип",
"account.form.saveError": "Не удалось сохранить счёт. Проверьте данные и попробуйте снова.",
"category.expenseSectionTitle": "Категории расходов",
"category.incomeSectionTitle": "Категории доходов",
"category.empty": "Категорий пока нет.",
"category.defaultBadge": "стандартная",
"category.defaultCannotDelete": "Стандартные категории нельзя удалить",
"category.confirmDelete": "Удалить категорию «{{name}}»?\n\nЕё транзакции останутся без категории.",
"category.kind.expense": "Расход",
"category.kind.income": "Доход",
"category.form.newTitle": "Новая категория",
"category.form.editTitle": "Изменить категорию",
"category.form.nameLabel": "Название",
"category.form.namePlaceholder": "Продукты, транспорт, подписки…",
"category.form.kindLabel": "Тип",
"category.form.iconLabel": "Иконка",
"category.form.colorLabel": "Цвет",
"category.form.customColorLabel": "Свой цвет",
"category.form.saveError": "Не удалось сохранить категорию. Проверьте данные и попробуйте снова.",
"cashFlow.income": "Доход",
"cashFlow.expense": "Расход",
"cashFlow.net": "Баланс",
@@ -344,6 +363,7 @@ const en: Record<keyof typeof ru, string> = {
"nav.roi": "Returns",
"nav.transactions": "Transactions",
"nav.accounts": "Accounts",
"nav.categories": "Categories",
"nav.cashFlow": "Cash Flow",
"nav.reports": "Reports",
"nav.budget": "Budget",
@@ -634,6 +654,24 @@ const en: Record<keyof typeof ru, string> = {
"account.form.typeLabel": "Type",
"account.form.saveError": "Failed to save the account. Check the data and try again.",
"category.expenseSectionTitle": "Expense categories",
"category.incomeSectionTitle": "Income categories",
"category.empty": "No categories yet.",
"category.defaultBadge": "default",
"category.defaultCannotDelete": "Default categories cannot be deleted",
"category.confirmDelete": "Delete the category “{{name}}”?\n\nIts transactions will be left uncategorized.",
"category.kind.expense": "Expense",
"category.kind.income": "Income",
"category.form.newTitle": "New category",
"category.form.editTitle": "Edit category",
"category.form.nameLabel": "Name",
"category.form.namePlaceholder": "Groceries, transport, subscriptions…",
"category.form.kindLabel": "Type",
"category.form.iconLabel": "Icon",
"category.form.colorLabel": "Color",
"category.form.customColorLabel": "Custom color",
"category.form.saveError": "Failed to save the category. Check the data and try again.",
"cashFlow.income": "Income",
"cashFlow.expense": "Expense",
"cashFlow.net": "Net",

View File

@@ -5,25 +5,41 @@ import {
Building2,
Car,
Clapperboard,
Code,
Dumbbell,
Gamepad2,
Gem,
Gift,
GraduationCap,
HeartPulse,
Home,
MoreHorizontal,
Package,
Plane,
PlusCircle,
Receipt,
Repeat,
Shapes,
Shirt,
ShoppingBag,
ShoppingBasket,
Smartphone,
Sofa,
Sparkles,
TrendingUp,
Truck,
Utensils,
Video,
Wallet,
ZapOff,
type LucideIcon,
} from "lucide-react";
// Maps the backend's plain-string icon keys (Category.icon) to a concrete
// lucide component. New categories fall back to a generic wallet glyph so an
// unmapped icon key never breaks rendering.
// unmapped icon key never breaks rendering. Keep this in sync with whatever
// icon keys actually exist on Category rows — an icon used by a real
// category but missing here silently renders as the Wallet fallback.
const ICON_MAP: Record<string, LucideIcon> = {
home: Home,
"shopping-basket": ShoppingBasket,
@@ -44,9 +60,29 @@ const ICON_MAP: Record<string, LucideIcon> = {
"building-2": Building2,
package: Package,
gem: Gem,
code: Code,
dumbbell: Dumbbell,
"gamepad-2": Gamepad2,
"graduation-cap": GraduationCap,
plane: Plane,
receipt: Receipt,
shapes: Shapes,
shirt: Shirt,
smartphone: Smartphone,
sofa: Sofa,
sparkles: Sparkles,
truck: Truck,
// lucide-react dropped brand/trademarked glyphs (no "Youtube" icon) — a
// generic video-camera icon is the closest available stand-in.
youtube: Video,
"zap-off": ZapOff,
};
export function getCategoryIcon(icon: string | null | undefined): LucideIcon {
if (!icon) return Wallet;
return ICON_MAP[icon] ?? Wallet;
}
// The full set of icon keys a category can be assigned, for the icon picker
// in the category form — kept in sync with ICON_MAP above.
export const CATEGORY_ICON_OPTIONS = Object.keys(ICON_MAP);

View File

@@ -9,6 +9,7 @@ import {
PieChart,
Repeat,
Settings,
Tags,
Target,
TrendingUp,
type LucideIcon,
@@ -29,6 +30,7 @@ export const NAV_ITEMS: NavItem[] = [
{ labelKey: "nav.roi", to: "/roi", icon: Calculator },
{ labelKey: "nav.transactions", to: "/transactions", icon: ArrowLeftRight },
{ labelKey: "nav.accounts", to: "/accounts", icon: Layers },
{ labelKey: "nav.categories", to: "/categories", icon: Tags },
{ labelKey: "nav.cashFlow", to: "/cash-flow", icon: Activity },
{ labelKey: "nav.reports", to: "/reports", icon: PieChart },
{ labelKey: "nav.budget", to: "/budget", icon: Target },

View File

@@ -0,0 +1,88 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { CategoryFormModal } from "@/components/categories/CategoryFormModal";
import { CategoryList } from "@/components/categories/CategoryList";
import { useCategories, useDeleteCategory } from "@/hooks/useCategories";
import { useTranslation } from "@/lib/i18n";
import type { Category, CategoryKind } from "@/types";
export function CategoriesPage() {
const { t } = useTranslation();
const { data: categories, isLoading } = useCategories();
const deleteCategory = useDeleteCategory();
const [modalOpen, setModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [modalKind, setModalKind] = useState<CategoryKind>("expense");
function openCreateModal(kind: CategoryKind) {
setEditingCategory(null);
setModalKind(kind);
setModalOpen(true);
}
function openEditModal(category: Category) {
setEditingCategory(category);
setModalKind(category.kind);
setModalOpen(true);
}
function handleDelete(category: Category) {
// Default categories are protected server-side too (400) — the list
// already disables the delete button for them, this is just a guard.
if (category.is_default) return;
if (window.confirm(t("category.confirmDelete", { name: category.name }))) {
deleteCategory.mutate(category.id);
}
}
const expenseCategories = (categories ?? []).filter((category) => category.kind === "expense");
const incomeCategories = (categories ?? []).filter((category) => category.kind === "income");
return (
<div className="space-y-5">
<Card>
<CardHeader>
<CardTitle>{t("category.expenseSectionTitle")}</CardTitle>
<Button onClick={() => openCreateModal("expense")}>
<Plus size={16} />
{t("common.add")}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : (
<CategoryList items={expenseCategories} onEdit={openEditModal} onDelete={handleDelete} />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("category.incomeSectionTitle")}</CardTitle>
<Button onClick={() => openCreateModal("income")}>
<Plus size={16} />
{t("common.add")}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
) : (
<CategoryList items={incomeCategories} onEdit={openEditModal} onDelete={handleDelete} />
)}
</CardContent>
</Card>
<CategoryFormModal
open={modalOpen}
onClose={() => setModalOpen(false)}
category={editingCategory}
defaultKind={modalKind}
/>
</div>
);
}

View File

@@ -36,6 +36,22 @@ export interface Category {
is_default: boolean;
}
export interface CategoryInput {
name: string;
kind: CategoryKind;
icon?: string | null;
color: string;
sort_order?: number;
}
// kind is fixed at creation on the backend (CategoryUpdate has no kind field).
export interface CategoryUpdateInput {
name?: string;
icon?: string | null;
color?: string;
sort_order?: number;
}
export interface Transaction {
id: number;
account_id: number;