import { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { ChevronDown, Plus, Pencil, Trash2, ReceiptText, } from 'lucide-react'; import { api } from '@/api.js'; import { Button, buttonVariants } 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; import { cn, fmt, fmtDate } from '@/lib/utils'; function plural(count, label) { return `${count} ${label}${count === 1 ? '' : 's'}`; } function billPreview(names = []) { if (!names.length) return 'No bills in this category yet.'; const visible = names.slice(0, 4).join(', '); const more = names.length > 4 ? `, +${names.length - 4} more` : ''; return `${visible}${more}`; } function Chip({ value, label, tone = 'muted', details }) { const toneClass = { active: 'border-primary/25 bg-primary/10 text-primary', muted: 'border-border bg-muted/55 text-muted-foreground', info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', }[tone]; return ( {value}

{label}

{details && details !== label &&

{details}

}
); } function StatChips({ category }) { const names = billPreview(category.bill_names); return (
); } function ChipLegend() { const items = [ ['Active', 'active'], ['Inactive', 'muted'], ['Payments', 'info'], ]; return (
{items.map(([label, tone]) => ( {label} ))}
); } function StatusPill({ active }) { return ( {active ? 'Active' : 'Inactive'} ); } function BillName({ bill }) { const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`; return ( {bill.name}

{label}

{plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid

); } function ExpandedBills({ category }) { const bills = category.bills || []; if (!bills.length) { return (
No bills in this category yet.
); } return (
{bills.map(bill => ( ))}
Bill State Expected Paid History

Due day {bill.due_day}

{fmt(bill.expected_amount)} {fmt(bill.total_paid)}

{plural(bill.payment_count || 0, 'payment')}

{fmtDate(bill.last_paid_date)}

{bills.map(bill => (

Due day {bill.due_day}

Expected

{fmt(bill.expected_amount)}

Paid

{fmt(bill.total_paid)}

Payments

{bill.payment_count || 0}

Last Paid

{fmtDate(bill.last_paid_date)}

))}
); } export default function CategoriesPage() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); const [expanded, setExpanded] = useState(() => new Set()); const addInputRef = useRef(null); const [renameTarget, setRenameTarget] = useState(null); const [renaming, setRenaming] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); 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]); function toggleCategory(id) { setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } function onRowKeyDown(event, id) { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); toggleCategory(id); } } 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); } } function openRename(event, cat) { event.stopPropagation(); 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); } } function openDelete(event, cat) { event.stopPropagation(); setDeleteTarget(cat); } async function handleDelete() { setDeleting(true); try { await api.deleteCategory(deleteTarget.id); toast.success(`"${deleteTarget.name}" deleted`); setExpanded(prev => { const next = new Set(prev); next.delete(deleteTarget.id); return next; }); setDeleteTarget(null); load(); } catch (err) { toast.error(err.message || 'Could not delete category.'); } finally { setDeleting(false); } } const activeBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0), 0); const inactiveBills = categories.reduce((sum, cat) => sum + (cat.inactive_bill_count || 0), 0); const paymentCount = categories.reduce((sum, cat) => sum + (cat.payment_count || 0), 0); return (

Categories

Organize bills by purpose, status, and payment activity.

{[ ['Categories', categories.length], ['Active bills', activeBills], ['Inactive', inactiveBills], ['Payments', paymentCount], ].map(([label, value]) => (

{label}

{value}

))}
setNewName(e.target.value)} placeholder="New category name..." disabled={adding} className="h-9 min-w-0 text-sm" />
{loading ? (
Loading...
) : categories.length === 0 ? (
No categories yet. Add one above.
) : (
{categories.map((cat) => { const isExpanded = expanded.has(cat.id); const preview = billPreview(cat.bill_names); return (
toggleCategory(cat.id)} onKeyDown={event => onRowKeyDown(event, cat.id)} className={cn( 'group grid cursor-pointer gap-4 px-4 py-4 transition-colors sm:px-5 md:grid-cols-[minmax(0,1fr)_auto] md:items-center', 'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset', isExpanded && 'bg-muted/25', )} >
{cat.name}

{cat.name}

{preview}

{preview}

{isExpanded && }
); })}
)}
Category totals include active and inactive bills in your account only.
{ if (!open) setRenameTarget(null); }} title="Rename Category" label="Name" defaultValue={renameTarget?.name ?? ''} placeholder="Category name" confirmLabel="Rename" loading={renaming} onConfirm={handleRename} /> { if (!open) setDeleteTarget(null); }}> Delete {deleteTarget?.name}? Bills in this category will become uncategorized. No bills or payments will be deleted. Cancel {deleting ? 'Deleting...' : 'Delete Category'}
); }