Add category management UI and service layer

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 <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-04-23 11:50:28 +00:00
parent 8ef3bb2965
commit 6111424f47
5 changed files with 455 additions and 22 deletions

View file

@ -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 - Per-category budget tracking with rollover support
- Visual radial gauges showing spend vs. budget - Visual radial gauges showing spend vs. budget
- Configurable alert threshold (default 80%) - Configurable alert threshold (default 80%)
- Edit existing budgets (amount, period, alert threshold) without losing history
### Investments ### Investments
- Portfolio tracking with holdings and buy/sell/dividend/split transactions - 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 - Hourly FX rates for multi-currency conversion to GBP base
- TWRR and MWRR performance metrics - TWRR and MWRR performance metrics
- Capital gains reporting (short/long-term by tax year) - 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 - 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 - 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 ### Reports
Seven report views: Seven report views:
1. Net Worth over time (area chart with time slider) 1. Net Worth over time (area chart with time slider)

View file

@ -1,14 +1,28 @@
import uuid 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 sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db 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() 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("") @router.get("")
async def get_categories( async def get_categories(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@ -19,18 +33,43 @@ async def get_categories(
@router.post("", status_code=201) @router.post("", status_code=201)
async def create( async def create(
body: dict, body: CategoryCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user=Depends(get_current_user), user=Depends(get_current_user),
): ):
result = await create_category( result = await create_category(
db, db,
user_id=user.id, user_id=user.id,
name=body["name"], name=body.name,
type_=body["type"], type_=body.type,
icon=body.get("icon"), icon=body.icon,
color=body.get("color"), color=body.color,
parent_id=uuid.UUID(body["parent_id"]) if body.get("parent_id") else None,
) )
await db.commit() await db.commit()
return result 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()

View file

@ -133,3 +133,51 @@ async def create_category(
db.add(cat) db.add(cat)
await db.flush() await db.flush()
return {"id": str(cat.id), "name": cat.name, "type": cat.type, "icon": cat.icon, "color": cat.color, "is_system": False} 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

View file

@ -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<Category[]> =>
api.get("/categories").then((r: { data: Category[] }) => r.data);
export const createCategory = (data: CategoryCreate): Promise<Category> =>
api.post("/categories", data).then((r: { data: Category }) => r.data);
export const updateCategory = (id: string, data: CategoryUpdate): Promise<Category> =>
api.put(`/categories/${id}`, data).then((r: { data: Category }) => r.data);
export const deleteCategory = (id: string): Promise<void> =>
api.delete(`/categories/${id}`).then(() => undefined);

View file

@ -9,6 +9,8 @@ import {
} from "@/api/auth"; } from "@/api/auth";
import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin"; import { listBackups, triggerBackup, downloadBackup, restoreBackup } from "@/api/admin";
import { getAiSettings, saveAiSettings, clearAiSettings, testAiSettings } from "@/api/settings"; 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 { AiSettings } from "@/api/settings";
import type { BackupFile } from "@/api/admin"; import type { BackupFile } from "@/api/admin";
import { cn } from "@/utils/cn"; import { cn } from "@/utils/cn";
@ -16,16 +18,17 @@ import { format } from "date-fns";
import { import {
User, Shield, MonitorSmartphone, Download, HardDrive, User, Shield, MonitorSmartphone, Download, HardDrive,
Loader2, CheckCircle, Eye, EyeOff, Trash2, 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"; } from "lucide-react";
const SECTIONS = [ const SECTIONS = [
{ id: "profile", label: "Profile", icon: User }, { id: "profile", label: "Profile", icon: User },
{ id: "security", label: "Security", icon: Shield }, { id: "security", label: "Security", icon: Shield },
{ id: "sessions", label: "Sessions", icon: MonitorSmartphone }, { id: "sessions", label: "Sessions", icon: MonitorSmartphone },
{ id: "data", label: "Data", icon: Download }, { id: "categories", label: "Categories", icon: Tag },
{ id: "backups", label: "Backups", icon: HardDrive }, { id: "data", label: "Data", icon: Download },
{ id: "ai", label: "AI", icon: Sparkles }, { id: "backups", label: "Backups", icon: HardDrive },
{ id: "ai", label: "AI", icon: Sparkles },
] as const; ] as const;
type Section = (typeof SECTIONS)[number]["id"]; type Section = (typeof SECTIONS)[number]["id"];
@ -62,12 +65,13 @@ export default function SettingsPage() {
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0 space-y-4"> <div className="flex-1 min-w-0 space-y-4">
{section === "profile" && <ProfileSection />} {section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />} {section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />} {section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />} {section === "categories" && <CategoriesSection />}
{section === "backups" && <BackupsSection />} {section === "data" && <DataSection />}
{section === "ai" && <AiSection />} {section === "backups" && <BackupsSection />}
{section === "ai" && <AiSection />}
</div> </div>
</div> </div>
</div> </div>
@ -875,6 +879,303 @@ function AiSection() {
); );
} }
// ─── Categories ───────────────────────────────────────────────────────────────
const TYPE_LABELS: Record<string, string> = { income: "Income", expense: "Expense", transfer: "Transfer" };
const TYPE_COLORS: Record<string, string> = { 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 (
<div className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary/40 transition-colors group">
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: cat.color ?? "#94a3b8" }} />
<span className="flex-1 text-sm truncate">{cat.name}</span>
<span className={cn("text-xs", TYPE_COLORS[cat.type])}>{TYPE_LABELS[cat.type]}</span>
{!cat.is_system && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onEdit(cat)}
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => onDelete(cat)}
className="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-base">{isEdit ? "Edit category" : "New category"}</h3>
<button type="button" onClick={onClose} className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors">
<X className="w-4 h-4" />
</button>
</div>
{error && <ErrorBanner message={error} />}
<div>
<label className="text-sm font-medium block mb-1.5">Name</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className={inputCls}
placeholder="Category name"
maxLength={100}
autoFocus
/>
</div>
{!isEdit && (
<div>
<label className="text-sm font-medium block mb-1.5">Type</label>
<select
value={type}
onChange={e => setType(e.target.value as any)}
className={inputCls}
>
<option value="expense">Expense</option>
<option value="income">Income</option>
<option value="transfer">Transfer</option>
</select>
</div>
)}
<div>
<label className="text-sm font-medium block mb-1.5">Colour</label>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={e => setColor(e.target.value)}
className="h-9 w-16 rounded-lg border border-input bg-background cursor-pointer p-0.5"
/>
<input
value={color}
onChange={e => setColor(e.target.value)}
className={cn(inputCls, "flex-1 font-mono")}
placeholder="#6366f1"
maxLength={7}
/>
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={onClose} className="flex-1 py-2 rounded-lg border border-border text-sm hover:bg-secondary transition-colors">
Cancel
</button>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={!name.trim() || mutation.isPending}
className="flex-1 flex items-center justify-center gap-2 bg-primary text-primary-foreground rounded-lg py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
{isEdit ? "Save" : "Create"}
</button>
</div>
</div>
</div>
);
}
function CategoriesSection() {
const qc = useQueryClient();
const [modal, setModal] = useState<"create" | Category | null>(null);
const [deleting, setDeleting] = useState<Category | null>(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 (
<div className="space-y-4">
<div className={cardCls}>
<div className="flex items-center justify-between gap-2 flex-wrap">
<SectionTitle>Categories</SectionTitle>
<button
type="button"
onClick={() => setModal("create")}
className="flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-primary/90 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add category
</button>
</div>
<p className="text-sm text-muted-foreground">
System categories are built-in and cannot be edited. Custom categories can be freely created, renamed, and coloured.
</p>
{/* Filter tabs */}
<div className="flex gap-1">
{(["all", "income", "expense", "transfer"] as const).map(t => (
<button
key={t}
type="button"
onClick={() => setFilter(t)}
className={cn(
"px-3 py-1 rounded-lg text-xs font-medium transition-colors capitalize",
filter === t ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-secondary"
)}
>
{t}
</button>
))}
</div>
{isLoading ? (
<div className="space-y-1">
{[1,2,3,4].map(i => <div key={i} className="h-9 bg-secondary/30 rounded-lg animate-pulse" />)}
</div>
) : (
<>
{/* User categories */}
{filtered(userCats).length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">Custom</p>
<div className="space-y-0.5">
{filtered(userCats).map(c => (
<CategoryRow
key={c.id}
cat={c}
onEdit={setModal}
onDelete={setDeleting}
/>
))}
</div>
</div>
)}
{/* System categories */}
{filtered(systemCats).length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1 px-1">System</p>
<div className="space-y-0.5">
{filtered(systemCats).map(c => (
<CategoryRow key={c.id} cat={c} onEdit={() => {}} onDelete={() => {}} />
))}
</div>
</div>
)}
{filtered([...userCats, ...systemCats]).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No categories for this type yet.</p>
)}
</>
)}
</div>
{/* Create / Edit modal */}
{modal !== null && (
<CategoryFormModal
editing={modal === "create" ? null : modal}
onClose={() => setModal(null)}
/>
)}
{/* Delete confirmation */}
{deleting && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-card border border-border rounded-2xl w-full max-w-sm shadow-xl p-6 space-y-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-full bg-destructive/15 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-destructive" />
</div>
<div>
<h3 className="font-semibold text-base">Delete "{deleting.name}"?</h3>
<p className="text-sm text-muted-foreground mt-1">
Transactions using this category will remain but their category will be unset. This cannot be undone.
</p>
</div>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setDeleting(null)}
disabled={deleteMutation.isPending}
className="flex-1 py-2.5 rounded-lg border border-border text-sm hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate(deleting.id)}
disabled={deleteMutation.isPending}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50 transition-colors"
>
{deleteMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Data ───────────────────────────────────────────────────────────────────── // ─── Data ─────────────────────────────────────────────────────────────────────
function DataSection() { function DataSection() {