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.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'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; 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 categoryBillCount(category) { return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); } 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 [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(); setCategories(cats); } catch (err) { setLoadError(err.message || 'Failed to load categories'); } 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(''); setShowEmptyCategories(true); 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 { const category = deleteTarget; await api.deleteCategory(deleteTarget.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(err.message || 'Failed to restore category'); } }, }, }); setExpanded(prev => { const next = new Set(prev); next.delete(category.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); 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, movedId) { setCategories(nextCategories); setMovingCategoryId(movedId); try { await api.reorderCategories(reorderPayload(nextCategories)); toast.success('Category order saved'); load(); } catch (err) { toast.error(err.message || 'Failed to save category order'); load(); } finally { setMovingCategoryId(null); } } function reorderVisibleCategories(orderedVisible) { 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, index) { 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, index) { 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], ].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'}
); }