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:
parent
57cac30029
commit
2f4f76db83
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue