diff --git a/frontend/package.json b/frontend/package.json index 6dbbf4e..e328f11 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "aurum-frontend", "private": true, - "version": "0.22.0", + "version": "0.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 67e5c1e..a5b54a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts index 57c511e..7ddfc7f 100644 --- a/frontend/src/api/categories.ts +++ b/frontend/src/api/categories.ts @@ -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("/categories"); } + +export function createCategory(input: CategoryInput) { + return api.post("/categories", input); +} + +export function updateCategory(id: number, input: CategoryUpdateInput) { + return api.patch(`/categories/${id}`, input); +} + +export function deleteCategory(id: number) { + return api.delete(`/categories/${id}`); +} diff --git a/frontend/src/components/categories/CategoryColorPicker.tsx b/frontend/src/components/categories/CategoryColorPicker.tsx new file mode 100644 index 0000000..4bb3bf2 --- /dev/null +++ b/frontend/src/components/categories/CategoryColorPicker.tsx @@ -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 ( +
+ {PRESET_COLORS.map((color) => ( +
+ ); +} diff --git a/frontend/src/components/categories/CategoryFormModal.tsx b/frontend/src/components/categories/CategoryFormModal.tsx new file mode 100644 index 0000000..06e9c56 --- /dev/null +++ b/frontend/src/components/categories/CategoryFormModal.tsx @@ -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(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 ( + +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm((prev) => ({ ...prev, icon }))} /> +
+ +
+ + setForm((prev) => ({ ...prev, color }))} /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/categories/CategoryIconPicker.tsx b/frontend/src/components/categories/CategoryIconPicker.tsx new file mode 100644 index 0000000..b91e05c --- /dev/null +++ b/frontend/src/components/categories/CategoryIconPicker.tsx @@ -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 ( +
+ {CATEGORY_ICON_OPTIONS.map((icon) => { + const Icon = getCategoryIcon(icon); + const selected = icon === value; + + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/categories/CategoryList.tsx b/frontend/src/components/categories/CategoryList.tsx new file mode 100644 index 0000000..d1a5de7 --- /dev/null +++ b/frontend/src/components/categories/CategoryList.tsx @@ -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

{t("category.empty")}

; + } + + return ( +
    + {items.map((category) => { + const Icon = getCategoryIcon(category.icon); + + return ( +
  • + + + + + {category.name} + {category.is_default && ( + + {t("category.defaultBadge")} + + )} + + + + + +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/useCategories.ts b/frontend/src/hooks/useCategories.ts index 40bc519..ab527ee 100644 --- a/frontend/src/hooks/useCategories.ts +++ b/frontend/src/hooks/useCategories.ts @@ -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) { + 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), + }); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 279eceb..5b1a926 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { "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 = { "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", diff --git a/frontend/src/lib/icons.tsx b/frontend/src/lib/icons.tsx index 271ca2e..d2e6970 100644 --- a/frontend/src/lib/icons.tsx +++ b/frontend/src/lib/icons.tsx @@ -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 = { home: Home, "shopping-basket": ShoppingBasket, @@ -44,9 +60,29 @@ const ICON_MAP: Record = { "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); diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts index bc8ad0b..1f82d48 100644 --- a/frontend/src/lib/navigation.ts +++ b/frontend/src/lib/navigation.ts @@ -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 }, diff --git a/frontend/src/pages/CategoriesPage.tsx b/frontend/src/pages/CategoriesPage.tsx new file mode 100644 index 0000000..cb519ba --- /dev/null +++ b/frontend/src/pages/CategoriesPage.tsx @@ -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(null); + const [modalKind, setModalKind] = useState("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 ( +
+ + + {t("category.expenseSectionTitle")} + + + + {isLoading ? ( +

{t("common.loading")}

+ ) : ( + + )} +
+
+ + + + {t("category.incomeSectionTitle")} + + + + {isLoading ? ( +

{t("common.loading")}

+ ) : ( + + )} +
+
+ + setModalOpen(false)} + category={editingCategory} + defaultKind={modalKind} + /> +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 55658af..f21f5ef 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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;