493 lines
18 KiB
JavaScript
493 lines
18 KiB
JavaScript
import { 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="border-t border-border/60 bg-muted/15 px-4 py-4 sm:px-6">
|
|
<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>
|
|
<th className="px-4 py-3 text-left font-semibold">Status</th>
|
|
<th className="px-4 py-3 text-right font-semibold">Expected</th>
|
|
<th className="px-4 py-3 text-right font-semibold">Due</th>
|
|
<th className="px-4 py-3 text-right font-semibold">Paid</th>
|
|
<th className="px-4 py-3 text-right font-semibold">Payments</th>
|
|
<th className="px-4 py-3 text-right font-semibold">Last Paid</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/50">
|
|
{bills.map(bill => (
|
|
<tr key={bill.id} className="hover:bg-muted/25">
|
|
<td className="px-4 py-3"><BillName bill={bill} /></td>
|
|
<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 tabular-nums">{bill.due_day}</td>
|
|
<td className="px-4 py-3 text-right font-mono">{fmt(bill.total_paid)}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{bill.payment_count || 0}</td>
|
|
<td className="px-4 py-3 text-right tabular-nums">{fmtDate(bill.last_paid_date)}</td>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 totalBills = categories.reduce((sum, cat) => sum + (cat.active_bill_count || 0) + (cat.inactive_bill_count || 0), 0);
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={180}>
|
|
<div>
|
|
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">Categories</h1>
|
|
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
|
<span>{plural(categories.length, 'category')}</span>
|
|
<span aria-hidden="true">/</span>
|
|
<span>{plural(totalBills, 'bill')}</span>
|
|
</p>
|
|
</div>
|
|
<ChipLegend />
|
|
</div>
|
|
|
|
<div className="table-surface overflow-hidden">
|
|
<div className="border-b border-border/50 bg-card/65 px-4 py-4 sm:px-6">
|
|
<form onSubmit={handleAdd} className="flex w-full flex-col gap-2 sm:max-w-xl sm:flex-row">
|
|
<Input
|
|
ref={addInputRef}
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder="New category name..."
|
|
disabled={adding}
|
|
className="h-9 text-sm"
|
|
/>
|
|
<Button type="submit" size="sm" className="h-9 sm:w-auto" disabled={adding || !newName.trim()}>
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
{adding ? 'Adding...' : 'Add'}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
{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.
|
|
</div>
|
|
) : (
|
|
<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(
|
|
'group flex cursor-pointer flex-col gap-4 px-4 py-4 transition-colors sm:px-6 lg:flex-row lg:items-center lg:justify-between',
|
|
'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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-1 self-end opacity-80 transition-opacity group-hover:opacity-100 lg:self-auto">
|
|
<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>
|
|
|
|
<div className="mt-4 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
|
|
<ReceiptText className="h-3.5 w-3.5" />
|
|
<span>Category totals include active and inactive bills in your account only.</span>
|
|
</div>
|
|
</div>
|
|
|
|
<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}
|
|
/>
|
|
|
|
<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>
|
|
);
|
|
}
|