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
- 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)

View file

@ -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()

View file

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

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";
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 */}
<div className="flex-1 min-w-0 space-y-4">
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
{section === "ai" && <AiSection />}
{section === "profile" && <ProfileSection />}
{section === "security" && <SecuritySection />}
{section === "sessions" && <SessionsSection />}
{section === "categories" && <CategoriesSection />}
{section === "data" && <DataSection />}
{section === "backups" && <BackupsSection />}
{section === "ai" && <AiSection />}
</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 ─────────────────────────────────────────────────────────────────────
function DataSection() {