269 lines
10 KiB
JavaScript
269 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';
|
|
import { makeBillDraft } from '@/lib/billDrafts';
|
|
|
|
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-normal text-muted-foreground">{label}</p>
|
|
<p className="tracker-number mt-1 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 [modal, setModal] = 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);
|
|
setModal({ bill });
|
|
} catch (err) {
|
|
toast.error(err.message || 'Could not open bill.');
|
|
} finally {
|
|
setOpeningBillId(null);
|
|
}
|
|
}, [categories]);
|
|
|
|
const handleBillSaved = useCallback(() => {
|
|
setModal(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>
|
|
)}
|
|
|
|
{modal && (
|
|
<BillModal
|
|
key={modal.bill?.id ? `edit-${modal.bill.id}` : `new-${modal.initialBill?.name || 'blank'}`}
|
|
bill={modal.bill}
|
|
initialBill={modal.initialBill}
|
|
categories={categories}
|
|
onClose={() => setModal(null)}
|
|
onSave={handleBillSaved}
|
|
onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|