BillTracker/client/components/BillModal.jsx

1094 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useActionState, useEffect, useState } from 'react';
import { Copy, Loader2 } from 'lucide-react';
import { validateNonNegativeMoney } from '@/lib/money';
import { toast } from 'sonner';
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,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } 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 LinkedTransactionsSection from '@/components/bill-modal/LinkedTransactionsSection';
import TemplateSection from '@/components/bill-modal/TemplateSection';
import { transactionTitle, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
import {
BILLING_SCHEDULE_OPTIONS,
billingCycleForSchedule,
defaultCycleDayForSchedule,
scheduleValue,
} from '@/lib/billingSchedule';
function getOrdinalSuffix(day) {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
// Radix Select crashes on empty string value
const CAT_NONE = 'none';
const DEBT_KEYWORDS = ['credit', 'loan', 'mortgage', 'housing', 'debt'];
const SNOWBALL_KEYWORDS = ['credit', 'loan', 'debt', 'mortgage', 'housing'];
const SUBSCRIPTION_TYPES = [
['streaming', 'Streaming'],
['software', 'Software'],
['cloud', 'Cloud'],
['music', 'Music'],
['news', 'News'],
['fitness', 'Fitness'],
['gaming', 'Gaming'],
['utilities', 'Utilities'],
['insurance', 'Insurance'],
['other', 'Other'],
];
function isDebtCat(categories, catId) {
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;
}
function isSnowballCat(categories, catId) {
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;
}
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
const isNew = !bill;
const sourceBill = bill || initialBill || null;
const [name, setName] = useState(sourceBill?.name || '');
const [categoryId, setCategoryId] = useState(sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE);
const [dueDay, setDueDay] = useState(String(sourceBill?.due_day || ''));
const [expectedAmount, setExpected] = useState(String(sourceBill?.expected_amount || ''));
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 [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);
const [isSubscription, setIsSubscription] = useState(!!sourceBill?.is_subscription);
const [subscriptionType, setSubscriptionType] = useState(sourceBill?.subscription_type || 'other');
const [reminderDaysBefore, setReminderDaysBefore] = useState(String(sourceBill?.reminder_days_before ?? 3));
const [has2fa, setHas2fa] = useState(!!sourceBill?.has_2fa);
const [website, setWebsite] = useState(sourceBill?.website || '');
const [username, setUsername] = useState(sourceBill?.username || '');
const [accountInfo, setAccountInfo] = useState(sourceBill?.account_info || '');
const [notes, setNotes] = useState(sourceBill?.notes || '');
const [currentBalance, setCurrentBalance] = useState(sourceBill?.current_balance == null ? '' : String(sourceBill.current_balance));
const [minimumPayment, setMinimumPayment] = useState(sourceBill?.minimum_payment == null ? '' : String(sourceBill.minimum_payment));
const [snowballInclude, setSnowballInclude] = useState(!!sourceBill?.snowball_include);
const [snowballExempt, setSnowballExempt] = useState(!!sourceBill?.snowball_exempt);
const [syncingPayments, setSyncingPayments] = useState(false);
// Track whether rules exist locally so the Sync button appears immediately
// after the first rule is added without waiting for sourceBill to refetch.
const [localHasRules, setLocalHasRules] = useState(!!sourceBill?.has_merchant_rule);
const [showDebtSection, setShowDebtSection] = useState(
() => isDebtCat(categories, sourceBill?.category_id ? String(sourceBill.category_id) : CAT_NONE)
|| !!sourceBill?.snowball_include
|| !!sourceBill?.snowball_exempt
|| sourceBill?.current_balance != null
|| sourceBill?.minimum_payment != null
);
const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
const [errors, setErrors] = useState({});
const [payments, setPayments] = useState([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [linkedTransactions, setLinkedTransactions] = useState([]);
const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false);
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 [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
);
// Controls the outer Dialog's open state so it closes via its own animation
// rather than being abruptly unmounted, which can leave Radix cleanup in a broken state.
const [dialogOpen, setDialogOpen] = useState(true);
// Deactivate dialog state
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [deactivateReason, setDeactivateReason] = useState('');
// Unmatch dialog state
const [unmatchTarget, setUnmatchTarget] = useState(null);
const [unmatchConfirmOpen, setUnmatchConfirmOpen] = useState(false);
const [bulkUnmatch, setBulkUnmatch] = useState(null);
const [bulkBusy, setBulkBusy] = useState(false);
const isDebtCategory = isDebtCat(categories, categoryId);
const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
async function loadPayments() {
if (isNew || !bill?.id) return;
setPaymentsLoading(true);
try {
const data = await api.billPayments(bill.id, 1, 100);
setPayments(data.payments || []);
} catch (err) {
toast.error(err.message || 'Failed to load payment history.');
} finally {
setPaymentsLoading(false);
}
}
async function loadLinkedTransactions() {
if (isNew || !bill?.id) return;
setLinkedTransactionsLoading(true);
try {
const data = await api.billTransactions(bill.id);
setLinkedTransactions(data.transactions || []);
} catch (err) {
toast.error(err.message || 'Failed to load linked transactions.');
} finally {
setLinkedTransactionsLoading(false);
}
}
useEffect(() => {
loadPayments();
loadLinkedTransactions();
// Intentional: reload only when the bill identity changes. The loaders are
// recreated each render, so listing them would reload on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bill?.id]);
// Imported payments (via sync or a merchant-rule historical import) must
// refresh the payment list AND the Tracker behind the modal, not just the
// linked transactions — matching the unmatch handlers.
async function refreshAfterImport() {
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}
async function handleSyncBillPayments() {
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.',
});
try {
const result = await promise;
if (result.added > 0) await refreshAfterImport();
if (result.late_attributions?.length) {
window.dispatchEvent(new CustomEvent('tracker:late-attributions', {
detail: { attributions: result.late_attributions },
}));
}
} catch {
// toast.promise already surfaced the error
} finally {
setSyncingPayments(false);
}
}
async function handleRulesChanged() {
setLocalHasRules(true);
await refreshAfterImport();
}
const validateName = (val) => {
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) => {
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';
return '';
};
// 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 validateInterestRate = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num)) return 'Invalid number';
if (num < 0 || num > 100) return 'Interest rate must be between 0 and 100';
return '';
};
const validateForm = () => {
const newErrors = {
name: validateName(name),
dueDay: validateDueDay(dueDay),
expectedAmount: validateExpectedAmount(expectedAmount),
interestRate: validateInterestRate(interestRate),
currentBalance: validateCurrentBalance(currentBalance),
minimumPayment: validateMinimumPayment(minimumPayment),
};
setErrors(newErrors);
return Object.values(newErrors).every(err => err === '');
};
// 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) => {
setErrors(prev => ({ ...prev, [field]: validator(value) }));
};
const handleCategoryChange = (val) => {
setCategoryId(val);
if (isDebtCat(categories, val)) {
setShowDebtSection(true);
} else {
setSnowballExempt(false);
}
};
const handleSnowballVisibilityChange = (checked) => {
if (checked) {
setSnowballExempt(false);
setSnowballInclude(!isSnowballCategory);
} else {
setSnowballInclude(false);
setSnowballExempt(isSnowballCategory);
}
};
async function handleVerifyAutopay() {
if (!bill?.id) return;
try {
const res = await api.verifyAutopay(bill.id);
setLocalVerifiedAt(new Date(res.autopay_verified_at));
toast.success('Autopay marked as verified.');
} catch (err) {
toast.error(err.message || 'Failed to verify autopay.');
}
}
const handleAutopayChange = (checked) => {
setAutopay(checked);
if (checked) {
setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid'));
} else {
setAutodraftStatus('none');
setAutoMarkPaid(false);
}
};
const handleCycleTypeChange = (value) => {
setCycleType(value);
setCycleDay(defaultCycleDayForSchedule(value));
};
function resetPaymentForm() {
setPaymentAmount('');
setPaymentDate(todayStr());
setPaymentMethod('manual');
setPaymentNotes('');
setEditingPayment(null);
}
function startAddPayment() {
resetPaymentForm();
setPaymentFormOpen(true);
}
function startEditPayment(payment) {
setEditingPayment(payment);
setPaymentAmount(String(payment.amount ?? ''));
setPaymentDate(payment.paid_date || todayStr());
setPaymentMethod(payment.method || 'manual');
setPaymentNotes(payment.notes || '');
setPaymentFormOpen(true);
}
async function handlePaymentSubmit(e) {
e.preventDefault();
const parsedAmount = parseFloat(paymentAmount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
toast.error('Enter a positive payment amount.');
return;
}
if (!paymentDate) {
toast.error('Choose a paid date.');
return;
}
setPaymentBusy(true);
try {
const payload = {
amount: parsedAmount,
paid_date: paymentDate,
method: paymentMethod,
notes: paymentNotes || null,
payment_source: 'manual',
};
if (editingPayment) {
await api.updatePayment(editingPayment.id, payload);
toast.success('Payment updated');
} else {
await api.createPayment({ ...payload, bill_id: bill.id });
toast.success('Payment added');
}
resetPaymentForm();
setPaymentFormOpen(false);
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Payment could not be saved.');
} finally {
setPaymentBusy(false);
}
}
async function handleDeletePayment() {
if (!deletePaymentTarget) return;
const payment = deletePaymentTarget;
setPaymentBusy(true);
try {
await api.deletePayment(payment.id);
setDeletePaymentTarget(null);
toast.success('Payment moved to recovery.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Failed to restore payment.');
}
},
},
});
if (editingPayment?.id === payment.id) {
resetPaymentForm();
setPaymentFormOpen(false);
}
await loadPayments();
onSave?.();
} catch (err) {
toast.error(err.message || 'Payment could not be removed.');
} finally {
setPaymentBusy(false);
}
}
function openUnmatch(transaction) {
setUnmatchTarget(transaction);
setBulkUnmatch(null);
setUnmatchConfirmOpen(false);
}
function closeUnmatch() {
setUnmatchTarget(null);
setBulkUnmatch(null);
setUnmatchConfirmOpen(false);
}
async function handleSingleUnmatch() {
if (!unmatchTarget?.id) return;
setTransactionBusyId(unmatchTarget.id);
try {
await api.unmatchTransaction(unmatchTarget.id);
toast.success('Transaction unmatched');
closeUnmatch();
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
} catch (err) {
toast.error(err.message || 'Transaction could not be unmatched.');
} finally {
setTransactionBusyId(null);
}
}
async function handleOpenBulkUnmatch() {
if (!unmatchTarget || !bill?.id) return;
const targetPayee = transactionTitle(unmatchTarget);
const similar = linkedTransactions.filter(tx =>
isSimilarPayee(transactionTitle(tx), targetPayee)
);
if (!similar.find(tx => tx.id === unmatchTarget.id)) {
similar.unshift(unmatchTarget);
}
let rules = [];
try {
const ruleData = await api.billMerchantRules(bill.id);
rules = (ruleData || []).filter(r => isSimilarPayee(r.merchant, targetPayee));
} catch {
// ignore — rules are optional
}
setBulkUnmatch({
similar,
rules,
checkedIds: new Set(similar.map(tx => tx.id)),
removeRuleId: null,
});
}
async function handleBulkConfirm() {
if (!bulkUnmatch) return;
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,
}));
if (matches.length === 0) { closeUnmatch(); return; }
setBulkBusy(true);
try {
await api.unmatchTransactionBulk(matches);
if (removeRuleId) {
try {
await api.deleteMerchantRule(bill.id, removeRuleId);
} catch {
toast.error('Transactions unmatched, but could not remove merchant rule.');
}
}
toast.success(`${matches.length} transaction${matches.length !== 1 ? 's' : ''} unmatched`);
closeUnmatch();
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
} catch (err) {
toast.error(err.message || 'Could not unmatch transactions.');
} finally {
setBulkBusy(false);
}
}
const [, submitAction, isPending] = useActionState(async () => {
if (!validateForm()) {
toast.error('Please fix the form errors before saving.');
return;
}
// validateForm() already enforced due-day (131) and interest-rate (0100)
// ranges and blocked the save with field errors, so these are just parses.
const parsedDueDay = Number(dueDay);
const trimmedInterestRate = interestRate.trim();
const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate);
const data = {
source_bill_id: sourceBill?.source_bill_id,
name: name.trim(),
category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10),
due_day: parsedDueDay,
override_due_date: sourceBill?.override_due_date,
expected_amount: parseFloat(expectedAmount) || 0,
interest_rate: parsedInterestRate,
billing_cycle: billingCycleForSchedule(cycleType),
cycle_type: cycleType,
cycle_day: cycleDay,
autopay_enabled: autopay,
autodraft_status: autopay ? autodraftStatus : 'none',
auto_mark_paid: canAutoMarkPaid && autoMarkPaid,
is_subscription: isSubscription,
subscription_type: isSubscription ? subscriptionType : null,
reminder_days_before: parseInt(reminderDaysBefore || '3', 10),
subscription_source: sourceBill?.subscription_source || 'manual',
subscription_detected_at: sourceBill?.subscription_detected_at,
has_2fa: has2fa,
website: website || null,
username: username || null,
account_info: accountInfo || null,
notes: notes || null,
history_visibility: sourceBill?.history_visibility,
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
snowball_order: sourceBill?.snowball_order,
snowball_include: snowballInclude,
snowball_exempt: snowballExempt,
};
try {
let savedBill;
if (isNew) {
if (data.source_bill_id) {
savedBill = await api.duplicateBill(data.source_bill_id, data);
} else {
savedBill = await api.createBill(data);
}
toast.success(`${data.name} added`);
} else {
savedBill = await api.updateBill(bill.id, data);
toast.success(`${data.name} updated`);
}
if (saveTemplate) {
const safeTemplateName = templateName.trim() || data.name;
await api.saveBillTemplate({ name: safeTemplateName, data });
toast.success('Template saved');
}
onSave(savedBill);
setDialogOpen(false);
} catch (err) {
toast.error(err.message || 'Failed to save bill.');
}
}, null);
async function handleDeactivate() {
if (!bill?.id) return;
try {
const payload = { 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.');
}
}
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
return (
<Dialog open={dialogOpen} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{isNew ? 'Add Bill' : 'Edit Bill'}
</DialogTitle>
</DialogHeader>
<form id="bill-modal-form" action={submitAction}>
<div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
{/* Name */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Name *</Label>
<Input
className={cn(inp, errors.name && 'border-red-500 focus-visible:ring-red-500')}
placeholder="e.g. Electricity"
value={name}
onChange={e => {
setName(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300);
}}
onBlur={() => handleBlur('name', name, validateName)}
required
/>
{errors.name && (
<span className="text-[10px] text-red-500 font-medium">{errors.name}</span>
)}
</div>
{/* Category */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
<Select value={categoryId} onValueChange={handleCategoryChange}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue placeholder="— none —" />
</SelectTrigger>
<SelectContent>
<SelectItem value={CAT_NONE}> none </SelectItem>
{categories.map(c => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Due Day */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Due day of month *</Label>
<Input
className={cn(inp, errors.dueDay && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="1" max="31" required
value={dueDay}
onChange={e => {
setDueDay(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300);
}}
onBlur={() => handleBlur('dueDay', dueDay, validateDueDay)}
/>
{errors.dueDay && (
<span className="text-[10px] text-red-500 font-medium">{errors.dueDay}</span>
)}
<p className="text-[10px] text-muted-foreground/70">
Enter the day of the month this bill is due.
</p>
</div>
{/* Expected Amount */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Expected Amount ($)</Label>
<Input
className={cn(inp, 'font-mono', errors.expectedAmount && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" step="0.01" placeholder="0.00"
value={expectedAmount}
onChange={e => {
setExpected(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300);
}}
onBlur={() => handleBlur('expectedAmount', expectedAmount, validateExpectedAmount)}
/>
{errors.expectedAmount && (
<span className="text-[10px] text-red-500 font-medium">{errors.expectedAmount}</span>
)}
</div>
{/* Billing Schedule */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Schedule</Label>
<Select value={cycleType} onValueChange={handleCycleTypeChange}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BILLING_SCHEDULE_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Cycle Day */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Day</Label>
{cycleType === 'monthly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[...Array(31)].map((_, i) => (
<SelectItem key={i+1} value={String(i+1)}>{i+1}{getOrdinalSuffix(i+1)}</SelectItem>
))}
</SelectContent>
</Select>
) : cycleType === 'weekly' || cycleType === 'biweekly' ? (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monday">Monday</SelectItem>
<SelectItem value="tuesday">Tuesday</SelectItem>
<SelectItem value="wednesday">Wednesday</SelectItem>
<SelectItem value="thursday">Thursday</SelectItem>
<SelectItem value="friday">Friday</SelectItem>
<SelectItem value="saturday">Saturday</SelectItem>
<SelectItem value="sunday">Sunday</SelectItem>
</SelectContent>
</Select>
) : (
<Select value={cycleDay} onValueChange={setCycleDay}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
].map((label, index) => (
<SelectItem key={label} value={String(index + 1)}>{label}</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
cycleType === 'quarterly' ? 'First month of the quarterly cycle' :
'Month due each year'}
</p>
</div>
{/* Subscription Details */}
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={isSubscription}
onChange={e => setIsSubscription(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-border accent-primary"
/>
<span>
<span className="block text-sm font-medium text-muted-foreground group-hover:text-foreground transition-colors">
Track as subscription
</span>
<span className="mt-0.5 block text-[10px] text-muted-foreground/70">
Adds this bill to the subscription view with monthly equivalent cost and SimpleFIN recommendation matching.
</span>
</span>
</label>
{isSubscription && (
<div className="mt-3 border-t border-border/40 pt-3">
<div className="space-y-1.5 sm:max-w-[50%]">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Subscription Type</Label>
<Select value={subscriptionType} onValueChange={setSubscriptionType}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SUBSCRIPTION_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */}
</div>
)}
</div>
{/* Reminder lead time — applies to every bill (the notifier honors
reminder_days_before for the early "due soon" reminder). */}
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="space-y-1.5 sm:max-w-[50%]">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days Before</Label>
<Input
className={cn(inp, 'tracker-number')}
type="number"
min="0"
max="30"
value={reminderDaysBefore}
onChange={e => setReminderDaysBefore(e.target.value)}
/>
<p className="text-[10px] text-muted-foreground/70">
Get an early reminder this many days before the due date (0-30). Also needs reminders enabled in Settings.
</p>
</div>
</div>
{/* Debt / Snowball Details — collapsible */}
<DebtDetailsSection
inp={inp}
errors={errors}
setErrors={setErrors}
showDebtSection={showDebtSection}
setShowDebtSection={setShowDebtSection}
isSnowballCategory={isSnowballCategory}
showOnSnowball={showOnSnowball}
interestRate={interestRate}
setInterestRate={setInterestRate}
currentBalance={currentBalance}
setCurrentBalance={setCurrentBalance}
minimumPayment={minimumPayment}
setMinimumPayment={setMinimumPayment}
validateInterestRate={validateInterestRate}
validateCurrentBalance={validateCurrentBalance}
validateMinimumPayment={validateMinimumPayment}
handleBlur={handleBlur}
onSnowballVisibilityChange={handleSnowballVisibilityChange}
/>
{/* Website */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Website</Label>
<Input
className={inp}
placeholder="https://…"
value={website}
onChange={e => setWebsite(e.target.value)}
/>
</div>
{/* Username */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Username / Email</Label>
<Input
className={inp}
value={username}
onChange={e => setUsername(e.target.value)}
/>
</div>
{/* Account Info */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Account Info</Label>
<Input
className={inp}
placeholder="Last 4 digits, account #…"
value={accountInfo}
onChange={e => setAccountInfo(e.target.value)}
/>
</div>
{/* Checkboxes */}
<div className="space-y-2.5 flex flex-col justify-end">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={autopay}
onChange={e => handleAutopayChange(e.target.checked)}
className="h-4 w-4 rounded border-border accent-emerald-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Autopay / Autodraft
</span>
</label>
<label
className={cn('flex items-center gap-2.5 group', canAutoMarkPaid ? 'cursor-pointer' : 'cursor-not-allowed opacity-50')}
title={canAutoMarkPaid ? undefined : 'Auto-mark requires Autodraft Status set to Assumed paid'}
>
<input
type="checkbox"
checked={canAutoMarkPaid && autoMarkPaid}
onChange={e => setAutoMarkPaid(e.target.checked)}
disabled={!canAutoMarkPaid}
className="h-4 w-4 rounded border-border accent-sky-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Auto-mark paid on due date
</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={has2fa}
onChange={e => setHas2fa(e.target.checked)}
className="h-4 w-4 rounded border-border accent-violet-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Has 2FA
</span>
</label>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Autodraft Status</Label>
<Select value={autodraftStatus} onValueChange={setAutodraftStatus} disabled={!autopay}>
<SelectTrigger className={cn(inp, !autopay && 'opacity-60')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="assumed_paid">Assumed paid</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
</SelectContent>
</Select>
</div>
{/* Autopay trust indicator — edit mode only */}
<AutopayTrustIndicator
isNew={isNew}
autopay={autopay}
stats={bill?.autopay_stats}
verifiedAt={localVerifiedAt}
onVerify={handleVerifyAutopay}
/>
{/* Notes */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<textarea
rows={2}
value={notes}
onChange={e => setNotes(e.target.value)}
className={cn(
'w-full rounded-md border border-border/60 bg-background/50 px-3 py-2',
'text-sm text-foreground placeholder:text-muted-foreground/50',
'resize-none outline-none focus:ring-1 focus:ring-ring transition-shadow',
)}
placeholder="Any additional notes…"
/>
</div>
<TemplateSection
inp={inp}
saveTemplate={saveTemplate}
setSaveTemplate={setSaveTemplate}
templateName={templateName}
setTemplateName={setTemplateName}
namePlaceholder={name}
/>
</div>
</form>
{!isNew && (
<div className="mt-4 border-t border-border/50 pt-4">
<PaymentHistoryList
payments={payments}
paymentsLoading={paymentsLoading}
paymentBusy={paymentBusy}
onAdd={startAddPayment}
onEdit={startEditPayment}
onDelete={setDeletePaymentTarget}
/>
{/* Bank matching rules + linked transactions */}
<LinkedTransactionsSection
isNew={isNew}
billId={sourceBill?.id}
billName={sourceBill?.name}
localHasRules={localHasRules}
syncingPayments={syncingPayments}
onSync={handleSyncBillPayments}
onRulesChanged={handleRulesChanged}
linkedTransactions={linkedTransactions}
linkedTransactionsLoading={linkedTransactionsLoading}
transactionBusyId={transactionBusyId}
onUnmatch={openUnmatch}
/>
{paymentFormOpen && (
<PaymentFormFields
inp={inp}
editingPayment={editingPayment}
paymentBusy={paymentBusy}
amount={paymentAmount} setAmount={setPaymentAmount}
date={paymentDate} setDate={setPaymentDate}
method={paymentMethod} setMethod={setPaymentMethod}
notes={paymentNotes} setNotes={setPaymentNotes}
onSubmit={handlePaymentSubmit}
onCancel={() => { resetPaymentForm(); setPaymentFormOpen(false); }}
/>
)}
</div>
)}
<DialogFooter className="mt-2 gap-2 sm:justify-between">
<div className="flex gap-2">
{!isNew && onDuplicate && (
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => onDuplicate(bill)}
className="gap-2 text-xs"
>
<Copy className="h-3.5 w-3.5" />
Duplicate
</Button>
)}
{!isNew && (
<Button
type="button"
variant="outline"
disabled={isPending}
onClick={() => bill?.active ? setDeactivateOpen(true) : handleDeactivate()}
className={cn('gap-1.5 text-xs', bill?.active ? 'border-destructive/40 text-destructive hover:bg-destructive/10 hover:border-destructive/60' : 'border-emerald-500/40 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10')}
>
{bill?.active ? 'Deactivate' : 'Reactivate'}
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={isPending} onClick={() => setDialogOpen(false)} className="text-xs">
Cancel
</Button>
<Button type="submit" form="bill-modal-form" disabled={isPending} className="gap-1.5 text-xs">
{isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isNew ? 'Add Bill' : 'Save Changes'}
</Button>
</div>
</DialogFooter>
<AlertDialog open={deactivateOpen} onOpenChange={open => { if (!open) { setDeactivateOpen(false); setDeactivateReason(''); } }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate "{bill?.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This bill will be hidden from the tracker. You can reactivate it at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1.5 py-1">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reason (optional)
</label>
<select
value={deactivateReason}
onChange={e => setDeactivateReason(e.target.value)}
className="w-full rounded-md border border-border/60 bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="">Select a reason</option>
<option value="Moved to spouse">Moved to spouse</option>
<option value="Switched providers">Switched providers</option>
<option value="Paid off">Paid off</option>
<option value="Cancelled">Cancelled</option>
<option value="Other">Other</option>
</select>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => { setDeactivateOpen(false); setDeactivateReason(''); }}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => { setDeactivateOpen(false); handleDeactivate(); }}
>
Deactivate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!deletePaymentTarget}
onOpenChange={open => {
if (!open && !paymentBusy) setDeletePaymentTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This moves the payment to recovery and removes it from bill status calculations.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={paymentBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={paymentBusy}
onClick={handleDeletePayment}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{paymentBusy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<UnmatchDialogs
unmatchTarget={unmatchTarget}
bulkUnmatch={bulkUnmatch}
setBulkUnmatch={setBulkUnmatch}
unmatchConfirmOpen={unmatchConfirmOpen}
setUnmatchConfirmOpen={setUnmatchConfirmOpen}
transactionBusyId={transactionBusyId}
bulkBusy={bulkBusy}
closeUnmatch={closeUnmatch}
onSingleUnmatch={handleSingleUnmatch}
onOpenBulkUnmatch={handleOpenBulkUnmatch}
onBulkConfirm={handleBulkConfirm}
/>
</DialogContent>
</Dialog>
);
}