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

@@ -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 },