refactor(ts): HealthPage → TypeScript + export makeBillDraft's SourceBill return

Annotated makeBillDraft's return as SourceBill (index-signatured) and exported it,
so its draft output stays typed; BillModal keeps initialBill: Partial<Bill> and
callers cast makeBillDraft(...) as Partial<Bill> at the boundary. typecheck 0.
This commit is contained in:
null 2026-07-04 22:19:39 -05:00
parent 57cac30029
commit 2f4f76db83
2 changed files with 67 additions and 23 deletions

View File

@ -12,7 +12,7 @@ interface Template {
// The source bill a draft is seeded from. The specific fields the draft reads // 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. // are declared; the index signature carries every other field through the spread.
interface SourceBill { export interface SourceBill {
id?: string | number | null; id?: string | number | null;
name?: string | null; name?: string | null;
category_id?: string | number | null; category_id?: string | number | null;
@ -56,7 +56,7 @@ function categoryIdOrFallback(
return template ? categoryForTemplate(template, categories) : null; 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 || {}; const data: SourceBill = source || {};
return { return {
...data, ...data,
@ -80,3 +80,6 @@ export function makeBillDraft(source: SourceBill | null | undefined, { copy = fa
snowball_exempt: !!data.snowball_exempt, snowball_exempt: !!data.snowball_exempt,
}; };
} }
/** The pre-filled draft object produced by makeBillDraft (fed to BillModal's `initialBill`). */
export type BillDraft = ReturnType<typeof makeBillDraft>;

View File

@ -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 { toast } from 'sonner';
import { import {
AlertTriangle, AlertTriangle,
@ -12,10 +12,11 @@ import { api } from '@/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import BillModal from '@/components/BillModal'; import BillModal from '@/components/BillModal';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { makeBillDraft } from '@/lib/billDrafts'; import { makeBillDraft } from '@/lib/billDrafts';
import type { Bill, Category } from '@/types';
const FIELD_LABELS = { const FIELD_LABELS: Record<string, string> = {
due_day: 'Due day', due_day: 'Due day',
category_id: 'Category', category_id: 'Category',
minimum_payment: 'Minimum payment', minimum_payment: 'Minimum payment',
@ -23,17 +24,49 @@ const FIELD_LABELS = {
interest_rate: 'APR', 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<Bill>;
}
function severityClass(severity: string): string {
return severity === 'error' return severity === 'error'
? 'border-destructive/25 bg-destructive/10 text-destructive' ? 'border-destructive/25 bg-destructive/10 text-destructive'
: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300'; : '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; return severity === 'error' ? 0 : 1;
} }
function issueSummary(issues = []) { function issueSummary(issues: Issue[] = []): string {
const errors = issues.filter(issue => issue.severity === 'error').length; const errors = issues.filter(issue => issue.severity === 'error').length;
const warnings = issues.length - errors; const warnings = issues.length - errors;
if (errors && warnings) return `${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'}`; 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'}`; 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 ( return (
<div className={cn( <div className={cn(
'rounded-xl border px-4 py-3 shadow-sm', 'rounded-xl border px-4 py-3 shadow-sm',
@ -56,7 +93,7 @@ function StatCard({ label, value, tone = 'default' }) {
); );
} }
function IssuePill({ issue }) { function IssuePill({ issue }: { issue: Issue }) {
return ( return (
<span className={cn( <span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide', 'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
@ -67,7 +104,11 @@ function IssuePill({ issue }) {
); );
} }
function BillIssueCard({ bill, onOpenBill, openingBillId }) { 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 sortedIssues = [...bill.issues].sort((a, b) => severityWeight(a.severity) - severityWeight(b.severity));
const opening = openingBillId === bill.id; const opening = openingBillId === bill.id;
@ -119,19 +160,19 @@ function BillIssueCard({ bill, onOpenBill, openingBillId }) {
} }
export default function HealthPage() { export default function HealthPage() {
const [data, setData] = useState(null); const [data, setData] = useState<HealthData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [includeInactive, setIncludeInactive] = useState(false); const [includeInactive, setIncludeInactive] = useState(false);
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState<Category[]>([]);
const [modal, setModal] = useState(null); const [modal, setModal] = useState<ModalState | null>(null);
const [openingBillId, setOpeningBillId] = useState(null); const [openingBillId, setOpeningBillId] = useState<number | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
setData(await api.billAudit(includeInactive)); setData(await api.billAudit(includeInactive) as HealthData);
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not run bill health check.'); toast.error(errMessage(err, 'Could not run bill health check.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -139,17 +180,17 @@ export default function HealthPage() {
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
const openBill = useCallback(async (billId) => { const openBill = useCallback(async (billId: number) => {
setOpeningBillId(billId); setOpeningBillId(billId);
try { try {
const [bill, cats] = await Promise.all([ const [bill, cats] = await Promise.all([
api.bill(billId), api.bill(billId) as Promise<Bill>,
categories.length ? Promise.resolve(categories) : api.categories(), (categories.length ? Promise.resolve(categories) : api.categories()) as Promise<Category[]>,
]); ]);
if (!categories.length) setCategories(cats); if (!categories.length) setCategories(cats);
setModal({ bill }); setModal({ bill });
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not open bill.'); toast.error(errMessage(err, 'Could not open bill.'));
} finally { } finally {
setOpeningBillId(null); setOpeningBillId(null);
} }
@ -170,7 +211,7 @@ export default function HealthPage() {
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}), [bills]); }), [bills]);
const hasIssues = sortedBills.length > 0; const hasIssues = sortedBills.length > 0;
const healthTone = useMemo(() => { const healthTone = useMemo<'error' | 'warning' | 'ok'>(() => {
if ((summary.error_count || 0) > 0) return 'error'; if ((summary.error_count || 0) > 0) return 'error';
if ((summary.warning_count || 0) > 0) return 'warning'; if ((summary.warning_count || 0) > 0) return 'warning';
return 'ok'; return 'ok';
@ -260,7 +301,7 @@ export default function HealthPage() {
categories={categories} categories={categories}
onClose={() => setModal(null)} onClose={() => setModal(null)}
onSave={handleBillSaved} 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<Bill> })}
/> />
)} )}
</div> </div>