refactor(ts): CategoriesPage → TypeScript (category list + drag-reorder + expand)

This commit is contained in:
null 2026-07-04 22:44:39 -05:00
parent 8c9702adbe
commit f15046bce3
1 changed files with 99 additions and 44 deletions

View File

@ -15,30 +15,83 @@ import {
import { import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmtDate, errMessage } from '@/lib/utils';
import { formatUSD, asDollars } from '@/lib/money';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
function plural(count, label) { function fmt(v: number | null | undefined): string {
return formatUSD(asDollars(Number(v) || 0));
}
interface CategoryBill {
id: number;
name: string;
due_day?: number | null;
expected_amount?: number;
total_paid?: number;
payment_count?: number;
last_paid_date?: string | null;
active?: number;
}
interface CatRow {
id: number;
name: string;
active_bill_count?: number;
inactive_bill_count?: number;
payment_count?: number;
bill_names?: string[];
bills?: CategoryBill[];
spending_enabled?: number | boolean;
[key: string]: unknown;
}
interface CategoryMoveControls {
enabled: boolean;
moving: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: (event: React.MouseEvent) => void;
onMoveDown: (event: React.MouseEvent) => void;
}
interface CategoryDragProps {
draggable: boolean;
isDragging?: boolean;
isDropTarget?: boolean;
onDragStart?: React.DragEventHandler<HTMLElement>;
onDragEnter?: React.DragEventHandler<HTMLElement>;
onDragOver?: React.DragEventHandler<HTMLElement>;
onDragEnd?: React.DragEventHandler<HTMLElement>;
onDrop?: React.DragEventHandler<HTMLElement>;
}
function plural(count: number, label: string): string {
return `${count} ${label}${count === 1 ? '' : 's'}`; return `${count} ${label}${count === 1 ? '' : 's'}`;
} }
function billPreview(names = []) { function billPreview(names: string[] = []): string {
if (!names.length) return 'No bills in this category yet.'; if (!names.length) return 'No bills in this category yet.';
const visible = names.slice(0, 4).join(', '); const visible = names.slice(0, 4).join(', ');
const more = names.length > 4 ? `, +${names.length - 4} more` : ''; const more = names.length > 4 ? `, +${names.length - 4} more` : '';
return `${visible}${more}`; return `${visible}${more}`;
} }
function categoryBillCount(category) { function categoryBillCount(category: CatRow): number {
return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); return (category.active_bill_count || 0) + (category.inactive_bill_count || 0);
} }
function Chip({ value, label, tone = 'muted', details }) { function Chip({ value, label, tone = 'muted', details }: {
const toneClass = { value: React.ReactNode;
label: string;
tone?: string;
details?: string;
}) {
const toneClass = ({
active: 'border-primary/25 bg-primary/10 text-primary', active: 'border-primary/25 bg-primary/10 text-primary',
muted: 'border-border bg-muted/55 text-muted-foreground', 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', info: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
}[tone]; } as Record<string, string>)[tone];
return ( return (
<Tooltip> <Tooltip>
@ -64,7 +117,7 @@ function Chip({ value, label, tone = 'muted', details }) {
); );
} }
function StatChips({ category }) { function StatChips({ category }: { category: CatRow }) {
const names = billPreview(category.bill_names); const names = billPreview(category.bill_names);
return ( return (
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
@ -90,7 +143,7 @@ function StatChips({ category }) {
} }
function ChipLegend() { function ChipLegend() {
const items = [ const items: [string, string][] = [
['Active', 'active'], ['Active', 'active'],
['Inactive', 'muted'], ['Inactive', 'muted'],
['Payments', 'info'], ['Payments', 'info'],
@ -113,7 +166,7 @@ function ChipLegend() {
); );
} }
function StatusPill({ active }) { function StatusPill({ active }: { active?: number }) {
return ( return (
<span className={cn( <span className={cn(
'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide', 'inline-flex rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
@ -126,7 +179,7 @@ function StatusPill({ active }) {
); );
} }
function BillName({ bill }) { function BillName({ bill }: { bill: CategoryBill }) {
const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`; const label = `${bill.name}: due day ${bill.due_day}, ${fmt(bill.expected_amount)} expected`;
return ( return (
<Tooltip> <Tooltip>
@ -145,7 +198,7 @@ function BillName({ bill }) {
); );
} }
function ExpandedBills({ category }) { function ExpandedBills({ category }: { category: CatRow }) {
const bills = category.bills || []; const bills = category.bills || [];
if (!bills.length) { if (!bills.length) {
@ -230,30 +283,30 @@ function ExpandedBills({ category }) {
} }
export default function CategoriesPage() { export default function CategoriesPage() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState<CatRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState<string | null>(null);
const [newName, setNewName] = useState(''); const [newName, setNewName] = useState('');
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [expanded, setExpanded] = useState(() => new Set()); const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const [showEmptyCategories, setShowEmptyCategories] = useState(false); const [showEmptyCategories, setShowEmptyCategories] = useState(false);
const [draggingId, setDraggingId] = useState(null); const [draggingId, setDraggingId] = useState<number | null>(null);
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState<number | null>(null);
const [movingCategoryId, setMovingCategoryId] = useState(null); const [movingCategoryId, setMovingCategoryId] = useState<number | string | null>(null);
const addInputRef = useRef(null); const addInputRef = useRef<HTMLInputElement>(null);
const [renameTarget, setRenameTarget] = useState(null); const [renameTarget, setRenameTarget] = useState<CatRow | null>(null);
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState<CatRow | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoadError(null); setLoadError(null);
try { try {
const cats = await api.categories(); const cats = await api.categories() as CatRow[];
setCategories(cats); setCategories(cats);
} catch (err) { } catch (err) {
setLoadError(err.message || 'Failed to load categories'); setLoadError(errMessage(err, 'Failed to load categories'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -261,7 +314,7 @@ export default function CategoriesPage() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
function toggleCategory(id) { function toggleCategory(id: number) {
setExpanded(prev => { setExpanded(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
@ -270,7 +323,7 @@ export default function CategoriesPage() {
}); });
} }
async function handleAdd(e) { async function handleAdd(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const trimmed = newName.trim(); const trimmed = newName.trim();
if (!trimmed) { if (!trimmed) {
@ -287,18 +340,19 @@ export default function CategoriesPage() {
addInputRef.current?.focus(); addInputRef.current?.focus();
load(); load();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(errMessage(err, 'Failed to add category'));
} finally { } finally {
setAdding(false); setAdding(false);
} }
} }
function openRename(event, cat) { function openRename(event: React.MouseEvent, cat: CatRow) {
event.stopPropagation(); event.stopPropagation();
setRenameTarget(cat); setRenameTarget(cat);
} }
async function handleRename(name) { async function handleRename(name: string) {
if (!renameTarget) return;
setRenaming(true); setRenaming(true);
try { try {
await api.updateCategory(renameTarget.id, { name }); await api.updateCategory(renameTarget.id, { name });
@ -306,22 +360,23 @@ export default function CategoriesPage() {
setRenameTarget(null); setRenameTarget(null);
load(); load();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(errMessage(err, 'Failed to rename category'));
} finally { } finally {
setRenaming(false); setRenaming(false);
} }
} }
function openDelete(event, cat) { function openDelete(event: React.MouseEvent, cat: CatRow) {
event.stopPropagation(); event.stopPropagation();
setDeleteTarget(cat); setDeleteTarget(cat);
} }
async function handleDelete() { async function handleDelete() {
const category = deleteTarget;
if (!category) return;
setDeleting(true); setDeleting(true);
try { try {
const category = deleteTarget; await api.deleteCategory(category.id);
await api.deleteCategory(deleteTarget.id);
toast.success(`"${category.name}" moved to recovery`, { toast.success(`"${category.name}" moved to recovery`, {
description: 'Bills keep their category if you restore it within 30 days.', description: 'Bills keep their category if you restore it within 30 days.',
action: { action: {
@ -332,7 +387,7 @@ export default function CategoriesPage() {
toast.success(`"${category.name}" restored`); toast.success(`"${category.name}" restored`);
load(); load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to restore category'); toast.error(errMessage(err, 'Failed to restore category'));
} }
}, },
}, },
@ -345,7 +400,7 @@ export default function CategoriesPage() {
setDeleteTarget(null); setDeleteTarget(null);
load(); load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not delete category.'); toast.error(errMessage(err, 'Could not delete category.'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@ -360,7 +415,7 @@ export default function CategoriesPage() {
: categories.filter(cat => categoryBillCount(cat) > 0); : categories.filter(cat => categoryBillCount(cat) > 0);
const reorderEnabled = !loading && !loadError; const reorderEnabled = !loading && !loadError;
async function persistCategoryOrder(nextCategories, movedId) { async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) {
setCategories(nextCategories); setCategories(nextCategories);
setMovingCategoryId(movedId); setMovingCategoryId(movedId);
try { try {
@ -368,24 +423,24 @@ export default function CategoriesPage() {
toast.success('Category order saved'); toast.success('Category order saved');
load(); load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save category order'); toast.error(errMessage(err, 'Failed to save category order'));
load(); load();
} finally { } finally {
setMovingCategoryId(null); setMovingCategoryId(null);
} }
} }
function reorderVisibleCategories(orderedVisible) { function reorderVisibleCategories(orderedVisible: CatRow[]) {
if (!reorderEnabled) return; if (!reorderEnabled) return;
const visibleIds = new Set(visibleCategories.map(cat => cat.id)); const visibleIds = new Set(visibleCategories.map(cat => cat.id));
const replacements = [...orderedVisible]; const replacements = [...orderedVisible];
const nextCategories = categories.map(cat => ( const nextCategories = categories.map(cat => (
visibleIds.has(cat.id) ? replacements.shift() : cat visibleIds.has(cat.id) ? replacements.shift()! : cat
)); ));
persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible)); persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible));
} }
function categoryMoveControls(cat, index) { function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls {
return { return {
enabled: reorderEnabled, enabled: reorderEnabled,
moving: movingCategoryId === cat.id, moving: movingCategoryId === cat.id,
@ -402,7 +457,7 @@ export default function CategoriesPage() {
}; };
} }
function categoryDragProps(cat, index) { function categoryDragProps(cat: CatRow, index: number): CategoryDragProps {
if (!reorderEnabled) return { draggable: false }; if (!reorderEnabled) return { draggable: false };
return { return {
draggable: true, draggable: true,
@ -457,12 +512,12 @@ export default function CategoriesPage() {
<div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]"> <div className="grid gap-3 md:grid-cols-[1fr_minmax(20rem,26rem)]">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[ {([
['Categories', categories.length], ['Categories', categories.length],
['Active bills', activeBills], ['Active bills', activeBills],
['Inactive', inactiveBills], ['Inactive', inactiveBills],
['Payments', paymentCount], ['Payments', paymentCount],
].map(([label, value]) => ( ] as [string, number][]).map(([label, value]) => (
<div key={label} className="rounded-xl border border-border/70 bg-card/80 px-4 py-3 shadow-sm"> <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="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> <p className="tracker-number mt-1 text-xl font-bold text-foreground">{value}</p>
@ -652,10 +707,10 @@ export default function CategoriesPage() {
onClick={async (event) => { onClick={async (event) => {
event.stopPropagation(); event.stopPropagation();
try { try {
const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled); const result = await api.toggleCategorySpending(cat.id, !cat.spending_enabled) as { spending_enabled?: number | boolean };
setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c)); setCategories(prev => prev.map(c => c.id === cat.id ? { ...c, spending_enabled: result.spending_enabled } : c));
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update category'); toast.error(errMessage(err, 'Failed to update category'));
} }
}} }}
aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`} aria-label={cat.spending_enabled ? `Disable spending for ${cat.name}` : `Enable spending for ${cat.name}`}