From 4d380c8da01d91c7b2e503e40b957459b6ffac1f Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 22:04:55 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20BillHistoricalImportDialog=20+?= =?UTF-8?q?=20BillsTableInner=20=E2=86=92=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branded Dollars on historical-import candidate amounts; typed BillPrefs + MoveControls/DragProps on the bills table. typecheck 0. --- ...log.jsx => BillHistoricalImportDialog.tsx} | 72 +++++++++++++------ ...illsTableInner.jsx => BillsTableInner.tsx} | 64 +++++++++++++---- 2 files changed, 101 insertions(+), 35 deletions(-) rename client/components/{BillHistoricalImportDialog.jsx => BillHistoricalImportDialog.tsx} (83%) rename client/components/{BillsTableInner.jsx => BillsTableInner.tsx} (84%) diff --git a/client/components/BillHistoricalImportDialog.jsx b/client/components/BillHistoricalImportDialog.tsx similarity index 83% rename from client/components/BillHistoricalImportDialog.jsx rename to client/components/BillHistoricalImportDialog.tsx index 8e97db3..e931a69 100644 --- a/client/components/BillHistoricalImportDialog.jsx +++ b/client/components/BillHistoricalImportDialog.tsx @@ -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'; -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 }, -}; +interface StatusMeta { + label: string | null; + className: string; + icon: ComponentType<{ className?: string }> | null; +} -function StatusChip({ candidate }) { - const meta = STATUS_META[candidate.status] ?? STATUS_META.unmatched; +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_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; + +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([]); const [loading, setLoading] = useState(true); - const [selected, setSelected] = useState(new Set()); + const [selected, setSelected] = useState>(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; }); } diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.tsx similarity index 84% rename from client/components/BillsTableInner.jsx rename to client/components/BillsTableInner.tsx index dd19edc..85f75a5 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.tsx @@ -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 {rate}% APR; } -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 (
['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 ( <>