From 6111424f472534da7223da5f44cf4fe0be9c94e8 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 23 Apr 2026 11:50:28 +0000 Subject: [PATCH] Add category management UI and service layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now create, edit, and delete custom categories from Settings → Categories. System categories (45 built-in) are shown read-only. Backend adds update_category() and delete_category() service functions; frontend has a new categories API module and a full CRUD section in SettingsPage with filter tabs, colour picker, and delete confirmation. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 10 +- backend/app/api/v1/categories.py | 55 +++- backend/app/services/category_service.py | 48 +++ frontend/src/api/categories.ts | 37 +++ frontend/src/pages/settings/SettingsPage.tsx | 327 ++++++++++++++++++- 5 files changed, 455 insertions(+), 22 deletions(-) create mode 100644 frontend/src/api/categories.ts diff --git a/README.md b/README.md index 5408d7a..25f4613 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Per-category budget tracking with rollover support - Visual radial gauges showing spend vs. budget - Configurable alert threshold (default 80%) +- Edit existing budgets (amount, period, alert threshold) without losing history ### Investments - Portfolio tracking with holdings and buy/sell/dividend/split transactions @@ -31,10 +32,17 @@ Runs entirely on your own hardware via Docker Compose. Designed for LAN access w - Hourly FX rates for multi-currency conversion to GBP base - TWRR and MWRR performance metrics - Capital gains reporting (short/long-term by tax year) -- OHLCV candlestick charts per asset +- OHLCV candlestick charts per asset with buy/sell transaction history overlay - Portfolio visualisations: allocation donut by holding, allocation donut by asset type, cost basis vs current value bar chart, return % per holding bar chart - Investment account balances include holding market values in net worth, balance sheet, and accounts list — supports both simple (holdings only) and full cash-flow tracking workflows +### Reports +### Categories +- 45 built-in system categories across income, expense, and transfer types +- Create custom categories with a name, type, and colour +- Rename and recolour existing custom categories +- Managed in **Settings → Categories** + ### Reports Seven report views: 1. Net Worth over time (area chart with time slider) diff --git a/backend/app/api/v1/categories.py b/backend/app/api/v1/categories.py index a474e55..cb5c68e 100644 --- a/backend/app/api/v1/categories.py +++ b/backend/app/api/v1/categories.py @@ -1,14 +1,28 @@ import uuid -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db -from app.services.category_service import create_category, list_categories +from app.services.category_service import create_category, list_categories, update_category, delete_category router = APIRouter() +class CategoryCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + type: str = Field(..., pattern="^(income|expense|transfer)$") + color: str | None = Field(default=None, max_length=7) + icon: str | None = Field(default=None, max_length=50) + + +class CategoryUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + color: str | None = None + icon: str | None = None + + @router.get("") async def get_categories( db: AsyncSession = Depends(get_db), @@ -19,18 +33,43 @@ async def get_categories( @router.post("", status_code=201) async def create( - body: dict, + body: CategoryCreate, db: AsyncSession = Depends(get_db), user=Depends(get_current_user), ): result = await create_category( db, user_id=user.id, - name=body["name"], - type_=body["type"], - icon=body.get("icon"), - color=body.get("color"), - parent_id=uuid.UUID(body["parent_id"]) if body.get("parent_id") else None, + name=body.name, + type_=body.type, + icon=body.icon, + color=body.color, ) await db.commit() return result + + +@router.put("/{category_id}", status_code=200) +async def update( + category_id: uuid.UUID, + body: CategoryUpdate, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + result = await update_category(db, user.id, category_id, body.name, body.color, body.icon) + if result is None: + raise HTTPException(status_code=404, detail="Category not found or is a system category") + await db.commit() + return result + + +@router.delete("/{category_id}", status_code=204) +async def delete( + category_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user=Depends(get_current_user), +): + ok = await delete_category(db, user.id, category_id) + if not ok: + raise HTTPException(status_code=404, detail="Category not found or is a system category") + await db.commit() diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py index 9162ae2..34f9e5b 100644 --- a/backend/app/services/category_service.py +++ b/backend/app/services/category_service.py @@ -133,3 +133,51 @@ async def create_category( db.add(cat) await db.flush() return {"id": str(cat.id), "name": cat.name, "type": cat.type, "icon": cat.icon, "color": cat.color, "is_system": False} + + +async def update_category( + db: AsyncSession, + user_id: uuid.UUID, + category_id: uuid.UUID, + name: str | None, + color: str | None, + icon: str | None, +) -> dict | None: + result = await db.execute( + select(Category).where( + Category.id == category_id, + Category.user_id == user_id, + Category.is_system == False, + ) + ) + cat = result.scalar_one_or_none() + if cat is None: + return None + if name is not None: + cat.name = name + if color is not None: + cat.color = color + if icon is not None: + cat.icon = icon + await db.flush() + return {"id": str(cat.id), "name": cat.name, "type": cat.type, "icon": cat.icon, "color": cat.color, "is_system": False} + + +async def delete_category( + db: AsyncSession, + user_id: uuid.UUID, + category_id: uuid.UUID, +) -> bool: + result = await db.execute( + select(Category).where( + Category.id == category_id, + Category.user_id == user_id, + Category.is_system == False, + ) + ) + cat = result.scalar_one_or_none() + if cat is None: + return False + await db.delete(cat) + await db.flush() + return True diff --git a/frontend/src/api/categories.ts b/frontend/src/api/categories.ts new file mode 100644 index 0000000..5006675 --- /dev/null +++ b/frontend/src/api/categories.ts @@ -0,0 +1,37 @@ +import { api } from "./client"; + +export interface Category { + id: string; + name: string; + type: "income" | "expense" | "transfer"; + icon: string | null; + color: string | null; + is_system: boolean; + parent_id: string | null; + sort_order: number; +} + +export interface CategoryCreate { + name: string; + type: "income" | "expense" | "transfer"; + color?: string | null; + icon?: string | null; +} + +export interface CategoryUpdate { + name?: string | null; + color?: string | null; + icon?: string | null; +} + +export const listCategories = (): Promise => + api.get("/categories").then((r: { data: Category[] }) => r.data); + +export const createCategory = (data: CategoryCreate): Promise => + api.post("/categories", data).then((r: { data: Category }) => r.data); + +export const updateCategory = (id: string, data: CategoryUpdate): Promise => + api.put(`/categories/${id}`, data).then((r: { data: Category }) => r.data); + +export const deleteCategory = (id: string): Promise => + api.delete(`/categories/${id}`).then(() => undefined); diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 1ea76ad..f8941c7 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -9,6 +9,8 @@ import { } from "@/api/auth"; import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin"; import { getAiSettings, saveAiSettings, clearAiSettings, testAiSettings } from "@/api/settings"; +import { listCategories, createCategory, updateCategory, deleteCategory } from "@/api/categories"; +import type { Category } from "@/api/categories"; import type { AiSettings } from "@/api/settings"; import type { BackupFile } from "@/api/admin"; import { cn } from "@/utils/cn"; @@ -16,16 +18,17 @@ import { format } from "date-fns"; import { User, Shield, MonitorSmartphone, Download, HardDrive, Loader2, CheckCircle, Eye, EyeOff, Trash2, - LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, Sparkles, + LogOut, QrCode, KeyRound, AlertTriangle, RefreshCw, RotateCcw, Sparkles, Tag, Plus, Pencil, X, } from "lucide-react"; const SECTIONS = [ - { id: "profile", label: "Profile", icon: User }, - { id: "security", label: "Security", icon: Shield }, - { id: "sessions", label: "Sessions", icon: MonitorSmartphone }, - { id: "data", label: "Data", icon: Download }, - { id: "backups", label: "Backups", icon: HardDrive }, - { id: "ai", label: "AI", icon: Sparkles }, + { id: "profile", label: "Profile", icon: User }, + { id: "security", label: "Security", icon: Shield }, + { id: "sessions", label: "Sessions", icon: MonitorSmartphone }, + { id: "categories", label: "Categories", icon: Tag }, + { id: "data", label: "Data", icon: Download }, + { id: "backups", label: "Backups", icon: HardDrive }, + { id: "ai", label: "AI", icon: Sparkles }, ] as const; type Section = (typeof SECTIONS)[number]["id"]; @@ -62,12 +65,13 @@ export default function SettingsPage() { {/* Content */}
- {section === "profile" && } - {section === "security" && } - {section === "sessions" && } - {section === "data" && } - {section === "backups" && } - {section === "ai" && } + {section === "profile" && } + {section === "security" && } + {section === "sessions" && } + {section === "categories" && } + {section === "data" && } + {section === "backups" && } + {section === "ai" && }
@@ -875,6 +879,303 @@ function AiSection() { ); } +// ─── Categories ─────────────────────────────────────────────────────────────── + +const TYPE_LABELS: Record = { income: "Income", expense: "Expense", transfer: "Transfer" }; +const TYPE_COLORS: Record = { income: "text-success", expense: "text-destructive", transfer: "text-muted-foreground" }; + +function CategoryRow({ + cat, + onEdit, + onDelete, +}: { + cat: Category; + onEdit: (c: Category) => void; + onDelete: (c: Category) => void; +}) { + return ( +
+
+ {cat.name} + {TYPE_LABELS[cat.type]} + {!cat.is_system && ( +
+ + +
+ )} +
+ ); +} + +function CategoryFormModal({ + editing, + onClose, +}: { + editing: Category | null; + onClose: () => void; +}) { + const qc = useQueryClient(); + const [name, setName] = useState(editing?.name ?? ""); + const [type, setType] = useState<"income" | "expense" | "transfer">(editing?.type ?? "expense"); + const [color, setColor] = useState(editing?.color ?? "#6366f1"); + const [error, setError] = useState(""); + + const isEdit = editing !== null; + + const mutation = useMutation({ + mutationFn: () => + isEdit + ? updateCategory(editing!.id, { name, color }) + : createCategory({ name, type, color }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["categories"] }); + onClose(); + }, + onError: (e: any) => setError(e?.response?.data?.detail ?? "Failed to save category"), + }); + + return ( +
+
+
+

{isEdit ? "Edit category" : "New category"}

+ +
+ + {error && } + +
+ + setName(e.target.value)} + className={inputCls} + placeholder="Category name" + maxLength={100} + autoFocus + /> +
+ + {!isEdit && ( +
+ + +
+ )} + +
+ +
+ setColor(e.target.value)} + className="h-9 w-16 rounded-lg border border-input bg-background cursor-pointer p-0.5" + /> + setColor(e.target.value)} + className={cn(inputCls, "flex-1 font-mono")} + placeholder="#6366f1" + maxLength={7} + /> +
+
+ +
+ + +
+
+
+ ); +} + +function CategoriesSection() { + const qc = useQueryClient(); + const [modal, setModal] = useState<"create" | Category | null>(null); + const [deleting, setDeleting] = useState(null); + const [filter, setFilter] = useState<"all" | "income" | "expense" | "transfer">("all"); + + const { data: categories = [], isLoading } = useQuery({ + queryKey: ["categories"], + queryFn: listCategories, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteCategory(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["categories"] }); + setDeleting(null); + }, + }); + + const userCats = categories.filter(c => !c.is_system); + const systemCats = categories.filter(c => c.is_system); + + const filtered = (list: Category[]) => + filter === "all" ? list : list.filter(c => c.type === filter); + + return ( +
+
+
+ Categories + +
+ +

+ System categories are built-in and cannot be edited. Custom categories can be freely created, renamed, and coloured. +

+ + {/* Filter tabs */} +
+ {(["all", "income", "expense", "transfer"] as const).map(t => ( + + ))} +
+ + {isLoading ? ( +
+ {[1,2,3,4].map(i =>
)} +
+ ) : ( + <> + {/* User categories */} + {filtered(userCats).length > 0 && ( +
+

Custom

+
+ {filtered(userCats).map(c => ( + + ))} +
+
+ )} + + {/* System categories */} + {filtered(systemCats).length > 0 && ( +
+

System

+
+ {filtered(systemCats).map(c => ( + {}} onDelete={() => {}} /> + ))} +
+
+ )} + + {filtered([...userCats, ...systemCats]).length === 0 && ( +

No categories for this type yet.

+ )} + + )} +
+ + {/* Create / Edit modal */} + {modal !== null && ( + setModal(null)} + /> + )} + + {/* Delete confirmation */} + {deleting && ( +
+
+
+
+ +
+
+

Delete "{deleting.name}"?

+

+ Transactions using this category will remain but their category will be unset. This cannot be undone. +

+
+
+
+ + +
+
+
+ )} +
+ ); +} + // ─── Data ───────────────────────────────────────────────────────────────────── function DataSection() {