mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 19:16:40 +07:00
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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "aurum-frontend",
|
||||
"private": true,
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
54
frontend/src/components/categories/CategoryColorPicker.tsx
Normal file
54
frontend/src/components/categories/CategoryColorPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/categories/CategoryFormModal.tsx
Normal file
118
frontend/src/components/categories/CategoryFormModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/categories/CategoryIconPicker.tsx
Normal file
36
frontend/src/components/categories/CategoryIconPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/categories/CategoryList.tsx
Normal file
65
frontend/src/components/categories/CategoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
88
frontend/src/pages/CategoriesPage.tsx
Normal file
88
frontend/src/pages/CategoriesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user