diff --git a/client/components/BillModal.jsx b/client/components/BillModal.tsx similarity index 89% rename from client/components/BillModal.jsx rename to client/components/BillModal.tsx index 0552e03..5668615 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { - Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, @@ -16,12 +16,12 @@ import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; 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 AutopayTrustIndicator from '@/components/bill-modal/AutopayTrustIndicator'; import PaymentHistoryList from '@/components/bill-modal/PaymentHistoryList'; 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 TemplateSection from '@/components/bill-modal/TemplateSection'; import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay'; @@ -31,8 +31,11 @@ import { defaultCycleDayForSchedule, scheduleValue, } from '@/lib/billingSchedule'; +import type { AutopayStats, BankTransaction, Bill, Category, Payment } from '@/types'; -function getOrdinalSuffix(day) { +type FormErrors = Record; + +function getOrdinalSuffix(day: number): string { if (day > 3 && day < 21) return 'th'; switch (day % 10) { case 1: return 'st'; @@ -46,7 +49,7 @@ function getOrdinalSuffix(day) { const CAT_NONE = 'none'; const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt']; const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing']; -const SUBSCRIPTION_TYPES = [ +const SUBSCRIPTION_TYPES: [string, string][] = [ ['streaming', 'Streaming'], ['software', 'Software'], ['cloud', 'Cloud'], @@ -59,18 +62,27 @@ const SUBSCRIPTION_TYPES = [ ['other', 'Other'], ]; -function isDebtCat(categories, catId) { +function isDebtCat(categories: Category[], catId: string | null | undefined): boolean { if (!catId || catId === CAT_NONE) return false; 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; 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 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 initialCycleType = scheduleValue(sourceBill || {}); 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 [autodraftStatus, setAutodraftStatus] = useState(sourceBill?.autodraft_status || (sourceBill?.autopay_enabled ? 'assumed_paid' : 'none')); 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 [templateName, setTemplateName] = useState(''); - const [errors, setErrors] = useState({}); - const [payments, setPayments] = useState([]); + const [errors, setErrors] = useState({}); + const [payments, setPayments] = useState([]); const [paymentsLoading, setPaymentsLoading] = useState(false); - const [linkedTransactions, setLinkedTransactions] = useState([]); + const [linkedTransactions, setLinkedTransactions] = useState([]); const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false); - const [transactionBusyId, setTransactionBusyId] = useState(null); + const [transactionBusyId, setTransactionBusyId] = useState(null); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentFormOpen, setPaymentFormOpen] = useState(false); - const [editingPayment, setEditingPayment] = useState(null); - const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); + const [editingPayment, setEditingPayment] = useState(null); + const [deletePaymentTarget, setDeletePaymentTarget] = useState(null); const [paymentAmount, setPaymentAmount] = useState(''); const [paymentDate, setPaymentDate] = useState(todayStr()); const [paymentMethod, setPaymentMethod] = useState('manual'); const [paymentNotes, setPaymentNotes] = useState(''); - const [localVerifiedAt, setLocalVerifiedAt] = useState( - bill?.autopay_verified_at ? new Date(bill.autopay_verified_at) : null + const [localVerifiedAt, setLocalVerifiedAt] = useState( + 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 @@ -137,9 +149,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const [deactivateReason, setDeactivateReason] = useState(''); // Unmatch dialog state - const [unmatchTarget, setUnmatchTarget] = useState(null); + const [unmatchTarget, setUnmatchTarget] = useState(null); const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false); - const [bulkUnmatch, setBulkUnmatch] = useState(null); + const [bulkUnmatch, setBulkUnmatch] = useState(null); const [bulkBusy, setBulkBusy] = useState(false); const isSnowballCategory = isSnowballCat(categories, categoryId); @@ -150,10 +162,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (isNew || !bill?.id) return; setPaymentsLoading(true); 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 || []); } catch (err) { - toast.error(err.message || 'Failed to load payment history.'); + toast.error(errMessage(err, 'Failed to load payment history.')); } finally { setPaymentsLoading(false); } @@ -163,10 +175,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (isNew || !bill?.id) return; setLinkedTransactionsLoading(true); try { - const data = await api.billTransactions(bill.id); + const data = await api.billTransactions(bill.id) as { transactions?: BankTransaction[] }; setLinkedTransactions(data.transactions || []); } catch (err) { - toast.error(err.message || 'Failed to load linked transactions.'); + toast.error(errMessage(err, 'Failed to load linked transactions.')); } finally { setLinkedTransactionsLoading(false); } @@ -189,18 +201,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } async function handleSyncBillPayments() { + if (!sourceBill?.id) return; setSyncingPayments(true); const promise = api.syncBillSimplefinPayments(sourceBill.id); toast.promise(promise, { loading: 'Scanning bank history…', - success: (result) => result.added > 0 - ? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.` - : 'No new matching transactions found.', - error: (err) => err.message || 'Sync failed.', + success: (r) => { + const added = (r as { added?: number }).added ?? 0; + return added > 0 + ? `${added} payment${added !== 1 ? 's' : ''} imported from bank history.` + : 'No new matching transactions found.'; + }, + error: (err) => errMessage(err, 'Sync failed.'), }); try { - const result = await promise; - if (result.added > 0) await refreshAfterImport(); + const result = await promise as { added?: number; late_attributions?: unknown[] }; + if ((result.added ?? 0) > 0) await refreshAfterImport(); if (result.late_attributions?.length) { window.dispatchEvent(new CustomEvent('tracker:late-attributions', { detail: { attributions: result.late_attributions }, @@ -218,13 +234,13 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await refreshAfterImport(); } - const validateName = (val) => { + const validateName = (val: string): string => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; return ''; }; - const validateDueDay = (val) => { + const validateDueDay = (val: string): string => { if (!val || val.trim() === '') return 'Due day is required'; const num = parseInt(val, 10); 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). - const validateExpectedAmount = (val) => validateNonNegativeMoney(val, 'Amount'); - const validateCurrentBalance = (val) => validateNonNegativeMoney(val, 'Balance'); - const validateMinimumPayment = (val) => validateNonNegativeMoney(val, 'Min payment'); + const validateExpectedAmount = (val: string): string => validateNonNegativeMoney(val, 'Amount'); + const validateCurrentBalance = (val: string): string => validateNonNegativeMoney(val, 'Balance'); + const validateMinimumPayment = (val: string): string => validateNonNegativeMoney(val, 'Min payment'); - const validateInterestRate = (val) => { + const validateInterestRate = (val: string): string => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num)) return 'Invalid number'; @@ -244,8 +260,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa return ''; }; - const validateForm = () => { - const newErrors = { + const validateForm = (): boolean => { + const newErrors: FormErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), 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 // state (the old positional guessing defaulted every unmapped field to // interestRate). - const handleBlur = (field, value, validator) => { + const handleBlur = (field: string, value: string, validator: (v: string) => string) => { setErrors(prev => ({ ...prev, [field]: validator(value) })); }; - const handleCategoryChange = (val) => { + const handleCategoryChange = (val: string) => { setCategoryId(val); if (isDebtCat(categories, val)) { setShowDebtSection(true); @@ -273,7 +289,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa } }; - const handleSnowballVisibilityChange = (checked) => { + const handleSnowballVisibilityChange = (checked: boolean) => { if (checked) { setSnowballExempt(false); setSnowballInclude(!isSnowballCategory); @@ -286,15 +302,15 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa async function handleVerifyAutopay() { if (!bill?.id) return; try { - const res = await api.verifyAutopay(bill.id); - setLocalVerifiedAt(new Date(res.autopay_verified_at)); + const res = await api.verifyAutopay(bill.id) as { autopay_verified_at?: string }; + setLocalVerifiedAt(new Date(res.autopay_verified_at ?? Date.now())); toast.success('Autopay marked as verified.'); } 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); if (checked) { 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); setCycleDay(defaultCycleDayForSchedule(value)); }; @@ -322,7 +338,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa setPaymentFormOpen(true); } - function startEditPayment(payment) { + function startEditPayment(payment: Payment) { setEditingPayment(payment); setPaymentAmount(String(payment.amount ?? '')); setPaymentDate(payment.paid_date || todayStr()); @@ -331,7 +347,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa setPaymentFormOpen(true); } - async function handlePaymentSubmit(e) { + async function handlePaymentSubmit(e: React.FormEvent) { e.preventDefault(); const parsedAmount = parseFloat(paymentAmount); 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); toast.success('Payment updated'); } else { - await api.createPayment({ ...payload, bill_id: bill.id }); + await api.createPayment({ ...payload, bill_id: bill?.id }); toast.success('Payment added'); } resetPaymentForm(); @@ -364,7 +380,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await loadPayments(); onSave?.(); } catch (err) { - toast.error(err.message || 'Payment could not be saved.'); + toast.error(errMessage(err, 'Payment could not be saved.')); } finally { setPaymentBusy(false); } @@ -387,7 +403,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await loadPayments(); onSave?.(); } 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(); onSave?.(); } catch (err) { - toast.error(err.message || 'Payment could not be removed.'); + toast.error(errMessage(err, 'Payment could not be removed.')); } finally { setPaymentBusy(false); } } - function openUnmatch(transaction) { + function openUnmatch(transaction: BankTransaction) { setUnmatchTarget(transaction); setBulkUnmatch(null); setUnmatchConfirmOpen(false); @@ -427,7 +443,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { - toast.error(err.message || 'Transaction could not be unmatched.'); + toast.error(errMessage(err, 'Transaction could not be unmatched.')); } finally { setTransactionBusyId(null); } @@ -442,9 +458,9 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa if (!similar.find(tx => tx.id === unmatchTarget.id)) { similar.unshift(unmatchTarget); } - let rules = []; + let rules: { id: number; merchant: string }[] = []; 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)); } catch { // ignore — rules are optional @@ -462,16 +478,19 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa const { similar, checkedIds, removeRuleId } = bulkUnmatch; const matches = similar .filter(tx => checkedIds.has(tx.id) && tx.linked_payment) - .map(tx => ({ - transaction_id: tx.id, - payment_id: tx.linked_payment.id, - payment_source: tx.linked_payment.payment_source, - })); + .map(tx => { + const lp = tx.linked_payment as { id: number; payment_source?: string }; + return { + transaction_id: tx.id, + payment_id: lp.id, + payment_source: lp.payment_source, + }; + }); if (matches.length === 0) { closeUnmatch(); return; } setBulkBusy(true); try { await api.unmatchTransactionBulk(matches); - if (removeRuleId) { + if (removeRuleId && bill?.id) { try { await api.deleteMerchantRule(bill.id, removeRuleId); } catch { @@ -483,7 +502,7 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); } catch (err) { - toast.error(err.message || 'Could not unmatch transactions.'); + toast.error(errMessage(err, 'Could not unmatch transactions.')); } finally { setBulkBusy(false); } @@ -533,10 +552,10 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa snowball_exempt: snowballExempt, }; try { - let savedBill; + let savedBill: Bill; if (isNew) { 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 { savedBill = await api.createBill(data); } @@ -553,21 +572,21 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa onSave(savedBill); setDialogOpen(false); } catch (err) { - toast.error(err.message || 'Failed to save bill.'); + toast.error(errMessage(err, 'Failed to save bill.')); } }, null); async function handleDeactivate() { if (!bill?.id) return; 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; await api.updateBill(bill.id, payload); toast.success(bill.active ? 'Bill deactivated' : 'Bill reactivated'); onSave?.(); setDialogOpen(false); } 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 diff --git a/client/components/bill-modal/DebtDetailsSection.tsx b/client/components/bill-modal/DebtDetailsSection.tsx index d757ed0..9685eb1 100644 --- a/client/components/bill-modal/DebtDetailsSection.tsx +++ b/client/components/bill-modal/DebtDetailsSection.tsx @@ -4,8 +4,8 @@ import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import type { Dispatch, SetStateAction } from 'react'; -type FormErrors = Record; -type Validator = (v: string) => string | null; +type FormErrors = Record; +type Validator = (v: string) => string; interface DebtDetailsSectionProps { inp?: string;