refactor(ts): convert bill-modal sub-components to TSX (B10)

The 7 BillModal sub-components + transactionDisplay helper: TemplateSection,
AutopayTrustIndicator, PaymentFormFields, PaymentHistoryList, DebtDetailsSection,
LinkedTransactionsSection, UnmatchDialogs. New @/types.BankTransaction — raw bank
amounts stay in CENTS on the wire, so branded `Cents` (formatCentsUSD, not
formatUSD), the complement to the Dollars brand. BulkUnmatchState typed with
null-safe setState callbacks. typecheck 0, lint 0 errors, build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 20:03:09 -05:00
parent 8a4b51915f
commit 31ff6adbd9
9 changed files with 170 additions and 36 deletions

View File

@ -1,9 +1,18 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { AutopayStats } from '@/types';
interface AutopayTrustIndicatorProps {
isNew?: boolean;
autopay?: boolean;
stats?: AutopayStats | null;
verifiedAt?: Date | null;
onVerify?: () => void;
}
// Edit-mode autopay trust panel: success rate over the last 12 months, a // Edit-mode autopay trust panel: success rate over the last 12 months, a
// "Mark verified" action, and staleness / failure warnings. Presentational — // "Mark verified" action, and staleness / failure warnings. Presentational —
// the parent owns the verify handler and the optimistic verified-at date. // the parent owns the verify handler and the optimistic verified-at date.
export default function AutopayTrustIndicator({ isNew, autopay, stats, verifiedAt, onVerify }) { export default function AutopayTrustIndicator({ isNew, autopay, stats, verifiedAt, onVerify }: AutopayTrustIndicatorProps) {
if (isNew || !autopay) return null; if (isNew || !autopay) return null;
const total = stats?.total ?? 0; const total = stats?.total ?? 0;

View File

@ -2,6 +2,28 @@ import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import type { Dispatch, SetStateAction } from 'react';
type FormErrors = Record<string, string | null | undefined>;
type Validator = (v: string) => string | null;
interface DebtDetailsSectionProps {
inp?: string;
errors: FormErrors;
setErrors: Dispatch<SetStateAction<FormErrors>>;
showDebtSection: boolean;
setShowDebtSection: Dispatch<SetStateAction<boolean>>;
isSnowballCategory?: boolean;
showOnSnowball?: boolean;
interestRate: string; setInterestRate: (v: string) => void;
currentBalance: string; setCurrentBalance: (v: string) => void;
minimumPayment: string; setMinimumPayment: (v: string) => void;
validateInterestRate: Validator;
validateCurrentBalance: Validator;
validateMinimumPayment: Validator;
handleBlur: (field: string, value: string, validator: Validator) => void;
onSnowballVisibilityChange: (checked: boolean) => void;
}
// Collapsible Debt / Snowball fields (interest rate, current balance, minimum // Collapsible Debt / Snowball fields (interest rate, current balance, minimum
// payment, snowball visibility). State lives in the parent BillModal (the save // payment, snowball visibility). State lives in the parent BillModal (the save
@ -17,7 +39,7 @@ export default function DebtDetailsSection({
validateInterestRate, validateCurrentBalance, validateMinimumPayment, validateInterestRate, validateCurrentBalance, validateMinimumPayment,
handleBlur, handleBlur,
onSnowballVisibilityChange, onSnowballVisibilityChange,
}) { }: DebtDetailsSectionProps) {
return ( return (
<div className="col-span-2"> <div className="col-span-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -3,6 +3,21 @@ import { cn, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import BillMerchantRules from '@/components/BillMerchantRules'; import BillMerchantRules from '@/components/BillMerchantRules';
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay'; import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
import type { BankTransaction } from '@/types';
interface LinkedTransactionsSectionProps {
isNew?: boolean;
billId?: number | string;
billName?: string;
localHasRules?: boolean;
syncingPayments?: boolean;
onSync?: () => void;
onRulesChanged?: () => void;
linkedTransactions: BankTransaction[];
linkedTransactionsLoading?: boolean;
transactionBusyId?: number | string | null;
onUnmatch: (transaction: BankTransaction) => void;
}
// Bank-matching rules (with a Sync-now action) + the list of transactions // Bank-matching rules (with a Sync-now action) + the list of transactions
// confirmed as matched to this bill (each with an Unmatch action). Presentational // confirmed as matched to this bill (each with an Unmatch action). Presentational
@ -19,7 +34,7 @@ export default function LinkedTransactionsSection({
linkedTransactionsLoading, linkedTransactionsLoading,
transactionBusyId, transactionBusyId,
onUnmatch, onUnmatch,
}) { }: LinkedTransactionsSectionProps) {
return ( return (
<> <>
{/* Bank Matching Rules */} {/* Bank Matching Rules */}
@ -107,7 +122,7 @@ export default function LinkedTransactionsSection({
'font-mono text-sm font-semibold tabular-nums', 'font-mono text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600', Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}> )}>
{fmtTransactionAmount(transaction.amount, transaction.currency)} {fmtTransactionAmount(transaction.amount, transaction.currency ?? undefined)}
</p> </p>
<Button <Button
type="button" type="button"

View File

@ -5,8 +5,9 @@ import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Payment } from '@/types';
const PAYMENT_METHODS = [ const PAYMENT_METHODS: [string, string][] = [
['manual', 'Manual'], ['manual', 'Manual'],
['bank', 'Bank Transfer'], ['bank', 'Bank Transfer'],
['card', 'Card'], ['card', 'Card'],
@ -15,6 +16,18 @@ const PAYMENT_METHODS = [
['cash', 'Cash'], ['cash', 'Cash'],
]; ];
interface PaymentFormFieldsProps {
inp?: string;
editingPayment?: Payment | null;
paymentBusy?: boolean;
amount: string; setAmount: (v: string) => void;
date: string; setDate: (v: string) => void;
method: string; setMethod: (v: string) => void;
notes: string; setNotes: (v: string) => void;
onSubmit?: React.FormEventHandler<HTMLFormElement>;
onCancel?: () => void;
}
// Add/edit form for a manual payment on a bill (edit mode). Presentational — // Add/edit form for a manual payment on a bill (edit mode). Presentational —
// the parent owns the form state, the submit handler, and open/close. // the parent owns the form state, the submit handler, and open/close.
export default function PaymentFormFields({ export default function PaymentFormFields({
@ -27,7 +40,7 @@ export default function PaymentFormFields({
notes, setNotes, notes, setNotes,
onSubmit, onSubmit,
onCancel, onCancel,
}) { }: PaymentFormFieldsProps) {
return ( return (
<form onSubmit={onSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3"> <form onSubmit={onSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">

View File

@ -1,35 +1,46 @@
import { Plus, Link2, Pencil, Trash2 } from 'lucide-react'; import { Plus, Link2, Pencil, Trash2 } from 'lucide-react';
import { cn, fmt, fmtDate } from '@/lib/utils'; import { cn, fmt, fmtDate } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Payment } from '@/types';
function isTransactionLinkedPayment(payment) { function isTransactionLinkedPayment(payment: Payment): boolean {
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null; return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
} }
function isHistoryOnlyPayment(payment) { function isHistoryOnlyPayment(payment: Payment): boolean {
return !!payment?.accounting_excluded; return !!payment?.accounting_excluded;
} }
function paymentSourceLabel(source) { function paymentSourceLabel(source: string | null | undefined): string {
const labels = { const labels: Record<string, string> = {
manual: 'Manual', manual: 'Manual',
file_import: 'File import', file_import: 'File import',
provider_sync: 'Sync', provider_sync: 'Sync',
transaction_match: 'Transaction', transaction_match: 'Transaction',
auto_match: 'SimpleFIN', auto_match: 'SimpleFIN',
}; };
return labels[source] || source || 'Manual'; return labels[source ?? ''] || source || 'Manual';
} }
function paymentSourceTone(source) { const PAYMENT_SOURCE_TONES: Record<string, string> = {
const tones = {
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400', file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
transaction_match: 'border-primary/25 bg-primary/10 text-primary', transaction_match: 'border-primary/25 bg-primary/10 text-primary',
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400', auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
}; };
return tones[source] || tones.manual;
function paymentSourceTone(source: string | null | undefined): string {
return PAYMENT_SOURCE_TONES[source ?? ''] || PAYMENT_SOURCE_TONES.manual!;
}
interface PaymentHistoryListProps {
payments: Payment[];
paymentsLoading?: boolean;
paymentBusy?: boolean;
onAdd?: () => void;
onEdit: (payment: Payment) => void;
onDelete: (payment: Payment) => void;
} }
// Payment-history list for a bill (edit mode): each recorded payment with its // Payment-history list for a bill (edit mode): each recorded payment with its
@ -43,7 +54,7 @@ export default function PaymentHistoryList({
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
}) { }: PaymentHistoryListProps) {
return ( return (
<> <>
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">

View File

@ -1,6 +1,15 @@
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
interface TemplateSectionProps {
inp?: string;
saveTemplate: boolean;
setSaveTemplate: (v: boolean) => void;
templateName: string;
setTemplateName: (v: string) => void;
namePlaceholder?: string;
}
// "Save this setup as a reusable template" toggle + optional template name. // "Save this setup as a reusable template" toggle + optional template name.
// Presentational — the parent owns the state and performs the save. // Presentational — the parent owns the state and performs the save.
export default function TemplateSection({ export default function TemplateSection({
@ -8,7 +17,7 @@ export default function TemplateSection({
saveTemplate, setSaveTemplate, saveTemplate, setSaveTemplate,
templateName, setTemplateName, templateName, setTemplateName,
namePlaceholder, namePlaceholder,
}) { }: TemplateSectionProps) {
return ( return (
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3"> <div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2.5 cursor-pointer group"> <label className="flex items-center gap-2.5 cursor-pointer group">

View File

@ -10,6 +10,34 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay'; import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
import type { Dispatch, SetStateAction } from 'react';
import type { BankTransaction } from '@/types';
interface MerchantRuleLite {
id: number;
merchant: string;
}
export interface BulkUnmatchState {
similar: BankTransaction[];
checkedIds: Set<number>;
rules: MerchantRuleLite[];
removeRuleId: number | null;
}
interface UnmatchDialogsProps {
unmatchTarget: BankTransaction | null;
bulkUnmatch: BulkUnmatchState | null;
setBulkUnmatch: Dispatch<SetStateAction<BulkUnmatchState | null>>;
unmatchConfirmOpen: boolean;
setUnmatchConfirmOpen: (v: boolean) => void;
transactionBusyId?: number | string | null;
bulkBusy?: boolean;
closeUnmatch: () => void;
onSingleUnmatch: () => void;
onOpenBulkUnmatch: () => void;
onBulkConfirm: () => void;
}
// The three unmatch flows for a linked bank transaction: the choice dialog // The three unmatch flows for a linked bank transaction: the choice dialog
// (single vs. review-similar), the single-unmatch confirm, and the bulk review // (single vs. review-similar), the single-unmatch confirm, and the bulk review
@ -25,7 +53,7 @@ export default function UnmatchDialogs({
onSingleUnmatch, onSingleUnmatch,
onOpenBulkUnmatch, onOpenBulkUnmatch,
onBulkConfirm, onBulkConfirm,
}) { }: UnmatchDialogsProps) {
return ( return (
<> <>
{/* ── Unmatch choice dialog ─────────────────────────────────── */} {/* ── Unmatch choice dialog ─────────────────────────────────── */}
@ -47,7 +75,7 @@ export default function UnmatchDialogs({
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'} {transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
{' · '} {' · '}
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)}</span> <span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency ?? undefined)}</span>
</p> </p>
</div> </div>
)} )}
@ -100,7 +128,7 @@ export default function UnmatchDialogs({
<> <>
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span> <span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
{' '}will be unlinked from this bill. {' '}will be unlinked from this bill.
{unmatchTarget.linked_payment?.payment_source === 'provider_sync' {(unmatchTarget.linked_payment as { payment_source?: string } | null | undefined)?.payment_source === 'provider_sync'
? ' The payment record will be removed and the balance restored.' ? ' The payment record will be removed and the balance restored.'
: ' The payment record will be removed.'} : ' The payment record will be removed.'}
</> </>
@ -146,7 +174,7 @@ export default function UnmatchDialogs({
size="sm" size="sm"
variant="outline" variant="outline"
className="h-6 px-2 text-xs" className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))} onClick={() => setBulkUnmatch(p => p ? { ...p, checkedIds: new Set(p.similar.map(t => t.id)) } : p)}
> >
All All
</Button> </Button>
@ -155,7 +183,7 @@ export default function UnmatchDialogs({
size="sm" size="sm"
variant="outline" variant="outline"
className="h-6 px-2 text-xs" className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))} onClick={() => setBulkUnmatch(p => p ? { ...p, checkedIds: new Set<number>() } : p)}
> >
None None
</Button> </Button>
@ -179,8 +207,9 @@ export default function UnmatchDialogs({
checked={bulkUnmatch.checkedIds.has(tx.id)} checked={bulkUnmatch.checkedIds.has(tx.id)}
onCheckedChange={checked => { onCheckedChange={checked => {
setBulkUnmatch(p => { setBulkUnmatch(p => {
if (!p) return p;
const next = new Set(p.checkedIds); const next = new Set(p.checkedIds);
checked ? next.add(tx.id) : next.delete(tx.id); if (checked) next.add(tx.id); else next.delete(tx.id);
return { ...p, checkedIds: next }; return { ...p, checkedIds: next };
}); });
}} }}
@ -196,7 +225,7 @@ export default function UnmatchDialogs({
'shrink-0 font-mono text-sm font-semibold tabular-nums', 'shrink-0 font-mono text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600', Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}> )}>
{fmtTransactionAmount(tx.amount, tx.currency)} {fmtTransactionAmount(tx.amount, tx.currency ?? undefined)}
</p> </p>
</label> </label>
))} ))}
@ -213,7 +242,7 @@ export default function UnmatchDialogs({
className="mt-0.5" className="mt-0.5"
checked={bulkUnmatch.removeRuleId === rule.id} checked={bulkUnmatch.removeRuleId === rule.id}
onCheckedChange={checked => onCheckedChange={checked =>
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null })) setBulkUnmatch(p => p ? { ...p, removeRuleId: checked ? rule.id : null } : p)
} }
/> />
<div> <div>

View File

@ -4,25 +4,33 @@ import { formatCentsUSD } from '@/lib/money';
// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee // the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee
// grouping. // grouping.
export function fmtTransactionAmount(amount, currency = 'USD') { interface TxLike {
payee?: string | null;
description?: string | null;
memo?: string | null;
posted_date?: string | null;
transacted_at?: string | number | null;
}
export function fmtTransactionAmount(amount: Parameters<typeof formatCentsUSD>[0], currency = 'USD') {
return formatCentsUSD(amount, { signed: true, currency }); return formatCentsUSD(amount, { signed: true, currency });
} }
export function transactionDate(tx) { export function transactionDate(tx: TxLike | null | undefined): string | null {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null; return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
} }
export function transactionTitle(tx) { export function transactionTitle(tx: TxLike | null | undefined): string {
return tx?.payee || tx?.description || tx?.memo || 'Transaction'; return tx?.payee || tx?.description || tx?.memo || 'Transaction';
} }
export function normalizePayee(s) { export function normalizePayee(s: string | null | undefined): string {
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
} }
// Two payees are "similar" when one normalized name is a prefix of the other // Two payees are "similar" when one normalized name is a prefix of the other
// (min 3 chars) — the grouping used to find related matches to unmatch together. // (min 3 chars) — the grouping used to find related matches to unmatch together.
export function isSimilarPayee(a, b) { export function isSimilarPayee(a: string | null | undefined, b: string | null | undefined): boolean {
const na = normalizePayee(a); const na = normalizePayee(a);
const nb = normalizePayee(b); const nb = normalizePayee(b);
const minLen = Math.min(na.length, nb.length); const minLen = Math.min(na.length, nb.length);

View File

@ -8,7 +8,25 @@
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads // Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
// that no typed consumer reads yet (trend series, autopay suggestion/stats, // that no typed consumer reads yet (trend series, autopay suggestion/stats,
// sparkline) are left `unknown` and refined when a consumer needs them. // sparkline) are left `unknown` and refined when a consumer needs them.
import type { Dollars } from '@/lib/money'; import type { Cents, Dollars } from '@/lib/money';
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
// (format them with formatCentsUSD, never formatUSD).
export interface BankTransaction {
id: number;
amount: Cents;
currency?: string | null;
payee?: string | null;
description?: string | null;
memo?: string | null;
posted_date?: string | null;
transacted_at?: string | number | null;
account_name?: string | null;
source_label?: string | null;
source_type_label?: string | null;
[key: string]: unknown;
}
// A bill's month status. Mirrors the server's statusService. // A bill's month status. Mirrors the server's statusService.
export type TrackerStatus = export type TrackerStatus =