import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { toast } from 'sonner'; import { ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart, } from 'lucide-react'; import { api } from '@/api'; 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, fmtDate, errMessage } from '@/lib/utils'; import { formatUSD, asDollars } from '@/lib/money'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; function fmt(v: number | null | undefined): string { return formatUSD(asDollars(Number(v) || 0)); } interface CategoryBill { id: number; name: string; due_day?: number | null; expected_amount?: number; total_paid?: number; payment_count?: number; last_paid_date?: string | null; active?: number; } interface CatRow { id: number; name: string; active_bill_count?: number; inactive_bill_count?: number; payment_count?: number; bill_names?: string[]; bills?: CategoryBill[]; spending_enabled?: number | boolean; [key: string]: unknown; } interface CategoryMoveControls { enabled: boolean; moving: boolean; canMoveUp: boolean; canMoveDown: boolean; onMoveUp: (event: React.MouseEvent) => void; onMoveDown: (event: React.MouseEvent) => void; } interface CategoryDragProps { draggable: boolean; isDragging?: boolean; isDropTarget?: boolean; onDragStart?: React.DragEventHandler; onDragEnter?: React.DragEventHandler; onDragOver?: React.DragEventHandler; onDragEnd?: React.DragEventHandler; onDrop?: React.DragEventHandler; } function plural(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}`; } function billPreview(names: string[] = []): string { 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 categoryBillCount(category: CatRow): number { return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); } function Chip({ value, label, tone = 'muted', details }: { value: React.ReactNode; label: string; tone?: string; details?: string; }) { 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', } as Record)[tone]; return ( {value}

{label}

{details && details !== label &&

{details}

}
); } function StatChips({ category }: { category: CatRow }) { const names = billPreview(category.bill_names); return (
); } function ChipLegend() { const items: [string, string][] = [ ['Active', 'active'], ['Inactive', 'muted'], ['Payments', 'info'], ]; return (
{items.map(([label, tone]) => ( {label} ))}
); } function StatusPill({ active }: { active?: number }) { return ( {active ? 'Active' : 'Inactive'} ); } function BillName({ bill }: { bill: CategoryBill }) { 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 }: { category: CatRow }) { 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 [loadError, setLoadError] = useState(null); const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); const [expanded, setExpanded] = useState>(() => new Set()); const [showEmptyCategories, setShowEmptyCategories] = useState(false); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [movingCategoryId, setMovingCategoryId] = useState(null); 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 () => { setLoadError(null); try { const cats = await api.categories() as CatRow[]; setCategories(cats); } catch (err) { setLoadError(errMessage(err, 'Failed to load categories')); } finally { setLoading(false); } }, []); useEffect(() => { load(); }, [load]); function toggleCategory(id: number) { setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleAdd(e: React.FormEvent) { 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(''); setShowEmptyCategories(true); addInputRef.current?.focus(); load(); } catch (err) { toast.error(errMessage(err, 'Failed to add category')); } finally { setAdding(false); } } function openRename(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setRenameTarget(cat); } async function handleRename(name: string) { if (!renameTarget) return; setRenaming(true); try { await api.updateCategory(renameTarget.id, { name }); toast.success('Category renamed'); setRenameTarget(null); load(); } catch (err) { toast.error(errMessage(err, 'Failed to rename category')); } finally { setRenaming(false); } } function openDelete(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setDeleteTarget(cat); } async function handleDelete() { const category = deleteTarget; if (!category) return; setDeleting(true); try { await api.deleteCategory(category.id); toast.success(`"${category.name}" moved to recovery`, { description: 'Bills keep their category if you restore it within 30 days.', action: { label: 'Undo', onClick: async () => { try { await api.restoreCategory(category.id); toast.success(`"${category.name}" restored`); load(); } catch (err) { toast.error(errMessage(err, 'Failed to restore category')); } }, }, }); setExpanded(prev => { const next = new Set(prev); next.delete(category.id); return next; }); setDeleteTarget(null); load(); } catch (err) { toast.error(errMessage(err, '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); const emptyCategories = categories.filter(cat => categoryBillCount(cat) === 0); const visibleCategories = showEmptyCategories ? categories : categories.filter(cat => categoryBillCount(cat) > 0); const reorderEnabled = !loading && !loadError; async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) { setCategories(nextCategories); setMovingCategoryId(movedId); try { await api.reorderCategories(reorderPayload(nextCategories)); toast.success('Category order saved'); load(); } catch (err) { toast.error(errMessage(err, 'Failed to save category order')); load(); } finally { setMovingCategoryId(null); } } function reorderVisibleCategories(orderedVisible: CatRow[]) { if (!reorderEnabled) return; const visibleIds = new Set(visibleCategories.map(cat => cat.id)); const replacements = [...orderedVisible]; const nextCategories = categories.map(cat => ( visibleIds.has(cat.id) ? replacements.shift()! : cat )); persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible)); } function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls { return { enabled: reorderEnabled, moving: movingCategoryId === cat.id, canMoveUp: index > 0, canMoveDown: index < visibleCategories.length - 1, onMoveUp: (event) => { event.stopPropagation(); reorderVisibleCategories(moveInArray(visibleCategories, index, index - 1)); }, onMoveDown: (event) => { event.stopPropagation(); reorderVisibleCategories(moveInArray(visibleCategories, index, index + 1)); }, }; } function categoryDragProps(cat: CatRow, index: number): CategoryDragProps { if (!reorderEnabled) return { draggable: false }; return { draggable: true, isDragging: draggingId === cat.id, isDropTarget: dropTargetId === cat.id && draggingId !== cat.id, onDragStart: (event) => { setDraggingId(cat.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(cat.id)); }, onDragEnter: () => { if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id); }, onDragOver: (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; if (draggingId && draggingId !== cat.id) setDropTargetId(cat.id); }, onDrop: (event) => { event.preventDefault(); event.stopPropagation(); const sourceId = Number(event.dataTransfer.getData('text/plain') || draggingId); const fromIndex = visibleCategories.findIndex(item => item.id === sourceId); if (fromIndex >= 0) reorderVisibleCategories(moveInArray(visibleCategories, fromIndex, index)); setDraggingId(null); setDropTargetId(null); }, onDragEnd: () => { setDraggingId(null); setDropTargetId(null); }, }; } return (

Categories

Organize bills by purpose, status, and payment activity.

{([ ['Categories', categories.length], ['Active bills', activeBills], ['Inactive', inactiveBills], ['Payments', paymentCount], ] as [string, number][]).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...
) : loadError ? (

Failed to load categories

{loadError}

) : visibleCategories.length === 0 ? (
{categories.length === 0 ? 'No categories yet. Add one above.' : 'No categories with bills yet.'} {emptyCategories.length > 0 && ( )}
) : (
{emptyCategories.length > 0 && (
{showEmptyCategories ? `Showing ${plural(emptyCategories.length, 'empty category')}.` : `${plural(emptyCategories.length, 'empty category')} hidden until a bill uses them.`}
)} {visibleCategories.map((cat, index) => { const isExpanded = expanded.has(cat.id); const preview = billPreview(cat.bill_names); const moveControls = categoryMoveControls(cat, index); const dragProps = categoryDragProps(cat, index); return (
{/* Plain container (not role=button) so the nested action buttons aren't interactive-in-interactive (a11y QA-B14-02). Mouse click-anywhere still expands; keyboard/SR users use the dedicated chevron toggle button below. */}
toggleCategory(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', isExpanded && 'bg-muted/25', )} >
event.stopPropagation()} onKeyDown={event => event.stopPropagation()} >
{cat.name}

{cat.name}

{preview}

{preview}

{cat.spending_enabled ? 'Shown in Spending page — click to disable' : 'Enable for Spending page'}
{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); }}> Move {deleteTarget?.name} to recovery? This hides the category from normal views. Bills keep their category link, and you can restore it for 30 days. Cancel {deleting ? 'Moving...' : 'Move to Recovery'}
); }