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.
Open Bills
);
}
return (
Bill
State
Expected
Paid
History
{bills.map(bill => (
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 => (
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]) => (
))}
{loading ? (
Loading...
) : loadError ? (
Failed to load categories
{loadError}
Try again
) : visibleCategories.length === 0 ? (
{categories.length === 0
? 'No categories yet. Add one above.'
: 'No categories with bills yet.'}
{emptyCategories.length > 0 && (
setShowEmptyCategories(true)}
>
Show Empty Categories
)}
) : (
{emptyCategories.length > 0 && (
{showEmptyCategories
? `Showing ${plural(emptyCategories.length, 'empty category')}.`
: `${plural(emptyCategories.length, 'empty category')} hidden until a bill uses them.`}
setShowEmptyCategories(value => !value)}
>
{showEmptyCategories ? 'Hide Empty' : 'Show Empty'}
)}
{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 (
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',
)}
>
event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
{cat.name}
{cat.name}
{preview}
{preview}
{
event.stopPropagation();
try {
const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled);
setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c));
} catch (err) {
toast.error(err.message || 'Failed to update category');
}
}}
aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`}
>
{cat.spending_enabled ? 'Shown in Spending page — click to disable' : 'Enable for Spending page'}
openRename(event, cat)}
aria-label={`Rename ${cat.name}`}
>
openDelete(event, cat)}
aria-label={`Delete ${cat.name}`}
>
{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'}
);
}