BillTracker/client/pages/HealthPage.jsx

265 lines
10 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useState } 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 } from '@/lib/utils';
const FIELD_LABELS = {
due_day: 'Due day',
category_id: 'Category',
minimum_payment: 'Minimum payment',
autopay_enabled: 'Autopay details',
interest_rate: 'APR',
};
function severityClass(severity) {
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) {
return severity === 'error' ? 0 : 1;
}
function issueSummary(issues = []) {
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' }) {
return (
<div className={cn(
'rounded-xl border px-4 py-3 shadow-sm',
tone === 'error' && 'border-destructive/20 bg-destructive/10',
tone === 'warning' && 'border-amber-500/20 bg-amber-500/10',
tone === 'ok' && 'border-emerald-500/20 bg-emerald-500/10',
tone === 'default' && 'border-border/70 bg-card/80',
)}>
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-1 font-mono text-xl font-bold text-foreground">{value}</p>
</div>
);
}
function IssuePill({ issue }) {
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
severityClass(issue.severity),
)}>
{issue.severity}
</span>
);
}
function BillIssueCard({ bill, onOpenBill, openingBillId }) {
const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity));
const opening = openingBillId === bill.id;
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<CardTitle className="flex min-w-0 flex-wrap items-center gap-2 text-base">
<Receipt className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{bill.name}</span>
<span className="rounded-full border border-border bg-muted/55 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground">
{issueSummary(bill.issues)}
</span>
</CardTitle>
<CardDescription className="mt-1">
{bill.category_name || 'No category'} - {bill.due_day ? `Due day ${bill.due_day}` : 'No due day'}
{!bill.active && ' - Inactive'}
</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => onOpenBill(bill.id)}
disabled={!!openingBillId}
>
{opening && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{opening ? 'Opening...' : 'Open Bill'}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{sortedIssues.map(issue => (
<div key={`${bill.id}-${issue.field}`} className="rounded-lg border border-border/70 bg-background/65 p-3">
<div className="flex flex-wrap items-center gap-2">
<IssuePill issue={issue} />
<span className="text-sm font-semibold text-foreground">
{FIELD_LABELS[issue.field] || issue.field}
</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{issue.suggestion}</p>
</div>
))}
</CardContent>
</Card>
);
}
export default function HealthPage() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [includeInactive, setIncludeInactive] = useState(false);
const [categories, setCategories] = useState([]);
const [modalBill, setModalBill] = useState(null);
const [openingBillId, setOpeningBillId] = useState(null);
const load = useCallback(async () => {
setLoading(true);
try {
setData(await api.billAudit(includeInactive));
} catch (err) {
toast.error(err.message || 'Could not run bill health check.');
} finally {
setLoading(false);
}
}, [includeInactive]);
useEffect(() => { load(); }, [load]);
const openBill = useCallback(async (billId) => {
setOpeningBillId(billId);
try {
const [bill, cats] = await Promise.all([
api.bill(billId),
categories.length ? Promise.resolve(categories) : api.categories(),
]);
if (!categories.length) setCategories(cats);
setModalBill(bill);
} catch (err) {
toast.error(err.message || 'Could not open bill.');
} finally {
setOpeningBillId(null);
}
}, [categories]);
const handleBillSaved = useCallback(() => {
setModalBill(null);
load();
}, [load]);
const summary = data?.summary || {};
const bills = data?.bills || [];
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(() => {
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 (
<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">
<ClipboardCheck className="h-4 w-4 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight">Bill Health</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Find setup gaps before they skew tracker and snowball results.</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={includeInactive ? 'default' : 'outline'}
size="sm"
aria-pressed={includeInactive}
onClick={() => setIncludeInactive(value => !value)}
>
{includeInactive ? 'Including Inactive' : 'Active Bills Only'}
</Button>
<Button type="button" variant="outline" size="sm" onClick={load} disabled={loading}>
{loading ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1.5 h-3.5 w-3.5" />}
Refresh
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Audited bills" value={summary.audited_bills ?? '-'} />
<StatCard label="Issues" value={summary.issue_count ?? '-'} tone={healthTone} />
<StatCard label="Errors" value={summary.error_count ?? '-'} tone={(summary.error_count || 0) > 0 ? 'error' : 'default'} />
<StatCard label="Warnings" value={summary.warning_count ?? '-'} tone={(summary.warning_count || 0) > 0 ? 'warning' : 'default'} />
</div>
{loading ? (
<Card>
<CardContent className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Running health check...
</CardContent>
</Card>
) : !hasIssues ? (
<Card className="border-emerald-500/20 bg-emerald-500/5">
<CardContent className="flex flex-col items-center gap-3 px-4 py-16 text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-full border border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<p className="font-semibold text-foreground">No bill setup issues found.</p>
<p className="mt-1 text-sm text-muted-foreground">Your audited bills have the core fields needed for tracking and snowball projections.</p>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-xl border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p>Fix errors first; warnings are cleanup items that improve confidence and projections.</p>
</div>
<div className="grid gap-3">
{sortedBills.map(bill => (
<BillIssueCard
key={bill.id}
bill={bill}
onOpenBill={openBill}
openingBillId={openingBillId}
/>
))}
</div>
</div>
)}
{modalBill && (
<BillModal
bill={modalBill}
categories={categories}
onClose={() => setModalBill(null)}
onSave={handleBillSaved}
/>
)}
</div>
);
}