diff --git a/client/pages/CategoriesPage.jsx b/client/pages/CategoriesPage.tsx similarity index 87% rename from client/pages/CategoriesPage.jsx rename to client/pages/CategoriesPage.tsx index e5e2c35..832d702 100644 --- a/client/pages/CategoriesPage.jsx +++ b/client/pages/CategoriesPage.tsx @@ -15,30 +15,83 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } 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'; -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; + onDragEnter?: React.DragEventHandler; + onDragOver?: React.DragEventHandler; + onDragEnd?: React.DragEventHandler; + onDrop?: React.DragEventHandler; +} + +function plural(count: number, label: string): string { return `${count} ${label}${count === 1 ? '' : 's'}`; } -function billPreview(names = []) { +function billPreview(names: string[] = []): string { 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 categoryBillCount(category) { +function categoryBillCount(category: CatRow): number { return (category.active_bill_count || 0) + (category.inactive_bill_count || 0); } -function Chip({ value, label, tone = 'muted', details }) { - const toneClass = { +function Chip({ value, label, tone = 'muted', details }: { + value: React.ReactNode; + label: string; + tone?: string; + details?: string; +}) { + 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]; + } as Record)[tone]; return ( @@ -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); return (
@@ -90,7 +143,7 @@ function StatChips({ category }) { } function ChipLegend() { - const items = [ + const items: [string, string][] = [ ['Active', 'active'], ['Inactive', 'muted'], ['Payments', 'info'], @@ -113,7 +166,7 @@ function ChipLegend() { ); } -function StatusPill({ active }) { +function StatusPill({ active }: { active?: number }) { return ( @@ -145,7 +198,7 @@ function BillName({ bill }) { ); } -function ExpandedBills({ category }) { +function ExpandedBills({ category }: { category: CatRow }) { const bills = category.bills || []; if (!bills.length) { @@ -230,30 +283,30 @@ function ExpandedBills({ category }) { } export default function CategoriesPage() { - const [categories, setCategories] = useState([]); + const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); + const [loadError, setLoadError] = useState(null); const [newName, setNewName] = useState(''); const [adding, setAdding] = useState(false); - const [expanded, setExpanded] = useState(() => new Set()); + const [expanded, setExpanded] = useState>(() => new Set()); const [showEmptyCategories, setShowEmptyCategories] = useState(false); - const [draggingId, setDraggingId] = useState(null); - const [dropTargetId, setDropTargetId] = useState(null); - const [movingCategoryId, setMovingCategoryId] = useState(null); - const addInputRef = useRef(null); + const [draggingId, setDraggingId] = useState(null); + const [dropTargetId, setDropTargetId] = useState(null); + const [movingCategoryId, setMovingCategoryId] = useState(null); + const addInputRef = useRef(null); - const [renameTarget, setRenameTarget] = useState(null); + const [renameTarget, setRenameTarget] = useState(null); const [renaming, setRenaming] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const load = useCallback(async () => { setLoadError(null); try { - const cats = await api.categories(); + const cats = await api.categories() as CatRow[]; setCategories(cats); } catch (err) { - setLoadError(err.message || 'Failed to load categories'); + setLoadError(errMessage(err, 'Failed to load categories')); } finally { setLoading(false); } @@ -261,7 +314,7 @@ export default function CategoriesPage() { useEffect(() => { load(); }, [load]); - function toggleCategory(id) { + function toggleCategory(id: number) { setExpanded(prev => { const next = new Set(prev); 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(); const trimmed = newName.trim(); if (!trimmed) { @@ -287,18 +340,19 @@ export default function CategoriesPage() { addInputRef.current?.focus(); load(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err, 'Failed to add category')); } finally { setAdding(false); } } - function openRename(event, cat) { + function openRename(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setRenameTarget(cat); } - async function handleRename(name) { + async function handleRename(name: string) { + if (!renameTarget) return; setRenaming(true); try { await api.updateCategory(renameTarget.id, { name }); @@ -306,22 +360,23 @@ export default function CategoriesPage() { setRenameTarget(null); load(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err, 'Failed to rename category')); } finally { setRenaming(false); } } - function openDelete(event, cat) { + function openDelete(event: React.MouseEvent, cat: CatRow) { event.stopPropagation(); setDeleteTarget(cat); } async function handleDelete() { + const category = deleteTarget; + if (!category) return; setDeleting(true); try { - const category = deleteTarget; - await api.deleteCategory(deleteTarget.id); + await api.deleteCategory(category.id); toast.success(`"${category.name}" moved to recovery`, { description: 'Bills keep their category if you restore it within 30 days.', action: { @@ -332,7 +387,7 @@ export default function CategoriesPage() { toast.success(`"${category.name}" restored`); load(); } 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); load(); } catch (err) { - toast.error(err.message || 'Could not delete category.'); + toast.error(errMessage(err, 'Could not delete category.')); } finally { setDeleting(false); } @@ -360,7 +415,7 @@ export default function CategoriesPage() { : categories.filter(cat => categoryBillCount(cat) > 0); const reorderEnabled = !loading && !loadError; - async function persistCategoryOrder(nextCategories, movedId) { + async function persistCategoryOrder(nextCategories: CatRow[], movedId: number | string | null) { setCategories(nextCategories); setMovingCategoryId(movedId); try { @@ -368,24 +423,24 @@ export default function CategoriesPage() { toast.success('Category order saved'); load(); } catch (err) { - toast.error(err.message || 'Failed to save category order'); + toast.error(errMessage(err, 'Failed to save category order')); load(); } finally { setMovingCategoryId(null); } } - function reorderVisibleCategories(orderedVisible) { + function reorderVisibleCategories(orderedVisible: CatRow[]) { 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 + visibleIds.has(cat.id) ? replacements.shift()! : cat )); persistCategoryOrder(nextCategories, movedItemId(visibleCategories, orderedVisible)); } - function categoryMoveControls(cat, index) { + function categoryMoveControls(cat: CatRow, index: number): CategoryMoveControls { return { enabled: reorderEnabled, 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 }; return { draggable: true, @@ -457,12 +512,12 @@ export default function CategoriesPage() {
- {[ + {([ ['Categories', categories.length], ['Active bills', activeBills], ['Inactive', inactiveBills], ['Payments', paymentCount], - ].map(([label, value]) => ( + ] as [string, number][]).map(([label, value]) => (

{label}

{value}

@@ -652,10 +707,10 @@ export default function CategoriesPage() { onClick={async (event) => { event.stopPropagation(); 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)); } 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}`}