BillTracker/client/pages/CategoriesPage.jsx

740 lines
30 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 {
ArrowDown, ArrowUp, ChevronDown, GripVertical, Plus, Pencil, Trash2, ReceiptText, AlertCircle, RefreshCw, ShoppingCart,
2026-05-04 16:38:03 -05:00
} 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';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
2026-05-04 16:38:03 -05:00
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}`;
}
2026-05-16 10:56:56 -05:00
function categoryBillCount(category) {
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
}
2026-05-04 16:38:03 -05:00
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="tracker-number px-4 py-3 text-right font-semibold">{fmt(bill.expected_amount)}</td>
<td className="tracker-number px-4 py-3 text-right font-semibold">{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="tracker-number mt-0.5 font-semibold">{fmt(bill.expected_amount)}</p>
2026-05-04 16:38:03 -05:00
</div>
<div>
<p className="text-muted-foreground">Paid</p>
<p className="tracker-number mt-0.5 font-semibold">{fmt(bill.total_paid)}</p>
2026-05-04 16:38:03 -05:00
</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);
2026-05-28 02:34:24 -05:00
const [loadError, setLoadError] = useState(null);
2026-05-03 19:51:57 -05:00
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-16 10:56:56 -05:00
const [showEmptyCategories, setShowEmptyCategories] = useState(false);
const [draggingId, setDraggingId] = useState(null);
const [dropTargetId, setDropTargetId] = useState(null);
const [movingCategoryId, setMovingCategoryId] = useState(null);
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 () => {
2026-05-28 02:34:24 -05:00
setLoadError(null);
2026-05-03 19:51:57 -05:00
try {
const cats = await api.categories();
setCategories(cats);
} catch (err) {
2026-05-28 02:34:24 -05:00
setLoadError(err.message || 'Failed to load categories');
2026-05-03 19:51:57 -05:00
} 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('');
2026-05-16 10:56:56 -05:00
setShowEmptyCategories(true);
2026-05-03 19:51:57 -05:00
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 {
2026-05-16 10:34:32 -05:00
const category = deleteTarget;
2026-05-03 19:51:57 -05:00
await api.deleteCategory(deleteTarget.id);
2026-05-16 10:34:32 -05:00
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');
}
},
},
});
2026-05-04 16:38:03 -05:00
setExpanded(prev => {
const next = new Set(prev);
2026-05-16 10:34:32 -05:00
next.delete(category.id);
2026-05-04 16:38:03 -05:00
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-16 10:56:56 -05:00
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);
},
};
}
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-normal text-muted-foreground">{label}</p>
<p className="tracker-number mt-1 text-xl font-bold text-foreground">{value}</p>
2026-05-04 20:12:57 -05:00
</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>
2026-05-28 02:34:24 -05:00
) : loadError ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertCircle className="h-8 w-8 text-destructive mb-3" />
<p className="text-sm font-medium text-foreground">Failed to load categories</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button size="sm" variant="outline" onClick={load}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
2026-05-16 10:56:56 -05:00
) : visibleCategories.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center text-sm text-muted-foreground">
<span>
{categories.length === 0
? 'No categories yet. Add one above.'
: 'No categories with bills yet.'}
</span>
{emptyCategories.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowEmptyCategories(true)}
>
Show Empty Categories
</Button>
)}
2026-05-03 19:51:57 -05:00
</div>
2026-05-04 16:38:03 -05:00
) : (
<div className="divide-y divide-border/50">
2026-05-16 10:56:56 -05:00
{emptyCategories.length > 0 && (
<div className="flex flex-col gap-2 bg-muted/20 px-4 py-3 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-5">
<span>
{showEmptyCategories
? `Showing ${plural(emptyCategories.length, 'empty category')}.`
: `${plural(emptyCategories.length, 'empty category')} hidden until a bill uses them.`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-fit px-2 text-xs"
onClick={() => setShowEmptyCategories(value => !value)}
>
{showEmptyCategories ? 'Hide Empty' : 'Show Empty'}
</Button>
</div>
)}
{visibleCategories.map((cat, index) => {
2026-05-04 16:38:03 -05:00
const isExpanded = expanded.has(cat.id);
const preview = billPreview(cat.bill_names);
const moveControls = categoryMoveControls(cat, index);
const dragProps = categoryDragProps(cat, index);
2026-05-04 16:38:03 -05:00
return (
<section
key={cat.id}
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
className={cn(
'bg-card/35',
dragProps?.isDragging && 'opacity-45',
dragProps?.isDropTarget && 'ring-2 ring-inset ring-primary/35',
)}
>
2026-05-04 16:38:03 -05:00
<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">
<div
className="flex shrink-0 items-center gap-0.5"
onClick={event => event.stopPropagation()}
onKeyDown={event => event.stopPropagation()}
>
<GripVertical
className={cn(
'h-4 w-4 text-muted-foreground/55',
moveControls.enabled && 'cursor-grab group-active:cursor-grabbing',
!moveControls.enabled && 'opacity-30',
)}
aria-hidden="true"
/>
<div className="flex flex-col">
<button
type="button"
onClick={moveControls.onMoveUp}
disabled={!moveControls.enabled || !moveControls.canMoveUp || moveControls.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move category up"
aria-label={`Move ${cat.name} up`}
>
<ArrowUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={moveControls.onMoveDown}
disabled={!moveControls.enabled || !moveControls.canMoveDown || moveControls.moving}
className="rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-25"
title="Move category down"
aria-label={`Move ${cat.name} down`}
>
<ArrowDown className="h-3 w-3" />
</button>
</div>
</div>
2026-05-04 16:38:03 -05:00
<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">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={`h-8 w-8 ${cat.spending_enabled ? 'text-emerald-500 hover:text-emerald-400' : 'text-muted-foreground/40 hover:text-muted-foreground'}`}
onClick={async (event) => {
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}`}
>
<ShoppingCart className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
{cat.spending_enabled ? 'Shown in Spending page — click to disable' : 'Enable for Spending page'}
</TooltipContent>
</Tooltip>
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>
2026-05-16 10:34:32 -05:00
<AlertDialogTitle>Move {deleteTarget?.name} to recovery?</AlertDialogTitle>
2026-05-04 16:38:03 -05:00
<AlertDialogDescription>
2026-05-16 10:34:32 -05:00
This hides the category from normal views. Bills keep their category link, and you can restore it for 30 days.
2026-05-04 16:38:03 -05:00
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: 'destructive' }))}
onClick={handleDelete}
disabled={deleting}
>
2026-05-16 10:34:32 -05:00
{deleting ? 'Moving...' : 'Move to Recovery'}
2026-05-04 16:38:03 -05:00
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
2026-05-03 19:51:57 -05:00
);
}