From 2f4f76db83aa2d345c6bab2d492ae6100a379274 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 22:19:39 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20HealthPage=20=E2=86=92=20TypeSc?= =?UTF-8?q?ript=20+=20export=20makeBillDraft's=20SourceBill=20return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotated makeBillDraft's return as SourceBill (index-signatured) and exported it, so its draft output stays typed; BillModal keeps initialBill: Partial and callers cast makeBillDraft(...) as Partial at the boundary. typecheck 0. --- client/lib/billDrafts.ts | 7 +- .../pages/{HealthPage.jsx => HealthPage.tsx} | 83 ++++++++++++++----- 2 files changed, 67 insertions(+), 23 deletions(-) rename client/pages/{HealthPage.jsx => HealthPage.tsx} (82%) diff --git a/client/lib/billDrafts.ts b/client/lib/billDrafts.ts index fe26304..5781f74 100644 --- a/client/lib/billDrafts.ts +++ b/client/lib/billDrafts.ts @@ -12,7 +12,7 @@ interface Template { // The source bill a draft is seeded from. The specific fields the draft reads // are declared; the index signature carries every other field through the spread. -interface SourceBill { +export interface SourceBill { id?: string | number | null; name?: string | null; category_id?: string | number | null; @@ -56,7 +56,7 @@ function categoryIdOrFallback( return template ? categoryForTemplate(template, categories) : null; } -export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}) { +export function makeBillDraft(source: SourceBill | null | undefined, { copy = false, template = null, categories = [] }: MakeBillDraftOptions = {}): SourceBill { const data: SourceBill = source || {}; return { ...data, @@ -80,3 +80,6 @@ export function makeBillDraft(source: SourceBill | null | undefined, { copy = fa snowball_exempt: !!data.snowball_exempt, }; } + +/** The pre-filled draft object produced by makeBillDraft (fed to BillModal's `initialBill`). */ +export type BillDraft = ReturnType; diff --git a/client/pages/HealthPage.jsx b/client/pages/HealthPage.tsx similarity index 82% rename from client/pages/HealthPage.jsx rename to client/pages/HealthPage.tsx index 1bb09f9..9c84dab 100644 --- a/client/pages/HealthPage.jsx +++ b/client/pages/HealthPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, @@ -12,10 +12,11 @@ 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 { cn, errMessage } from '@/lib/utils'; import { makeBillDraft } from '@/lib/billDrafts'; +import type { Bill, Category } from '@/types'; -const FIELD_LABELS = { +const FIELD_LABELS: Record = { due_day: 'Due day', category_id: 'Category', minimum_payment: 'Minimum payment', @@ -23,17 +24,49 @@ const FIELD_LABELS = { interest_rate: 'APR', }; -function severityClass(severity) { +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) { +function severityWeight(severity: string): number { return severity === 'error' ? 0 : 1; } -function issueSummary(issues = []) { +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'}`; @@ -41,7 +74,11 @@ function issueSummary(issues = []) { return `${warnings} warning${warnings === 1 ? '' : 's'}`; } -function StatCard({ label, value, tone = 'default' }) { +function StatCard({ label, value, tone = 'default' }: { + label: string; + value: ReactNode; + tone?: 'default' | 'error' | 'warning' | 'ok'; +}) { return (
void; + openingBillId: number | null; +}) { const sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity)); const opening = openingBillId === bill.id; @@ -119,19 +160,19 @@ function BillIssueCard({ bill, onOpenBill, openingBillId }) { } export default function HealthPage() { - const [data, setData] = useState(null); + 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 [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)); + setData(await api.billAudit(includeInactive) as HealthData); } catch (err) { - toast.error(err.message || 'Could not run bill health check.'); + toast.error(errMessage(err, 'Could not run bill health check.')); } finally { setLoading(false); } @@ -139,17 +180,17 @@ export default function HealthPage() { useEffect(() => { load(); }, [load]); - const openBill = useCallback(async (billId) => { + const openBill = useCallback(async (billId: number) => { setOpeningBillId(billId); try { const [bill, cats] = await Promise.all([ - api.bill(billId), - categories.length ? Promise.resolve(categories) : api.categories(), + 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(err.message || 'Could not open bill.'); + toast.error(errMessage(err, 'Could not open bill.')); } finally { setOpeningBillId(null); } @@ -170,7 +211,7 @@ export default function HealthPage() { return a.name.localeCompare(b.name); }), [bills]); const hasIssues = sortedBills.length > 0; - const healthTone = useMemo(() => { + const healthTone = useMemo<'error' | 'warning' | 'ok'>(() => { if ((summary.error_count || 0) > 0) return 'error'; if ((summary.warning_count || 0) > 0) return 'warning'; return 'ok'; @@ -260,7 +301,7 @@ export default function HealthPage() { categories={categories} onClose={() => setModal(null)} onSave={handleBillSaved} - onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) })} + onDuplicate={bill => setModal({ bill: null, initialBill: makeBillDraft(bill, { copy: true, categories }) as Partial })} /> )}