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
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";
|
||||
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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue