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:
parent
31ff6adbd9
commit
73be95d12d
|
|
@ -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 => {
|
||||||
transaction_id: tx.id,
|
const lp = tx.linked_payment as { id: number; payment_source?: string };
|
||||||
payment_id: tx.linked_payment.id,
|
return {
|
||||||
payment_source: tx.linked_payment.payment_source,
|
transaction_id: tx.id,
|
||||||
}));
|
payment_id: lp.id,
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue