refactor(ts): BillHistoricalImportDialog + BillsTableInner → TypeScript

Branded Dollars on historical-import candidate amounts; typed BillPrefs +
MoveControls/DragProps on the bills table. typecheck 0.
This commit is contained in:
null 2026-07-04 22:04:55 -05:00
parent 41e847a33d
commit 4d380c8da0
2 changed files with 101 additions and 35 deletions

View File

@ -1,24 +1,40 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, type ComponentType } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react'; import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate, errMessage } from '@/lib/utils';
import type { Dollars } from '@/lib/money';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
const STATUS_META = { interface StatusMeta {
unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null }, label: string | null;
matched_this_bill:{ label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, className: string;
matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle }, icon: ComponentType<{ className?: string }> | null;
payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 }, }
};
function StatusChip({ candidate }) { interface Candidate {
const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched; id: number | string;
status?: string;
payee?: string | null;
paid_date?: string | null;
amount: Dollars;
matched_bill_name?: string | null;
}
const STATUS_META = {
unmatched: { label: 'Unmatched', className: 'text-muted-foreground', icon: null },
matched_this_bill: { label: 'Already linked', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
matched_other_bill:{ label: null, className: 'text-amber-600 dark:text-amber-400', icon: AlertTriangle },
payment_exists: { label: 'Payment exists', className: 'text-emerald-600 dark:text-emerald-400', icon: CheckCircle2 },
} satisfies Record<string, StatusMeta>;
function StatusChip({ candidate }: { candidate: Candidate }) {
const meta: StatusMeta = STATUS_META[candidate.status as keyof typeof STATUS_META] ?? STATUS_META.unmatched;
const Icon = meta.icon; const Icon = meta.icon;
const label = candidate.status === 'matched_other_bill' const label = candidate.status === 'matched_other_bill'
? `Matched to ${candidate.matched_bill_name || 'another bill'}` ? `Matched to ${candidate.matched_bill_name || 'another bill'}`
@ -34,11 +50,19 @@ function StatusChip({ candidate }) {
// ── Main dialog ─────────────────────────────────────────────────────────────── // ── Main dialog ───────────────────────────────────────────────────────────────
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) { interface BillHistoricalImportDialogProps {
const [step, setStep] = useState('choice'); // 'choice' | 'pick' billId: number;
const [candidates, setCandidates] = useState([]); billName: string;
open: boolean;
onClose: () => void;
onImported?: (result: { imported?: number }) => void;
}
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }: BillHistoricalImportDialogProps) {
const [step, setStep] = useState<'choice' | 'pick'>('choice');
const [candidates, setCandidates] = useState<Candidate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(new Set()); const [selected, setSelected] = useState<Set<number | string>>(new Set());
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
// Load candidates whenever the dialog opens // Load candidates whenever the dialog opens
@ -48,11 +72,12 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
setSelected(new Set()); setSelected(new Set());
setLoading(true); setLoading(true);
api.merchantRuleCandidates(billId) api.merchantRuleCandidates(billId)
.then(data => { .then(raw => {
const data = raw as { candidates?: Candidate[] };
// Pre-select importable candidates (not already a payment for this bill) // Pre-select importable candidates (not already a payment for this bill)
const importable = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); const importableList = (data.candidates || []).filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
setCandidates(data.candidates || []); setCandidates(data.candidates || []);
setSelected(new Set(importable.map(c => c.id))); setSelected(new Set(importableList.map(c => c.id)));
}) })
.catch(() => setCandidates([])) .catch(() => setCandidates([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -61,29 +86,30 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill'); const importable = candidates.filter(c => c.status !== 'payment_exists' && c.status !== 'matched_this_bill');
const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill'); const alreadyDone = candidates.filter(c => c.status === 'payment_exists' || c.status === 'matched_this_bill');
async function doImport(ids) { async function doImport(ids: (number | string)[]) {
if (ids.length === 0) { onClose(); return; } if (ids.length === 0) { onClose(); return; }
setImporting(true); setImporting(true);
try { try {
const result = await api.importHistoricalPayments(billId, ids); const result = await api.importHistoricalPayments(billId, ids) as { imported?: number };
toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`); toast.success(`${result.imported} payment${result.imported === 1 ? '' : 's'} imported for ${billName}`);
onImported?.(result); onImported?.(result);
onClose(); onClose();
} catch (err) { } catch (err) {
toast.error(err.message || 'Import failed'); toast.error(errMessage(err, 'Import failed'));
} finally { } finally {
setImporting(false); setImporting(false);
} }
} }
function toggleAll(checked) { function toggleAll(checked: boolean) {
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set()); setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
} }
function toggle(id) { function toggle(id: number | string) {
setSelected(prev => { setSelected(prev => {
const next = new Set(prev); const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id); if (next.has(id)) next.delete(id);
else next.add(id);
return next; return next;
}); });
} }

View File

@ -1,11 +1,27 @@
import React from 'react';
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule'; import { scheduleLabel } from '@/lib/billingSchedule';
import { formatUSDWhole } from '@/lib/money'; import { formatUSDWhole } from '@/lib/money';
import { MobileBillRow } from '@/components/MobileBillRow'; import { MobileBillRow } from '@/components/MobileBillRow';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { Bill } from '@/types';
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
function ordinal(n) { interface BillPrefs {
showCategory?: boolean;
showDueDay?: boolean;
showAmount?: boolean;
showCycle?: boolean;
showApr?: boolean;
showBalance?: boolean;
showMinPayment?: boolean;
showAutopay?: boolean;
show2fa?: boolean;
showSubscription?: boolean;
}
function ordinal(n: number | null | undefined): string {
const d = Number(n); const d = Number(n);
if (!d) return '—'; if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`; if (d > 3 && d < 21) return `${d}th`;
@ -17,11 +33,11 @@ function ordinal(n) {
} }
} }
function hasHistoricalVisibility(bill) { function hasHistoricalVisibility(bill: Bill): boolean {
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default'); return !!bill.has_history_ranges || (!!bill.history_visibility && bill.history_visibility !== 'default');
} }
function AprColor({ rate }) { function AprColor({ rate }: { rate: number }) {
const cls = const cls =
rate >= 25 ? 'text-rose-400' : rate >= 25 ? 'text-rose-400' :
rate >= 15 ? 'text-amber-400' : rate >= 15 ? 'text-amber-400' :
@ -29,23 +45,35 @@ function AprColor({ rate }) {
return <span className={cn('tracker-number text-[11px] font-semibold', cls)}>{rate}% APR</span>; return <span className={cn('tracker-number text-[11px] font-semibold', cls)}>{rate}% APR</span>;
} }
const ALL_ON = { const ALL_ON: BillPrefs = {
showCategory: true, showDueDay: true, showAmount: true, showCycle: true, showCategory: true, showDueDay: true, showAmount: true, showCycle: true,
showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true, showApr: true, showBalance: true, showMinPayment: true, showAutopay: true, show2fa: true,
}; };
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) { interface BillCardProps {
bill: Bill;
prefs?: BillPrefs;
onEdit?: (id: number) => void;
onToggle?: (bill: Bill) => void;
onDelete?: (bill: Bill) => void;
onHistory?: (bill: Bill) => void;
onDuplicate?: (bill: Bill) => void;
moveControls?: MoveControls;
dragProps?: DragProps;
}
function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: BillCardProps) {
const isDebt = bill.current_balance != null || bill.minimum_payment != null; const isDebt = bill.current_balance != null || bill.minimum_payment != null;
const hasHistory = hasHistoricalVisibility(bill); const hasHistory = hasHistoricalVisibility(bill);
return ( return (
<div <div
draggable={dragProps?.draggable} draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart} onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
onDragEnter={dragProps?.onDragEnter} onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
onDragOver={dragProps?.onDragOver} onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
onDragEnd={dragProps?.onDragEnd} onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
onDrop={dragProps?.onDrop} onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
className={cn( className={cn(
'group flex items-center gap-3 px-5 py-3.5 transition-colors', 'group flex items-center gap-3 px-5 py-3.5 transition-colors',
'hover:bg-accent/20', 'hover:bg-accent/20',
@ -260,7 +288,19 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
); );
} }
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }) { interface BillsTableInnerProps {
bills: Bill[];
prefs?: BillPrefs;
onEdit?: (id: number) => void;
onToggle?: (bill: Bill) => void;
onDelete?: (bill: Bill) => void;
onHistory?: (bill: Bill) => void;
onDuplicate?: (bill: Bill) => void;
moveControlsFor?: (bill: Bill, index: number) => MoveControls | undefined;
dragPropsFor?: (bill: Bill, index: number) => DragProps | undefined;
}
export default function BillsTableInner({ bills, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControlsFor, dragPropsFor }: BillsTableInnerProps) {
return ( return (
<> <>
<div className="hidden divide-y divide-border/30 sm:block"> <div className="hidden divide-y divide-border/30 sm:block">