import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, CheckCircle2, ClipboardCheck, Loader2, RefreshCw, Receipt, } from 'lucide-react'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import BillModal from '@/components/BillModal'; import { cn, errMessage } from '@/lib/utils'; import { makeBillDraft } from '@/lib/billDrafts'; import type { Bill, Category } from '@/types'; const FIELD_LABELS: Record = { due_day: 'Due day', category_id: 'Category', minimum_payment: 'Minimum payment', autopay_enabled: 'Autopay details', interest_rate: 'APR', }; interface Issue { severity: string; field: string; suggestion?: string; } interface HealthBill { id: number; name: string; category_name?: string | null; due_day?: number | null; active?: number; issues: Issue[]; } interface HealthSummary { audited_bills?: number; issue_count?: number; error_count?: number; warning_count?: number; } interface HealthData { summary?: HealthSummary; bills?: HealthBill[]; } interface ModalState { bill?: Bill | null; initialBill?: Partial; } function severityClass(severity: string): string { return severity === 'error' ? 'border-destructive/25 bg-destructive/10 text-destructive' : 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300'; } function severityWeight(severity: string): number { return severity === 'error' ? 0 : 1; } function issueSummary(issues: Issue[] = []): string { const errors = issues.filter(issue => issue.severity === 'error').length; const warnings = issues.length - errors; if (errors && warnings) return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`; if (errors) return `${errors} error${errors === 1 ? '' : 's'}`; return `${warnings} warning${warnings === 1 ? '' : 's'}`; } function StatCard({ label, value, tone = 'default' }: { label: string; value: ReactNode; tone?: 'default' | 'error' | 'warning' | 'ok'; }) { return (

{label}

{value}

); } function IssuePill({ issue }: { issue: Issue }) { return ( {issue.severity} ); } function BillIssueCard({ bill, onOpenBill, openingBillId }: { bill: HealthBill; onOpenBill: (id: number) => void; openingBillId: number | null; }) { const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity)); const opening = openingBillId === bill.id; return (
{bill.name} {issueSummary(bill.issues)} {bill.category_name || 'No category'} - {bill.due_day ? `Due day ${bill.due_day}` : 'No due day'} {!bill.active && ' - Inactive'}
{sortedIssues.map(issue => (
{FIELD_LABELS[issue.field] || issue.field}

{issue.suggestion}

))}
); } export default function HealthPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [includeInactive, setIncludeInactive] = useState(false); const [categories, setCategories] = useState([]); const [modal, setModal] = useState(null); const [openingBillId, setOpeningBillId] = useState(null); const load = useCallback(async () => { setLoading(true); try { setData(await api.billAudit(includeInactive) as HealthData); } catch (err) { toast.error(errMessage(err, 'Could not run bill health check.')); } finally { setLoading(false); } }, [includeInactive]); useEffect(() => { load(); }, [load]); const openBill = useCallback(async (billId: number) => { setOpeningBillId(billId); try { const [bill, cats] = await Promise.all([ api.bill(billId) as Promise, (categories.length ? Promise.resolve(categories) : api.categories()) as Promise, ]); if (!categories.length) setCategories(cats); setModal({ bill }); } catch (err) { toast.error(errMessage(err, 'Could not open bill.')); } finally { setOpeningBillId(null); } }, [categories]); const handleBillSaved = useCallback(() => { setModal(null); load(); }, [load]); const summary = data?.summary || {}; const bills = useMemo(() => data?.bills || [], [data]); const sortedBills = useMemo(() => [...bills].sort((a, b) => { const aErrors = a.issues.filter(issue => issue.severity === 'error').length; const bErrors = b.issues.filter(issue => issue.severity === 'error').length; if (aErrors !== bErrors) return bErrors - aErrors; if (a.issues.length !== b.issues.length) return b.issues.length - a.issues.length; return a.name.localeCompare(b.name); }), [bills]); const hasIssues = sortedBills.length > 0; const healthTone = useMemo<'error' | 'warning' | 'ok'>(() => { if ((summary.error_count || 0) > 0) return 'error'; if ((summary.warning_count || 0) > 0) return 'warning'; return 'ok'; }, [summary.error_count, summary.warning_count]); return (

Bill Health

Find setup gaps before they skew tracker and snowball results.

0 ? 'error' : 'default'} /> 0 ? 'warning' : 'default'} />
{loading ? ( Running health check... ) : !hasIssues ? (

No bill setup issues found.

Your audited bills have the core fields needed for tracking and snowball projections.

) : (

Fix errors first; warnings are cleanup items that improve confidence and projections.

{sortedBills.map(bill => ( ))}
)} {modal && ( setModal(null)} onSave={handleBillSaved} onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial })} /> )}
); }