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 { 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<string, string>;
|
||||
|
||||
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<FormErrors>({});
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(false);
|
||||
const [linkedTransactions, setLinkedTransactions] = useState([]);
|
||||
const [linkedTransactions, setLinkedTransactions] = useState<BankTransaction[]>([]);
|
||||
const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false);
|
||||
const [transactionBusyId, setTransactionBusyId] = useState(null);
|
||||
const [transactionBusyId, setTransactionBusyId] = useState<number | null>(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<Payment | null>(null);
|
||||
const [deletePaymentTarget, setDeletePaymentTarget] = useState<Payment | null>(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<Date | 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
|
||||
|
|
@ -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<BankTransaction | null>(null);
|
||||
const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false);
|
||||
const [bulkUnmatch, setBulkUnmatch] = useState(null);
|
||||
const [bulkUnmatch, setBulkUnmatch] = useState<BulkUnmatchState | null>(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
|
|||
<AutopayTrustIndicator
|
||||
isNew={isNew}
|
||||
autopay={autopay}
|
||||
stats={bill?.autopay_stats}
|
||||
stats={bill?.autopay_stats as AutopayStats | null}
|
||||
verifiedAt={localVerifiedAt}
|
||||
onVerify={handleVerifyAutopay}
|
||||
/>
|
||||
|
|
@ -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<string, string | null | undefined>;
|
||||
type Validator = (v: string) => string | null;
|
||||
type FormErrors = Record<string, string>;
|
||||
type Validator = (v: string) => string;
|
||||
|
||||
interface DebtDetailsSectionProps {
|
||||
inp?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue