BillTracker/client/pages/CategoriesPage.jsx

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