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';
import { useState, useEffect } from 'react';
import { useState, useEffect, type ComponentType } from 'react';
import { toast } from 'sonner';
import { CheckCircle2, Circle, Loader2, AlertTriangle, CalendarDays } from 'lucide-react';
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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
interface StatusMeta {
label: string | null;
className: string;
icon: ComponentType<{ className?: string }> | null;
}
interface Candidate {
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_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 }) {
const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched;
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 label = candidate.status === 'matched_other_bill'
? `Matched to ${candidate.matched_bill_name || 'another bill'}`
@ -34,11 +50,19 @@ function StatusChip({ candidate }) {
// ── Main dialog ───────────────────────────────────────────────────────────────
export default function BillHistoricalImportDialog({ billId, billName, open, onClose, onImported }) {
const [step, setStep] = useState('choice'); // 'choice' | 'pick'
const [candidates, setCandidates] = useState([]);
interface BillHistoricalImportDialogProps {
billId: number;
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 [selected, setSelected] = useState(new Set());
const [selected, setSelected] = useState<Set<number | string>>(new Set());
const [importing, setImporting] = useState(false);
// Load candidates whenever the dialog opens
@ -48,11 +72,12 @@ export default function BillHistoricalImportDialog({ billId, billName, open, onC
setSelected(new Set());
setLoading(true);
api.merchantRuleCandidates(billId)
.then(data => {
.then(raw => {
const data = raw as { candidates?: Candidate[] };
// 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 || []);
setSelected(new Set(importable.map(c => c.id)));
setSelected(new Set(importableList.map(c => c.id)));
})
.catch(() => setCandidates([]))
.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 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; }
setImporting(true);
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}`);
onImported?.(result);
onClose();
} catch (err) {
toast.error(err.message || 'Import failed');
toast.error(errMessage(err, 'Import failed'));
} finally {
setImporting(false);
}
}
function toggleAll(checked) {
function toggleAll(checked: boolean) {
setSelected(checked ? new Set(importable.map(c => c.id)) : new Set());
}
function toggle(id) {
function toggle(id: number | string) {
setSelected(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;
});
}

View File

@ -1,11 +1,27 @@
import React from 'react';
import { ArrowDown, ArrowUp, Copy, GripVertical, PenLine, EyeOff, Eye, Clock, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { scheduleLabel } from '@/lib/billingSchedule';
import { formatUSDWhole } from '@/lib/money';
import { MobileBillRow } from '@/components/MobileBillRow';
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);
if (!d) return '—';
if (d > 3 && d < 21) return `${d}th`;
@ -17,11 +33,11 @@ function ordinal(n) {
}
}
function hasHistoricalVisibility(bill) {
return !!bill.has_history_ranges || (bill.history_visibility && bill.history_visibility !== 'default');
function hasHistoricalVisibility(bill: Bill): boolean {
return !!bill.has_history_ranges || (!!bill.history_visibility && bill.history_visibility !== 'default');
}
function AprColor({ rate }) {
function AprColor({ rate }: { rate: number }) {
const cls =
rate >= 25 ? 'text-rose-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>;
}
const ALL_ON = {
const ALL_ON: BillPrefs = {
showCategory: true, showDueDay: true, showAmount: true, showCycle: 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 hasHistory = hasHistoricalVisibility(bill);
return (
<div
draggable={dragProps?.draggable}
onDragStart={dragProps?.onDragStart}
onDragEnter={dragProps?.onDragEnter}
onDragOver={dragProps?.onDragOver}
onDragEnd={dragProps?.onDragEnd}
onDrop={dragProps?.onDrop}
onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
className={cn(
'group flex items-center gap-3 px-5 py-3.5 transition-colors',
'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 (
<>
<div className="hidden divide-y divide-border/30 sm:block">