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
|
||||
// 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<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 {
|
||||
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<string, string> = {
|
||||
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<Bill>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn(
|
||||
'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 (
|
||||
<span className={cn(
|
||||
'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 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<HealthData | null>(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<Category[]>([]);
|
||||
const [modal, setModal] = useState<ModalState | null>(null);
|
||||
const [openingBillId, setOpeningBillId] = useState<number | null>(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<Bill>,
|
||||
(categories.length ? Promise.resolve(categories) : api.categories()) as Promise<Category[]>,
|
||||
]);
|
||||
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<Bill> })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
Loading…
Reference in New Issue