refactor(ts): convert BillModal to TSX (B11) — bill-modal feature done

The app's largest, most-central component (1092 ln, the add/edit form reached
from 9 entry points). Typed ~40 useState vars (Bill/Payment/BankTransaction/
BulkUnmatchState/Date), the useActionState save (isNew narrows `bill` non-null
via the aliased `!bill` const), FormErrors = Record<string,string>, and every
API response cast (billPayments/billTransactions/syncBillSimplefinPayments/
verifyAutopay/billMerchantRules). Its now-typed sub-components type-check the
props BillModal passes them. cycle_day coerced to string for Radix Select;
Bill index-sig fields (autopay_verified_at, source_bill_id) cast at use.

client/components/bill-modal/ + BillModal are 100% .tsx. typecheck 0, lint 0
errors (35 warns), build green, 48 tests, 17/17 e2e probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 20:10:48 -05:00
parent 31ff6adbd9
commit 73be95d12d
2 changed files with 91 additions and 72 deletions

View File

@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
@ -16,12 +16,12 @@ import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { cn, todayStr, errMessage } from '@/lib/utils';
import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection'; import DebtDetailsSection from '@/components/bill-modal/DebtDetailsSection';
import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; import AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator';
import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList';
import PaymentFormFields from '@/components/bill-modal/PaymentFormFields'; import PaymentFormFields from '@/components/bill-modal/PaymentFormFields';
import UnmatchDialogs from '@/components/bill-modal/UnmatchDialogs'; import UnmatchDialogs, { type BulkUnmatchState } from '@/components/bill-modal/UnmatchDialogs';
import LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection'; import LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection';
import TemplateSection from '@/components/bill-modal/TemplateSection'; import TemplateSection from '@/components/bill-modal/TemplateSection';
import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay'; import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
@ -31,8 +31,11 @@ import {
defaultCycleDayForSchedule, defaultCycleDayForSchedule,
scheduleValue, scheduleValue,
} from '@/lib/billingSchedule'; } from '@/lib/billingSchedule';
import type { AutopayStats, BankTransaction, Bill, Category, Payment } from '@/types';
function getOrdinalSuffix(day) { type FormErrors = Record<string, string>;
function getOrdinalSuffix(day: number): string {
if (day > 3 && day < 21) return 'th'; if (day > 3 && day < 21) return 'th';
switch (day % 10) { switch (day % 10) {
case 1: return 'st'; case 1: return 'st';
@ -46,7 +49,7 @@ function getOrdinalSuffix(day) {
const CAT_NONE = 'none'; const CAT_NONE = 'none';
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing'];
const SUBSCRIPTION_TYPES = [ const SUBSCRIPTION_TYPES: [string, string][] = [
['streaming', 'Streaming'], ['streaming', 'Streaming'],
['software', 'Software'], ['software', 'Software'],
['cloud', 'Cloud'], ['cloud', 'Cloud'],
@ -59,18 +62,27 @@ const SUBSCRIPTION_TYPES = [
['other', 'Other'], ['other', 'Other'],
]; ];
function isDebtCat(categories, catId) { function isDebtCat(categories: Category[], catId: string | null | undefined): boolean {
if (!catId || catId === CAT_NONE) return false; if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId); const cat = categories.find(c => String(c.id) === catId);
return cat ? DEBT_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; return cat ? DEBT_KEYWORDS.some(kw => String(cat.name).toLowerCase().includes(kw)) : false;
} }
function isSnowballCat(categories, catId) { function isSnowballCat(categories: Category[], catId: string | null | undefined): boolean {
if (!catId || catId === CAT_NONE) return false; if (!catId || catId === CAT_NONE) return false;
const cat = categories.find(c => String(c.id) === catId); const cat = categories.find(c => String(c.id) === catId);
return cat ? SNOWBALL_KEYWORDS.some(kw => cat.name.toLowerCase().includes(kw)) : false; return cat ? SNOWBALL_KEYWORDS.some(kw => String(cat.name).toLowerCase().includes(kw)) : false;
} }
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) { interface BillModalProps {
bill?: Bill | null;
initialBill?: Bill | null;
categories: Category[];
onClose: () => void;
onSave: (savedBill?: Bill) => void;
onDuplicate?: (bill: Bill) => void;
}
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }: BillModalProps) {
const isNew = !bill; const isNew = !bill;
const sourceBill = bill || initialBill || null; const sourceBill = bill || initialBill || null;
@ -81,7 +93,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate)); const [interestRate, setInterestRate] = useState(sourceBill?.interest_rate == null ? '' : String(sourceBill.interest_rate));
const initialCycleType = scheduleValue(sourceBill || {}); const initialCycleType = scheduleValue(sourceBill || {});
const [cycleType, setCycleType] = useState(initialCycleType); const [cycleType, setCycleType] = useState(initialCycleType);
const [cycleDay, setCycleDay] = useState(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType)); const [cycleDay, setCycleDay] = useState(String(sourceBill?.cycle_day || defaultCycleDayForSchedule(initialCycleType)));
const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled); const [autopay, setAutopay] = useState(!!sourceBill?.autopay_enabled);
const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); const [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none'));
const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid); const [autoMarkPaid, setAutoMarkPaid] = useState(!!sourceBill?.auto_mark_paid);
@ -110,22 +122,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
); );
const [saveTemplate, setSaveTemplate] = useState(false); const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState(''); const [templateName, setTemplateName] = useState('');
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState<FormErrors>({});
const [payments, setPayments] = useState([]); const [payments, setPayments] = useState<Payment[]>([]);
const [paymentsLoading, setPaymentsLoading] = useState(false); const [paymentsLoading, setPaymentsLoading] = useState(false);
const [linkedTransactions, setLinkedTransactions] = useState([]); const [linkedTransactions, setLinkedTransactions] = useState<BankTransaction[]>([]);
const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false); const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false);
const [transactionBusyId, setTransactionBusyId] = useState(null); const [transactionBusyId, setTransactionBusyId] = useState<number | null>(null);
const [paymentBusy, setPaymentBusy] = useState(false); const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentFormOpen, setPaymentFormOpen] = useState(false); const [paymentFormOpen, setPaymentFormOpen] = useState(false);
const [editingPayment, setEditingPayment] = useState(null); const [editingPayment, setEditingPayment] = useState<Payment | null>(null);
const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); const [deletePaymentTarget, setDeletePaymentTarget] = useState<Payment | null>(null);
const [paymentAmount, setPaymentAmount] = useState(''); const [paymentAmount, setPaymentAmount] = useState('');
const [paymentDate, setPaymentDate] = useState(todayStr()); const [paymentDate, setPaymentDate] = useState(todayStr());
const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentMethod, setPaymentMethod] = useState('manual');
const [paymentNotes, setPaymentNotes] = useState(''); const [paymentNotes, setPaymentNotes] = useState('');
const [localVerifiedAt, setLocalVerifiedAt] = useState( const [localVerifiedAt, setLocalVerifiedAt] = useState<Date | null>(
bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null bill?.autopay_verified_at ? new Date(bill.autopay_verified_at as string) : null
); );
// Controls the outer Dialog's open state so it closes via its own animation // Controls the outer Dialog's open state so it closes via its own animation
@ -137,9 +149,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const [deactivateReason, setDeactivateReason] = useState(''); const [deactivateReason, setDeactivateReason] = useState('');
// Unmatch dialog state // Unmatch dialog state
const [unmatchTarget, setUnmatchTarget] = useState(null); const [unmatchTarget, setUnmatchTarget] = useState<BankTransaction | null>(null);
const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false);
const [bulkUnmatch, setBulkUnmatch] = useState(null); const [bulkUnmatch, setBulkUnmatch] = useState<BulkUnmatchState | null>(null);
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const isSnowballCategory = isSnowballCat(categories, categoryId); const isSnowballCategory = isSnowballCat(categories, categoryId);
@ -150,10 +162,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
if (isNew || !bill?.id) return; if (isNew || !bill?.id) return;
setPaymentsLoading(true); setPaymentsLoading(true);
try { try {
const data = await api.billPayments(bill.id, 1, 100); const data = await api.billPayments(bill.id, 1, 100) as { payments?: Payment[] };
setPayments(data.payments || []); setPayments(data.payments || []);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load payment history.'); toast.error(errMessage(err, 'Failed to load payment history.'));
} finally { } finally {
setPaymentsLoading(false); setPaymentsLoading(false);
} }
@ -163,10 +175,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
if (isNew || !bill?.id) return; if (isNew || !bill?.id) return;
setLinkedTransactionsLoading(true); setLinkedTransactionsLoading(true);
try { try {
const data = await api.billTransactions(bill.id); const data = await api.billTransactions(bill.id) as { transactions?: BankTransaction[] };
setLinkedTransactions(data.transactions || []); setLinkedTransactions(data.transactions || []);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load linked transactions.'); toast.error(errMessage(err, 'Failed to load linked transactions.'));
} finally { } finally {
setLinkedTransactionsLoading(false); setLinkedTransactionsLoading(false);
} }
@ -189,18 +201,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} }
async function handleSyncBillPayments() { async function handleSyncBillPayments() {
if (!sourceBill?.id) return;
setSyncingPayments(true); setSyncingPayments(true);
const promise = api.syncBillSimplefinPayments(sourceBill.id); const promise = api.syncBillSimplefinPayments(sourceBill.id);
toast.promise(promise, { toast.promise(promise, {
loading: 'Scanning bank history…', loading: 'Scanning bank history…',
success: (result) => result.added > 0 success: (r) => {
? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.` const added = (r as { added?: number }).added ?? 0;
: 'No new matching transactions found.', return added > 0
error: (err) => err.message || 'Sync failed.', ? `${added} payment${added !== 1 ? 's' : ''} imported from bank history.`
: 'No new matching transactions found.';
},
error: (err) => errMessage(err, 'Sync failed.'),
}); });
try { try {
const result = await promise; const result = await promise as { added?: number; late_attributions?: unknown[] };
if (result.added > 0) await refreshAfterImport(); if ((result.added ?? 0) > 0) await refreshAfterImport();
if (result.late_attributions?.length) { if (result.late_attributions?.length) {
window.dispatchEvent(new CustomEvent('tracker:late-attributions', { window.dispatchEvent(new CustomEvent('tracker:late-attributions', {
detail: { attributions: result.late_attributions }, detail: { attributions: result.late_attributions },
@ -218,13 +234,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await refreshAfterImport(); await refreshAfterImport();
} }
const validateName = (val) => { const validateName = (val: string): string => {
if (!val || val.trim() === '') return 'Name is required'; if (!val || val.trim() === '') return 'Name is required';
if (val.trim().length < 2) return 'Name must be at least 2 characters'; if (val.trim().length < 2) return 'Name must be at least 2 characters';
return ''; return '';
}; };
const validateDueDay = (val) => { const validateDueDay = (val: string): string => {
if (!val || val.trim() === '') return 'Due day is required'; if (!val || val.trim() === '') return 'Due day is required';
const num = parseInt(val, 10); const num = parseInt(val, 10);
if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31'; if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31';
@ -232,11 +248,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
}; };
// Money fields share one non-negative validator (blank allowed, 0 allowed). // Money fields share one non-negative validator (blank allowed, 0 allowed).
const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount'); const validateExpectedAmount = (val: string): string => validateNonNegativeMoney(val, 'Amount');
const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance'); const validateCurrentBalance = (val: string): string => validateNonNegativeMoney(val, 'Balance');
const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment'); const validateMinimumPayment = (val: string): string => validateNonNegativeMoney(val, 'Min payment');
const validateInterestRate = (val) => { const validateInterestRate = (val: string): string => {
if (val === '' || val === null) return ''; if (val === '' || val === null) return '';
const num = parseFloat(val); const num = parseFloat(val);
if (isNaN(num)) return 'Invalid number'; if (isNaN(num)) return 'Invalid number';
@ -244,8 +260,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
return ''; return '';
}; };
const validateForm = () => { const validateForm = (): boolean => {
const newErrors = { const newErrors: FormErrors = {
name: validateName(name), name: validateName(name),
dueDay: validateDueDay(dueDay), dueDay: validateDueDay(dueDay),
expectedAmount: validateExpectedAmount(expectedAmount), expectedAmount: validateExpectedAmount(expectedAmount),
@ -260,11 +276,11 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
// Value passed explicitly so this never falls through to the wrong field's // Value passed explicitly so this never falls through to the wrong field's
// state (the old positional guessing defaulted every unmapped field to // state (the old positional guessing defaulted every unmapped field to
// interestRate). // interestRate).
const handleBlur = (field, value, validator) => { const handleBlur = (field: string, value: string, validator: (v: string) => string) => {
setErrors(prev => ({ ...prev, [field]: validator(value) })); setErrors(prev => ({ ...prev, [field]: validator(value) }));
}; };
const handleCategoryChange = (val) => { const handleCategoryChange = (val: string) => {
setCategoryId(val); setCategoryId(val);
if (isDebtCat(categories, val)) { if (isDebtCat(categories, val)) {
setShowDebtSection(true); setShowDebtSection(true);
@ -273,7 +289,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} }
}; };
const handleSnowballVisibilityChange = (checked) => { const handleSnowballVisibilityChange = (checked: boolean) => {
if (checked) { if (checked) {
setSnowballExempt(false); setSnowballExempt(false);
setSnowballInclude(!isSnowballCategory); setSnowballInclude(!isSnowballCategory);
@ -286,15 +302,15 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
async function handleVerifyAutopay() { async function handleVerifyAutopay() {
if (!bill?.id) return; if (!bill?.id) return;
try { try {
const res = await api.verifyAutopay(bill.id); const res = await api.verifyAutopay(bill.id) as { autopay_verified_at?: string };
setLocalVerifiedAt(new Date(res.autopay_verified_at)); setLocalVerifiedAt(new Date(res.autopay_verified_at ?? Date.now()));
toast.success('Autopay marked as verified.'); toast.success('Autopay marked as verified.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to verify autopay.'); toast.error(errMessage(err, 'Failed to verify autopay.'));
} }
} }
const handleAutopayChange = (checked) => { const handleAutopayChange = (checked: boolean) => {
setAutopay(checked); setAutopay(checked);
if (checked) { if (checked) {
setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid')); setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid'));
@ -304,7 +320,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
} }
}; };
const handleCycleTypeChange = (value) => { const handleCycleTypeChange = (value: string) => {
setCycleType(value); setCycleType(value);
setCycleDay(defaultCycleDayForSchedule(value)); setCycleDay(defaultCycleDayForSchedule(value));
}; };
@ -322,7 +338,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setPaymentFormOpen(true); setPaymentFormOpen(true);
} }
function startEditPayment(payment) { function startEditPayment(payment: Payment) {
setEditingPayment(payment); setEditingPayment(payment);
setPaymentAmount(String(payment.amount ?? '')); setPaymentAmount(String(payment.amount ?? ''));
setPaymentDate(payment.paid_date || todayStr()); setPaymentDate(payment.paid_date || todayStr());
@ -331,7 +347,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
setPaymentFormOpen(true); setPaymentFormOpen(true);
} }
async function handlePaymentSubmit(e) { async function handlePaymentSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
const parsedAmount = parseFloat(paymentAmount); const parsedAmount = parseFloat(paymentAmount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) { if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
@ -356,7 +372,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await api.updatePayment(editingPayment.id, payload); await api.updatePayment(editingPayment.id, payload);
toast.success('Payment updated'); toast.success('Payment updated');
} else { } else {
await api.createPayment({ ...payload, bill_id: bill.id }); await api.createPayment({ ...payload, bill_id: bill?.id });
toast.success('Payment added'); toast.success('Payment added');
} }
resetPaymentForm(); resetPaymentForm();
@ -364,7 +380,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await loadPayments(); await loadPayments();
onSave?.(); onSave?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Payment could not be saved.'); toast.error(errMessage(err, 'Payment could not be saved.'));
} finally { } finally {
setPaymentBusy(false); setPaymentBusy(false);
} }
@ -387,7 +403,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await loadPayments(); await loadPayments();
onSave?.(); onSave?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to restore payment.'); toast.error(errMessage(err, 'Failed to restore payment.'));
} }
}, },
}, },
@ -399,13 +415,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await loadPayments(); await loadPayments();
onSave?.(); onSave?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Payment could not be removed.'); toast.error(errMessage(err, 'Payment could not be removed.'));
} finally { } finally {
setPaymentBusy(false); setPaymentBusy(false);
} }
} }
function openUnmatch(transaction) { function openUnmatch(transaction: BankTransaction) {
setUnmatchTarget(transaction); setUnmatchTarget(transaction);
setBulkUnmatch(null); setBulkUnmatch(null);
setUnmatchConfirmOpen(false); setUnmatchConfirmOpen(false);
@ -427,7 +443,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await Promise.all([loadPayments(), loadLinkedTransactions()]); await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.(); onSave?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Transaction could not be unmatched.'); toast.error(errMessage(err, 'Transaction could not be unmatched.'));
} finally { } finally {
setTransactionBusyId(null); setTransactionBusyId(null);
} }
@ -442,9 +458,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
if (!similar.find(tx => tx.id === unmatchTarget.id)) { if (!similar.find(tx => tx.id === unmatchTarget.id)) {
similar.unshift(unmatchTarget); similar.unshift(unmatchTarget);
} }
let rules = []; let rules: { id: number; merchant: string }[] = [];
try { try {
const ruleData = await api.billMerchantRules(bill.id); const ruleData = await api.billMerchantRules(bill.id) as { id: number; merchant: string }[] | null;
rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee)); rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee));
} catch { } catch {
// ignore — rules are optional // ignore — rules are optional
@ -462,16 +478,19 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
const { similar, checkedIds, removeRuleId } = bulkUnmatch; const { similar, checkedIds, removeRuleId } = bulkUnmatch;
const matches = similar const matches = similar
.filter(tx => checkedIds.has(tx.id) && tx.linked_payment) .filter(tx => checkedIds.has(tx.id) && tx.linked_payment)
.map(tx => ({ .map(tx => {
const lp = tx.linked_payment as { id: number; payment_source?: string };
return {
transaction_id: tx.id, transaction_id: tx.id,
payment_id: tx.linked_payment.id, payment_id: lp.id,
payment_source: tx.linked_payment.payment_source, payment_source: lp.payment_source,
})); };
});
if (matches.length === 0) { closeUnmatch(); return; } if (matches.length === 0) { closeUnmatch(); return; }
setBulkBusy(true); setBulkBusy(true);
try { try {
await api.unmatchTransactionBulk(matches); await api.unmatchTransactionBulk(matches);
if (removeRuleId) { if (removeRuleId && bill?.id) {
try { try {
await api.deleteMerchantRule(bill.id, removeRuleId); await api.deleteMerchantRule(bill.id, removeRuleId);
} catch { } catch {
@ -483,7 +502,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
await Promise.all([loadPayments(), loadLinkedTransactions()]); await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.(); onSave?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Could not unmatch transactions.'); toast.error(errMessage(err, 'Could not unmatch transactions.'));
} finally { } finally {
setBulkBusy(false); setBulkBusy(false);
} }
@ -533,10 +552,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
snowball_exempt: snowballExempt, snowball_exempt: snowballExempt,
}; };
try { try {
let savedBill; let savedBill: Bill;
if (isNew) { if (isNew) {
if (data.source_bill_id) { if (data.source_bill_id) {
savedBill = await api.duplicateBill(data.source_bill_id, data); savedBill = await api.duplicateBill(data.source_bill_id as number, data) as Bill;
} else { } else {
savedBill = await api.createBill(data); savedBill = await api.createBill(data);
} }
@ -553,21 +572,21 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
onSave(savedBill); onSave(savedBill);
setDialogOpen(false); setDialogOpen(false);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save bill.'); toast.error(errMessage(err, 'Failed to save bill.'));
} }
}, null); }, null);
async function handleDeactivate() { async function handleDeactivate() {
if (!bill?.id) return; if (!bill?.id) return;
try { try {
const payload = { active: bill.active ? 0 : 1 }; const payload: { active: number; inactive_reason?: string } = { active: bill.active ? 0 : 1 };
if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason; if (bill.active && deactivateReason) payload.inactive_reason = deactivateReason;
await api.updateBill(bill.id, payload); await api.updateBill(bill.id, payload);
toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated');
onSave?.(); onSave?.();
setDialogOpen(false); setDialogOpen(false);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update bill.'); toast.error(errMessage(err, 'Failed to update bill.'));
} }
} }
@ -898,7 +917,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa
<AutopayTrustIndicator <AutopayTrustIndicator
isNew={isNew} isNew={isNew}
autopay={autopay} autopay={autopay}
stats={bill?.autopay_stats} stats={bill?.autopay_stats as AutopayStats | null}
verifiedAt={localVerifiedAt} verifiedAt={localVerifiedAt}
onVerify={handleVerifyAutopay} onVerify={handleVerifyAutopay}
/> />

View File

@ -4,8 +4,8 @@ import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
type FormErrors = Record<string, string | null | undefined>; type FormErrors = Record<string, string>;
type Validator = (v: string) => string | null; type Validator = (v: string) => string;
interface DebtDetailsSectionProps { interface DebtDetailsSectionProps {
inp?: string; inp?: string;