BillTracker/client/components/BillModal.jsx

1195 lines
51 KiB
React
Raw Normal View History

import { useActionState, useEffect, useState } from 'react';
import { Copy, Link2, Link2Off, Loader2, RefreshCw } from 'lucide-react';
import { validateNonNegativeMoney } from '@/lib/money';
2026-05-03 19:51:57 -05:00
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,
2026-05-03 19:51:57 -05:00
} from '@/components/ui/dialog';
2026-05-16 20:26:09 -05:00
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
2026-05-03 19:51:57 -05:00
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { api } from '@/api';
2026-05-16 20:26:09 -05:00
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import BillMerchantRules from '@/components/BillMerchantRules';
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 { transactionTitle, transactionDate, fmtTransactionAmount, isSimilarPayee } from '@/components/bill-modal/transactionDisplay';
import {
BILLING_SCHEDULE_OPTIONS,
billingCycleForSchedule,
defaultCycleDayForSchedule,
scheduleValue,
} from '@/lib/billingSchedule';
2026-05-03 19:51:57 -05:00
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';
}
}
2026-05-03 19:51:57 -05:00
// Radix Select crashes on empty string value
const CAT_NONE = 'none';
2026-05-14 02:11:54 -05:00
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'],
];
2026-05-14 02:11:54 -05:00
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;
}
2026-05-16 10:17:24 -05:00
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;
}
2026-05-14 02:11:54 -05:00
2026-05-16 15:38:28 -05:00
export default function BillModal({ bill, initialBill, categories, onClose, onSave, onDuplicate }) {
2026-05-03 19:51:57 -05:00
const isNew = !bill;
2026-05-16 15:38:28 -05:00
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));
2026-05-16 15:38:28 -05:00
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));
2026-05-16 15:38:28 -05:00
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);
2026-05-14 02:11:54 -05:00
const [showDebtSection, setShowDebtSection] = useState(
2026-05-16 15:38:28 -05:00
() => 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
2026-05-14 02:11:54 -05:00
);
2026-05-16 15:38:28 -05:00
const [saveTemplate, setSaveTemplate] = useState(false);
const [templateName, setTemplateName] = useState('');
2026-05-09 13:03:36 -05:00
const [errors, setErrors] = useState({});
2026-05-16 20:26:09 -05:00
const [payments, setPayments] = useState([]);
const [paymentsLoading, setPaymentsLoading] = useState(false);
const [linkedTransactions, setLinkedTransactions] = useState([]);
const [linkedTransactionsLoading, setLinkedTransactionsLoading] = useState(false);
const [transactionBusyId, setTransactionBusyId] = useState(null);
2026-05-16 20:26:09 -05:00
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
);
2026-05-09 13:03:36 -05:00
// 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);
2026-05-14 02:11:54 -05:00
const isDebtCategory = isDebtCat(categories, categoryId);
2026-05-16 10:17:24 -05:00
const isSnowballCategory = isSnowballCat(categories, categoryId);
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
2026-05-16 15:38:28 -05:00
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
2026-05-14 02:11:54 -05:00
2026-05-16 20:26:09 -05:00
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);
}
}
2026-05-16 20:26:09 -05:00
useEffect(() => {
loadPayments();
loadLinkedTransactions();
2026-05-16 20:26:09 -05:00
}, [bill?.id]);
2026-05-09 13:03:36 -05:00
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');
2026-05-09 13:03:36 -05:00
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),
2026-05-14 02:11:54 -05:00
currentBalance: validateCurrentBalance(currentBalance),
minimumPayment: validateMinimumPayment(minimumPayment),
2026-05-09 13:03:36 -05:00
};
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) }));
2026-05-09 13:03:36 -05:00
};
2026-05-14 02:11:54 -05:00
const handleCategoryChange = (val) => {
setCategoryId(val);
2026-05-14 03:00:01 -05:00
if (isDebtCat(categories, val)) {
setShowDebtSection(true);
} else {
setSnowballExempt(false);
}
};
const handleSnowballVisibilityChange = (checked) => {
if (checked) {
setSnowballExempt(false);
2026-05-16 10:17:24 -05:00
setSnowballInclude(!isSnowballCategory);
2026-05-14 03:00:01 -05:00
} else {
setSnowballInclude(false);
2026-05-16 10:17:24 -05:00
setSnowballExempt(isSnowballCategory);
2026-05-14 03:00:01 -05:00
}
2026-05-09 13:03:36 -05:00
};
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.');
}
}
2026-05-16 15:38:28 -05:00
const handleAutopayChange = (checked) => {
setAutopay(checked);
if (checked) {
setAutodraftStatus(prev => (prev && prev !== 'none' ? prev : 'assumed_paid'));
} else {
setAutodraftStatus('none');
setAutoMarkPaid(false);
}
};
2026-05-16 20:26:09 -05:00
const handleCycleTypeChange = (value) => {
setCycleType(value);
setCycleDay(defaultCycleDayForSchedule(value));
2026-05-16 20:26:09 -05:00
};
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 () => {
2026-05-09 13:03:36 -05:00
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.
2026-05-03 19:51:57 -05:00
const parsedDueDay = Number(dueDay);
const trimmedInterestRate = interestRate.trim();
const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate);
const data = {
2026-05-16 15:38:28 -05:00
source_bill_id: sourceBill?.source_bill_id,
2026-05-03 19:51:57 -05:00
name: name.trim(),
category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10),
due_day: parsedDueDay,
2026-05-16 15:38:28 -05:00
override_due_date: sourceBill?.override_due_date,
2026-05-03 19:51:57 -05:00
expected_amount: parseFloat(expectedAmount) || 0,
interest_rate: parsedInterestRate,
billing_cycle: billingCycleForSchedule(cycleType),
cycle_type: cycleType,
cycle_day: cycleDay,
2026-05-03 19:51:57 -05:00
autopay_enabled: autopay,
2026-05-16 15:38:28 -05:00
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,
2026-05-03 19:51:57 -05:00
has_2fa: has2fa,
website: website || null,
username: username || null,
account_info: accountInfo || null,
notes: notes || null,
2026-05-16 15:38:28 -05:00
history_visibility: sourceBill?.history_visibility,
2026-05-14 02:11:54 -05:00
current_balance: currentBalance === '' ? null : parseFloat(currentBalance),
minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment),
2026-05-16 15:38:28 -05:00
snowball_order: sourceBill?.snowball_order,
2026-05-14 02:11:54 -05:00
snowball_include: snowballInclude,
2026-05-14 03:00:01 -05:00
snowball_exempt: snowballExempt,
2026-05-03 19:51:57 -05:00
};
try {
let savedBill;
2026-05-03 19:51:57 -05:00
if (isNew) {
2026-05-16 15:38:28 -05:00
if (data.source_bill_id) {
savedBill = await api.duplicateBill(data.source_bill_id, data);
2026-05-16 15:38:28 -05:00
} else {
savedBill = await api.createBill(data);
2026-05-16 15:38:28 -05:00
}
toast.success(`${data.name} added`);
2026-05-03 19:51:57 -05:00
} else {
savedBill = await api.updateBill(bill.id, data);
toast.success(`${data.name} updated`);
2026-05-03 19:51:57 -05:00
}
2026-05-16 15:38:28 -05:00
if (saveTemplate) {
const safeTemplateName = templateName.trim() || data.name;
await api.saveBillTemplate({ name: safeTemplateName, data });
toast.success('Template saved');
}
onSave(savedBill);
setDialogOpen(false);
2026-05-03 19:51:57 -05:00
} catch (err) {
toast.error(err.message || 'Failed to save bill.');
2026-05-03 19:51:57 -05:00
}
}, null);
2026-05-03 19:51:57 -05:00
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.');
}
}
2026-05-09 13:03:36 -05:00
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
2026-05-03 19:51:57 -05:00
return (
<Dialog open={dialogOpen} onOpenChange={v => { if (!v) onClose(); }}>
2026-05-16 20:26:09 -05:00
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
2026-05-03 19:51:57 -05:00
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
{isNew ? 'Add Bill' : 'Edit Bill'}
</DialogTitle>
</DialogHeader>
<form id="bill-modal-form" action={submitAction}>
2026-05-04 13:14:32 -05:00
<div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
2026-05-03 19:51:57 -05:00
{/* Name */}
<div className="col-span-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Name *</Label>
<Input
2026-05-09 13:03:36 -05:00
className={cn(inp, errors.name && 'border-red-500 focus-visible:ring-red-500')}
2026-05-03 19:51:57 -05:00
placeholder="e.g. Electricity"
value={name}
2026-05-09 13:03:36 -05:00
onChange={e => {
setName(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300);
}}
onBlur={() => handleBlur('name', name, validateName)}
2026-05-03 19:51:57 -05:00
required
/>
2026-05-09 13:03:36 -05:00
{errors.name && (
<span className="text-[10px] text-red-500 font-medium">{errors.name}</span>
)}
2026-05-03 19:51:57 -05:00
</div>
{/* Category */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
2026-05-14 02:11:54 -05:00
<Select value={categoryId} onValueChange={handleCategoryChange}>
2026-05-03 19:51:57 -05:00
<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
2026-05-09 13:03:36 -05:00
className={cn(inp, errors.dueDay && 'border-red-500 focus-visible:ring-red-500')}
2026-05-03 19:51:57 -05:00
type="number" min="1" max="31" required
value={dueDay}
2026-05-09 13:03:36 -05:00
onChange={e => {
setDueDay(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300);
}}
onBlur={() => handleBlur('dueDay', dueDay, validateDueDay)}
2026-05-03 19:51:57 -05:00
/>
2026-05-09 13:03:36 -05:00
{errors.dueDay && (
<span className="text-[10px] text-red-500 font-medium">{errors.dueDay}</span>
)}
2026-05-03 19:51:57 -05:00
<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
2026-05-09 13:03:36 -05:00
className={cn(inp, 'font-mono', errors.expectedAmount && 'border-red-500 focus-visible:ring-red-500')}
2026-05-03 19:51:57 -05:00
type="number" min="0" step="0.01" placeholder="0.00"
value={expectedAmount}
2026-05-09 13:03:36 -05:00
onChange={e => {
setExpected(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300);
}}
onBlur={() => handleBlur('expectedAmount', expectedAmount, validateExpectedAmount)}
2026-05-03 19:51:57 -05:00
/>
2026-05-09 13:03:36 -05:00
{errors.expectedAmount && (
<span className="text-[10px] text-red-500 font-medium">{errors.expectedAmount}</span>
)}
2026-05-03 19:51:57 -05:00
</div>
{/* Billing Schedule */}
2026-05-03 19:51:57 -05:00
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Schedule</Label>
2026-05-16 20:26:09 -05:00
<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>
) : (
2026-05-16 20:26:09 -05:00
<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">
2026-05-14 02:11:54 -05:00
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
2026-05-16 20:26:09 -05:00
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>
2026-05-14 03:00:01 -05:00
{/* 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}
/>
2026-05-14 02:11:54 -05:00
2026-05-03 19:51:57 -05:00
{/* 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}
2026-05-16 15:38:28 -05:00
onChange={e => handleAutopayChange(e.target.checked)}
2026-05-03 19:51:57 -05:00
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>
2026-05-16 15:38:28 -05:00
<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>
2026-05-03 19:51:57 -05:00
<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>
2026-05-16 15:38:28 -05:00
<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}
/>
2026-05-03 19:51:57 -05:00
{/* 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>
2026-05-16 15:38:28 -05:00
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={saveTemplate}
onChange={e => setSaveTemplate(e.target.checked)}
className="h-4 w-4 rounded border-border accent-primary"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Save this setup as a reusable template
</span>
</label>
{saveTemplate && (
<div className="mt-2 space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Template Name</Label>
<Input
className={inp}
value={templateName}
onChange={e => setTemplateName(e.target.value)}
placeholder={name || 'My bill template'}
/>
</div>
)}
</div>
2026-05-03 19:51:57 -05:00
</div>
</form>
2026-05-16 20:26:09 -05:00
{!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}
/>
2026-05-16 20:26:09 -05:00
{/* Bank Matching Rules */}
{!isNew && (
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Bank matching rules</p>
<p className="mt-0.5 text-[11px] text-muted-foreground/70">
Transactions whose description contains these patterns are automatically imported as payments.
</p>
</div>
{localHasRules && (
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 gap-1.5 text-xs"
disabled={syncingPayments}
title="Scan unmatched bank transactions and import any matching payments for this bill"
onClick={async () => {
setSyncingPayments(true);
// One toast that transitions loading → done.
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) {
// Imported payments must refresh the payment list AND the
// Tracker behind the modal (the row may now be covered).
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}
// Surface late-attribution prompts to the tracker page
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);
}
}}
>
{syncingPayments
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync</>}
</Button>
)}
</div>
<div className="px-3 py-3">
<BillMerchantRules
billId={sourceBill?.id}
billName={sourceBill?.name}
onRulesChanged={async () => {
setLocalHasRules(true);
// A historical import (fired after adding a rule) creates
// payments, so refresh the payment list AND the Tracker too —
// not just the linked transactions.
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
}}
/>
</div>
</div>
)}
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
</div>
2026-05-18 09:44:16 -05:00
<span className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium',
linkedTransactions.length > 0
? 'border-primary/20 bg-primary/5 text-primary'
: 'border-border/60 bg-muted/30 text-muted-foreground',
)}>
<Link2 className="h-3.5 w-3.5" />
2026-05-18 09:44:16 -05:00
{linkedTransactions.length}
</span>
</div>
{linkedTransactionsLoading ? (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
Loading linked transactions...
</div>
) : linkedTransactions.length === 0 ? (
<div className="flex items-center justify-center gap-2 px-3 py-8 text-sm text-muted-foreground">
<Link2Off className="h-4 w-4" />
No transactions linked to this bill yet.
</div>
) : (
<div className="max-h-56 divide-y divide-border/40 overflow-y-auto">
{linkedTransactions.map(transaction => (
<div key={transaction.id} className="flex items-start justify-between gap-3 px-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(transaction)}</p>
<span className="rounded-md border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{transaction.source_label || transaction.source_type_label || 'Transaction'}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{transactionDate(transaction) ? fmtDate(transactionDate(transaction)) : 'No date'} · {transaction.description || transaction.memo || 'No description'}
</p>
{transaction.account_name && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground/75">{transaction.account_name}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-2 sm:flex-row sm:items-center">
<p className={cn(
'font-mono text-sm font-semibold tabular-nums',
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(transaction.amount, transaction.currency)}
</p>
<Button
type="button"
size="sm"
variant="outline"
disabled={transactionBusyId === transaction.id}
onClick={() => openUnmatch(transaction)}
className="h-8 gap-1.5 text-xs"
>
<Link2Off className="h-3.5 w-3.5" />
{transactionBusyId === transaction.id ? 'Unmatching...' : 'Unmatch'}
</Button>
</div>
</div>
))}
</div>
)}
</div>
2026-05-16 20:26:09 -05:00
{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); }}
/>
2026-05-16 20:26:09 -05:00
)}
</div>
)}
2026-05-16 15:38:28 -05:00
<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>
2026-05-16 15:38:28 -05:00
</div>
2026-05-03 19:51:57 -05:00
</DialogFooter>
2026-05-16 20:26:09 -05:00
<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>
2026-05-16 20:26:09 -05:00
<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}
/>
2026-05-03 19:51:57 -05:00
</DialogContent>
</Dialog>
);
}