226 lines
8.0 KiB
JavaScript
226 lines
8.0 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
|
import { api } from '@/api.js';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { InputDialog } from '@/components/ui/input-dialog';
|
|
import {
|
|
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { buttonVariants } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ─── CategoriesPage ───────────────────────────────────────────────────────────
|
|
|
|
export default function CategoriesPage() {
|
|
const [categories, setCategories] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [newName, setNewName] = useState('');
|
|
const [adding, setAdding] = useState(false);
|
|
const addInputRef = useRef(null);
|
|
|
|
// Rename dialog state
|
|
const [renameTarget, setRenameTarget] = useState(null); // { id, name }
|
|
const [renaming, setRenaming] = useState(false);
|
|
|
|
// Delete dialog state
|
|
const [deleteTarget, setDeleteTarget] = useState(null); // { id, name }
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const cats = await api.categories();
|
|
setCategories(cats);
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
// ── Add ──────────────────────────────────────────────────────────────────────
|
|
|
|
async function handleAdd(e) {
|
|
e.preventDefault();
|
|
const trimmed = newName.trim();
|
|
if (!trimmed) {
|
|
toast.error('Enter a category name');
|
|
addInputRef.current?.focus();
|
|
return;
|
|
}
|
|
setAdding(true);
|
|
try {
|
|
await api.createCategory({ name: trimmed });
|
|
toast.success(`"${trimmed}" added`);
|
|
setNewName('');
|
|
addInputRef.current?.focus();
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
} finally {
|
|
setAdding(false);
|
|
}
|
|
}
|
|
|
|
// ── Rename ───────────────────────────────────────────────────────────────────
|
|
|
|
function openRename(cat) {
|
|
setRenameTarget(cat);
|
|
}
|
|
|
|
async function handleRename(name) {
|
|
setRenaming(true);
|
|
try {
|
|
await api.updateCategory(renameTarget.id, { name });
|
|
toast.success('Category renamed');
|
|
setRenameTarget(null);
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
} finally {
|
|
setRenaming(false);
|
|
}
|
|
}
|
|
|
|
// ── Delete ───────────────────────────────────────────────────────────────────
|
|
|
|
function openDelete(cat) {
|
|
setDeleteTarget(cat);
|
|
}
|
|
|
|
async function handleDelete() {
|
|
setDeleting(true);
|
|
try {
|
|
await api.deleteCategory(deleteTarget.id);
|
|
toast.success(`"${deleteTarget.name}" deleted`);
|
|
setDeleteTarget(null);
|
|
load();
|
|
} catch (err) {
|
|
toast.error(err.message);
|
|
} finally {
|
|
setDeleting(false);
|
|
}
|
|
}
|
|
|
|
// ── Render ───────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<div>
|
|
{/* Page header — floats on bg-background */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
|
<p className="text-sm text-muted-foreground mt-0.5">{categories.length} categories</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card layer — lifted above page background */}
|
|
<div className="table-surface">
|
|
|
|
{/* Card header with inline add form */}
|
|
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
|
<form onSubmit={handleAdd} className="flex gap-2 flex-1 max-w-sm">
|
|
<Input
|
|
ref={addInputRef}
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder="New category name…"
|
|
disabled={adding}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Button type="submit" size="sm" className="h-8" disabled={adding || !newName.trim()}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{adding ? 'Adding…' : 'Add'}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Category list */}
|
|
{loading ? (
|
|
<div className="py-16 text-center text-muted-foreground text-sm">Loading…</div>
|
|
) : categories.length === 0 ? (
|
|
<div className="py-16 text-center text-muted-foreground text-sm">
|
|
No categories yet. Add one above.
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-border/50">
|
|
{categories.map((cat) => (
|
|
<div
|
|
key={cat.id}
|
|
className="group flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-medium">{cat.name}</span>
|
|
{cat.bill_count > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{cat.bill_count} {cat.bill_count === 1 ? 'bill' : 'bills'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 opacity-70 hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => openRename(cat)}
|
|
>
|
|
<Pencil className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive hover:text-destructive"
|
|
onClick={() => openDelete(cat)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>{/* /card */}
|
|
|
|
{/* Rename dialog */}
|
|
<InputDialog
|
|
open={!!renameTarget}
|
|
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
|
|
title="Rename Category"
|
|
label="Name"
|
|
defaultValue={renameTarget?.name ?? ''}
|
|
placeholder="Category name"
|
|
confirmLabel="Rename"
|
|
loading={renaming}
|
|
onConfirm={handleRename}
|
|
/>
|
|
|
|
{/* Delete dialog */}
|
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Bills in this category will become uncategorized. This cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className={cn(buttonVariants({ variant: 'destructive' }))}
|
|
onClick={handleDelete}
|
|
disabled={deleting}
|
|
>
|
|
{deleting ? 'Deleting…' : 'Delete Category'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
</div>
|
|
);
|
|
}
|