BillTracker/client/pages/CategoriesPage.jsx

512 lines
19 KiB
React
Raw Normal View History

2026-05-09 13:03:36 -05:00
import React, { useState, useEffect, useCallback, useRef } from 'react';
2026-05-04 16:38:03 -05:00
import { Link } from 'react-router-dom';
2026-05-03 19:51:57 -05:00
import { toast } from 'sonner';
2026-05-04 16:38:03 -05:00
import {
ChevronDown, Plus, Pencil, Trash2, ReceiptText,
} from 'lucide-react';
2026-05-03 19:51:57 -05:00
import { api } from '@/api.js';
2026-05-04 16:38:03 -05:00
import { Button, buttonVariants } from '@/components/ui/button';
2026-05-03 19:51:57 -05:00
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';
2026-05-04 16:38:03 -05:00
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 (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
title={details || label}
aria-label={details || label}
className={cn(
'inline-flex h-6 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
toneClass,
)}
>
{value}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-64 leading-relaxed">
<p>{label}</p>
{details && details !== label && <p className="opacity-85">{details}</p>}
</TooltipContent>
</Tooltip>
);
}
function StatChips({ category }) {
const names = billPreview(category.bill_names);
return (
<div className="flex flex-wrap items-center gap-1.5">
<Chip
value={category.active_bill_count || 0}
label={plural(category.active_bill_count || 0, 'active bill')}
details={names}
tone="active"
/>
<Chip
value={category.inactive_bill_count || 0}
label={plural(category.inactive_bill_count || 0, 'inactive bill')}
details={names}
/>
<Chip
value={category.payment_count || 0}
label={plural(category.payment_count || 0, 'payment')}
details={names}
tone="info"
/>
</div>
);
}
2026-05-03 19:51:57 -05:00
2026-05-04 16:38:03 -05:00
function ChipLegend() {
const items = [
['Active', 'active'],
['Inactive', 'muted'],
['Payments', 'info'],
];
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{items.map(([label, tone]) => (
<span key={label} className="inline-flex items-center gap-1.5">
<span className={cn(
'h-2.5 w-2.5 rounded-full border',
tone === 'active' && 'border-primary/30 bg-primary/45',
tone === 'muted' && 'border-border bg-muted',
tone === 'info' && 'border-sky-500/30 bg-sky-500/45',
)} />
{label}
</span>
))}
</div>
);
}
function StatusPill({ active }) {
return (
<span className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
active
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-border bg-muted text-muted-foreground',
)}>
{active ? 'Active' : 'Inactive'}
</span>
);
}
function BillName({ bill }) {
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
return (
<Tooltip>
<TooltipTrigger asChild>
<span title={label} className="font-medium text-foreground underline-offset-4 hover:underline">
{bill.name}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 leading-relaxed">
<p>{label}</p>
<p className="opacity-85">
{plural(bill.payment_count || 0, 'payment')} / {fmt(bill.total_paid)} paid
</p>
</TooltipContent>
</Tooltip>
);
}
function ExpandedBills({ category }) {
const bills = category.bills || [];
if (!bills.length) {
return (
<div className="border-t border-border/60 bg-muted/15 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-border/70 bg-background/65 p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span>No bills in this category yet.</span>
<Button asChild variant="outline" size="sm" className="w-fit">
<Link to="/bills">Open Bills</Link>
</Button>
</div>
</div>
);
}
return (
2026-05-04 20:12:57 -05:00
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-5">
2026-05-04 16:38:03 -05:00
<div className="hidden overflow-hidden rounded-lg border border-border/60 bg-background/75 lg:block">
<table className="w-full text-sm">
<thead className="bg-muted/45 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-3 text-left font-semibold">Bill</th>
2026-05-04 20:12:57 -05:00
<th className="px-4 py-3 text-left font-semibold">State</th>
2026-05-04 16:38:03 -05:00
<th className="px-4 py-3 text-right font-semibold">Expected</th>
<th className="px-4 py-3 text-right font-semibold">Paid</th>
2026-05-04 20:12:57 -05:00
<th className="px-4 py-3 text-right font-semibold">History</th>
2026-05-04 16:38:03 -05:00
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{bills.map(bill => (
<tr key={bill.id} className="hover:bg-muted/25">
2026-05-04 20:12:57 -05:00
<td className="px-4 py-3">
<BillName bill={bill} />
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
</td>
2026-05-04 16:38:03 -05:00
<td className="px-4 py-3"><StatusPill active={bill.active} /></td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.expected_amount)}</td>
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
2026-05-04 20:12:57 -05:00
<td className="px-4 py-3 text-right">
<p className="tabular-nums">{plural(bill.payment_count || 0, 'payment')}</p>
<p className="mt-1 text-xs tabular-nums text-muted-foreground">{fmtDate(bill.last_paid_date)}</p>
</td>
2026-05-04 16:38:03 -05:00
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 lg:hidden">
{bills.map(bill => (
<div key={bill.id} className="rounded-lg border border-border/60 bg-background/75 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm"><BillName bill={bill} /></p>
<p className="mt-1 text-xs text-muted-foreground">Due day {bill.due_day}</p>
</div>
<StatusPill active={bill.active} />
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<p className="text-muted-foreground">Expected</p>
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.expected_amount)}</p>
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="mt-0.5 font-mono font-semibold">{fmt(bill.total_paid)}</p>
</div>
<div>
<p className="text-muted-foreground">Payments</p>
<p className="mt-0.5 font-semibold tabular-nums">{bill.payment_count || 0}</p>
</div>
<div>
<p className="text-muted-foreground">Last Paid</p>
<p className="mt-0.5 font-semibold tabular-nums">{fmtDate(bill.last_paid_date)}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
2026-05-03 19:51:57 -05:00
export default function CategoriesPage() {
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false);
2026-05-04 16:38:03 -05:00
const [expanded, setExpanded] = useState(() => new Set());
2026-05-03 19:51:57 -05:00
const addInputRef = useRef(null);
2026-05-04 16:38:03 -05:00
const [renameTarget, setRenameTarget] = useState(null);
2026-05-03 19:51:57 -05:00
const [renaming, setRenaming] = useState(false);
2026-05-04 16:38:03 -05:00
const [deleteTarget, setDeleteTarget] = useState(null);
2026-05-03 19:51:57 -05:00
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]);
2026-05-04 16:38:03 -05:00
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);
}
}
2026-05-03 19:51:57 -05:00
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);
}
}
2026-05-04 16:38:03 -05:00
function openRename(event, cat) {
event.stopPropagation();
2026-05-03 19:51:57 -05:00
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);
}
}
2026-05-04 16:38:03 -05:00
function openDelete(event, cat) {
event.stopPropagation();
2026-05-03 19:51:57 -05:00
setDeleteTarget(cat);
}
async function handleDelete() {
setDeleting(true);
try {
await api.deleteCategory(deleteTarget.id);
toast.success(`"${deleteTarget.name}" deleted`);
2026-05-04 16:38:03 -05:00
setExpanded(prev => {
const next = new Set(prev);
next.delete(deleteTarget.id);
return next;
});
2026-05-03 19:51:57 -05:00
setDeleteTarget(null);
load();
} catch (err) {
2026-05-04 16:38:03 -05:00
toast.error(err.message || 'Could not delete category.');
2026-05-03 19:51:57 -05:00
} finally {
setDeleting(false);
}
}
2026-05-04 20:12:57 -05:00
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);
2026-05-03 19:51:57 -05:00
return (
2026-05-04 16:38:03 -05:00
<TooltipProvider delayDuration={180}>
2026-05-04 20:12:57 -05:00
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-border/70 bg-card shadow-sm">
<ReceiptText className="h-4 w-4 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Organize bills by purpose, status, and payment activity.</p>
</div>
</div>
2026-05-04 16:38:03 -05:00
</div>
<ChipLegend />
2026-05-03 19:51:57 -05:00
</div>
2026-05-04 20:12:57 -05:00
<div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
['Categories', categories.length],
['Active bills', activeBills],
['Inactive', inactiveBills],
['Payments', paymentCount],
].map(([label, value]) => (
<div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-xl font-bold text-foreground">{value}</p>
</div>
))}
2026-05-03 19:51:57 -05:00
</div>
2026-05-04 16:38:03 -05:00
2026-05-04 20:12:57 -05:00
<form onSubmit={handleAdd} className="flex min-w-0 flex-col gap-2 rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm sm:flex-row md:flex-col lg:flex-row">
<Input
ref={addInputRef}
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New category name..."
disabled={adding}
className="h-9 min-w-0 text-sm"
/>
<Button type="submit" size="sm" className="h-9 shrink-0 sm:w-auto" disabled={adding || !newName.trim()}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{adding ? 'Adding...' : 'Add'}
</Button>
</form>
</div>
<div className="table-surface overflow-hidden rounded-xl">
2026-05-04 16:38:03 -05:00
{loading ? (
<div className="py-16 text-center text-sm text-muted-foreground">Loading...</div>
) : categories.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground">
No categories yet. Add one above.
2026-05-03 19:51:57 -05:00
</div>
2026-05-04 16:38:03 -05:00
) : (
<div className="divide-y divide-border/50">
{categories.map((cat) => {
const isExpanded = expanded.has(cat.id);
const preview = billPreview(cat.bill_names);
return (
<section key={cat.id} className="bg-card/35">
<div
role="button"
tabIndex={0}
aria-expanded={isExpanded}
title={preview}
onClick={() => toggleCategory(cat.id)}
onKeyDown={event => onRowKeyDown(event, cat.id)}
className={cn(
2026-05-04 20:12:57 -05:00
'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',
2026-05-04 16:38:03 -05:00
'hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
isExpanded && 'bg-muted/25',
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<ChevronDown
className={cn(
'mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform',
isExpanded && 'rotate-180 text-foreground',
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate text-sm font-semibold tracking-tight text-foreground" title={preview}>
{cat.name}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72 leading-relaxed">
<p className="font-medium">{cat.name}</p>
<p className="opacity-85">{preview}</p>
</TooltipContent>
</Tooltip>
<StatChips category={cat} />
</div>
2026-05-04 20:12:57 -05:00
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{preview}</p>
2026-05-04 16:38:03 -05:00
</div>
</div>
2026-05-04 20:12:57 -05:00
<div className="flex items-center justify-end gap-1 opacity-80 transition-opacity group-hover:opacity-100">
2026-05-04 16:38:03 -05:00
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(event) => openRename(event, cat)}
aria-label={`Rename ${cat.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(event) => openDelete(event, cat)}
aria-label={`Delete ${cat.name}`}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isExpanded && <ExpandedBills category={cat} />}
</section>
);
})}
</div>
)}
</div>
2026-05-04 20:12:57 -05:00
<div className="text-xs text-muted-foreground">
Category totals include active and inactive bills in your account only.
2026-05-04 16:38:03 -05:00
</div>
2026-05-03 19:51:57 -05:00
2026-05-04 16:38:03 -05:00
<InputDialog
open={!!renameTarget}
onOpenChange={(open) => { if (!open) setRenameTarget(null); }}
title="Rename Category"
label="Name"
defaultValue={renameTarget?.name ?? ''}
placeholder="Category name"
confirmLabel="Rename"
loading={renaming}
onConfirm={handleRename}
/>
2026-05-03 19:51:57 -05:00
2026-05-04 16:38:03 -05:00
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleteTarget?.name}?</AlertDialogTitle>
<AlertDialogDescription>
Bills in this category will become uncategorized. No bills or payments will be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete Category'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
2026-05-03 19:51:57 -05:00
);
}