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:
parent
8ef3bb2965
commit
6111424f47
5 changed files with 455 additions and 22 deletions
10
README.md
10
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
|
- 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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
37
frontend/src/api/categories.ts
Normal file
37
frontend/src/api/categories.ts
Normal 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);
|
||||||
|
|
@ -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,13 +18,14 @@ 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: "categories", label: "Categories", icon: Tag },
|
||||||
{ id: "data", label: "Data", icon: Download },
|
{ id: "data", label: "Data", icon: Download },
|
||||||
{ id: "backups", label: "Backups", icon: HardDrive },
|
{ id: "backups", label: "Backups", icon: HardDrive },
|
||||||
{ id: "ai", label: "AI", icon: Sparkles },
|
{ id: "ai", label: "AI", icon: Sparkles },
|
||||||
|
|
@ -65,6 +68,7 @@ export default function SettingsPage() {
|
||||||
{section === "profile" && <ProfileSection />}
|
{section === "profile" && <ProfileSection />}
|
||||||
{section === "security" && <SecuritySection />}
|
{section === "security" && <SecuritySection />}
|
||||||
{section === "sessions" && <SessionsSection />}
|
{section === "sessions" && <SessionsSection />}
|
||||||
|
{section === "categories" && <CategoriesSection />}
|
||||||
{section === "data" && <DataSection />}
|
{section === "data" && <DataSection />}
|
||||||
{section === "backups" && <BackupsSection />}
|
{section === "backups" && <BackupsSection />}
|
||||||
{section === "ai" && <AiSection />}
|
{section === "ai" && <AiSection />}
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue