import React, { 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.
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 [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]) => (
))}
{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}
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); }}>
Delete {deleteTarget?.name}?
Bills in this category will become uncategorized. No bills or payments will be deleted.
Cancel
{deleting ? 'Deleting...' : 'Delete Category'}
);
}