BillTracker/client/components/BillModal.jsx

1719 lines
77 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 { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { formatCentsUSD, 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 { Checkbox } from '@/components/ui/checkbox';
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 BillMerchantRules from '@/components/BillMerchantRules';
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 PAYMENT_METHODS = [
['manual', 'Manual'],
['bank', 'Bank Transfer'],
['card', 'Card'],
['autopay', 'Autopay'],
['check', 'Check'],
['cash', 'Cash'],
];
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 fmtTransactionAmount(amount, currency = 'USD') {
return formatCentsUSD(amount, { signed: true, currency });
}
function transactionDate(tx) {
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
}
function transactionTitle(tx) {
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
}
function isTransactionLinkedPayment(payment) {
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
}
function isHistoryOnlyPayment(payment) {
return !!payment?.accounting_excluded;
}
function paymentSourceLabel(source) {
const labels = {
manual: 'Manual',
file_import: 'File import',
provider_sync: 'Sync',
transaction_match: 'Transaction',
auto_match: 'SimpleFIN',
};
return labels[source] || source || 'Manual';
}
function paymentSourceTone(source) {
const tones = {
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
};
return tones[source] || tones.manual;
}
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;
}
function normalizePayee(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
}
function isSimilarPayee(a, b) {
const na = normalizePayee(a);
const nb = normalizePayee(b);
const minLen = Math.min(na.length, nb.length);
if (minLen < 3) return false;
return na.startsWith(nb) || nb.startsWith(na);
}
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();
}, [bill?.id]);
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 */}
<div className="col-span-2">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-border/40" />
<button
type="button"
onClick={() => setShowDebtSection(s => !s)}
className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ChevronDown
className={cn('h-3 w-3 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/>
Debt / Snowball Details
{isSnowballCategory && (
<span className="text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· auto-detected
</span>
)}
{!showOnSnowball && isSnowballCategory && (
<span className="text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt
</span>
)}
</button>
<div className="h-px flex-1 bg-border/40" />
</div>
{showDebtSection && (
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 p-3 rounded-xl bg-muted/20 border border-border/30">
{/* Interest Rate */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
<Input
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" max="100" step="0.01" placeholder="Optional"
value={interestRate}
onChange={e => {
setInterestRate(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
}}
onBlur={() => handleBlur('interestRate', interestRate, validateInterestRate)}
/>
{errors.interestRate && (
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Enter 29.99 for 29.99%.</p>
</div>
{/* Current Balance */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Current Balance ($)</Label>
<Input
className={cn(inp, 'font-mono', errors.currentBalance && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" step="0.01" placeholder="Optional"
value={currentBalance}
onChange={e => {
setCurrentBalance(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300);
}}
onBlur={() => handleBlur('currentBalance', currentBalance, validateCurrentBalance)}
/>
{errors.currentBalance && (
<span className="text-[10px] text-red-500 font-medium">{errors.currentBalance}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Outstanding debt balance.</p>
</div>
{/* Minimum Payment */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Minimum Payment ($)</Label>
<Input
className={cn(inp, 'font-mono', errors.minimumPayment && 'border-red-500 focus-visible:ring-red-500')}
type="number" min="0" step="0.01" placeholder="Optional"
value={minimumPayment}
onChange={e => {
setMinimumPayment(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300);
}}
onBlur={() => handleBlur('minimumPayment', minimumPayment, validateMinimumPayment)}
/>
{errors.minimumPayment && (
<span className="text-[10px] text-red-500 font-medium">{errors.minimumPayment}</span>
)}
<p className="text-[10px] text-muted-foreground/70">Required minimum monthly payment.</p>
</div>
{/* Include in Snowball */}
<div className="flex flex-col justify-end pb-1 space-y-1">
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={showOnSnowball}
onChange={e => handleSnowballVisibilityChange(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">
Show on Debt Snowball
</span>
</label>
<p className="text-[10px] text-muted-foreground/70 pl-6">
Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
</p>
</div>
</div>
)}
</div>
{/* 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 */}
{!isNew && autopay && (() => {
const stats = bill?.autopay_stats;
const total = stats?.total ?? 0;
const failures = stats?.failures ?? 0;
const daysSince = localVerifiedAt
? Math.floor((Date.now() - localVerifiedAt.getTime()) / 86400000)
: null;
const needsVerify = daysSince === null || daysSince > 90;
return (
<div className="rounded-md border border-border/50 bg-muted/20 px-3 py-2.5 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className={cn('text-xs font-medium', failures > 0 ? 'text-amber-500' : total > 0 ? 'text-emerald-500' : 'text-muted-foreground/60')}>
{total > 0
? `${failures > 0 ? '⚠' : '✓'} ${total - failures}/${total} successful (12 mo)`
: 'No payment history yet'}
</span>
<button
type="button"
onClick={handleVerifyAutopay}
className="text-[11px] text-sky-500 hover:text-sky-400 underline underline-offset-2 transition-colors"
>
Mark verified
</button>
</div>
{needsVerify && (
<p className="text-[11px] text-amber-500/80">
{daysSince === null
? "Autopay never confirmed — verify it's still active."
: `Last verified ${daysSince}d ago — confirm autopay is still on.`}
</p>
)}
{!needsVerify && (
<p className="text-[11px] text-muted-foreground/60">Verified {daysSince}d ago</p>
)}
{failures > 0 && stats?.last_failure_date && (
<p className="text-[11px] text-amber-500/80">
Last failure: {stats.last_failure_date}{stats?.last_failure_notes ? `${stats.last_failure_notes}` : ''}
</p>
)}
</div>
);
})()}
{/* 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>
<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>
</div>
</form>
{!isNew && (
<div className="mt-4 border-t border-border/50 pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment history</p>
<p className="text-[11px] text-muted-foreground/75">{payments.length} recorded</p>
</div>
<Button type="button" size="sm" variant="outline" disabled={paymentBusy} onClick={startAddPayment} className="h-8 gap-2 text-xs">
<Plus className="h-3.5 w-3.5" />
Add Payment
</Button>
</div>
{paymentsLoading ? (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-8 text-center text-sm text-muted-foreground">
Loading payment history...
</div>
) : payments.length === 0 ? (
<div className="flex flex-col items-center gap-1.5 rounded-lg bg-muted/20 px-3 py-8 text-center">
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
<p className="text-xs text-muted-foreground/60">Use the form below to record the first payment.</p>
</div>
) : (
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
{payments.map(payment => {
const linkedPayment = isTransactionLinkedPayment(payment);
const historyOnly = isHistoryOnlyPayment(payment);
return (
<div key={payment.id} className={cn(
'flex items-start justify-between gap-3 rounded-lg border px-3 py-2.5',
historyOnly
? 'border-amber-500/25 bg-amber-500/[0.06] opacity-85'
: 'border-border/60 bg-background/35'
)}>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className={cn('font-mono text-sm font-semibold', historyOnly ? 'text-muted-foreground line-through decoration-amber-500/70' : 'text-foreground')}>{fmt(payment.amount)}</p>
<span className={cn(
'rounded-md border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
paymentSourceTone(payment.payment_source),
)}>
{paymentSourceLabel(payment.payment_source)}
</span>
{historyOnly && (
<span className="rounded-md border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">
History only
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)} · {payment.method || (payment.payment_source === 'transaction_match' ? 'Synced' : 'manual')}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<div className="flex shrink-0 gap-1">
{historyOnly ? (
<span className="inline-flex h-8 items-center rounded-md border border-amber-500/25 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-600 dark:text-amber-300">
Overridden
</span>
) : linkedPayment ? (
<span className="inline-flex h-8 items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 text-[11px] font-medium text-primary">
<Link2 className="h-3.5 w-3.5" />
Matched
</span>
) : (
<>
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => startEditPayment(payment)} className="h-8 w-8" aria-label="Edit payment">
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button type="button" size="icon" variant="ghost" disabled={paymentBusy} onClick={() => setDeletePaymentTarget(payment)} className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label="Remove payment">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
{/* 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);
try {
const result = await api.syncBillSimplefinPayments(sourceBill.id);
if (result.added > 0) {
toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`);
// Imported payments must refresh the payment list AND the
// Tracker behind the modal (the row may now be covered) —
// same as the unmatch handlers.
await Promise.all([loadPayments(), loadLinkedTransactions()]);
onSave?.();
} else {
toast.info('No new matching transactions found.');
}
// 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 (err) {
toast.error(err.message || 'Sync failed.');
} 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>
<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" />
{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>
{paymentFormOpen && (
<form onSubmit={handlePaymentSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{editingPayment ? 'Edit payment' : 'Add payment'}
</p>
<span className="rounded-md border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
manual
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Amount ($)</Label>
<Input
type="number"
min="0"
step="0.01"
value={paymentAmount}
onChange={e => setPaymentAmount(e.target.value)}
className={cn(inp, 'font-mono')}
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
type="date"
value={paymentDate}
onChange={e => setPaymentDate(e.target.value)}
className={cn(inp, 'font-mono')}
required
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
value={paymentNotes}
onChange={e => setPaymentNotes(e.target.value)}
className={inp}
placeholder="Paid from checking"
/>
</div>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={paymentBusy}
onClick={() => {
resetPaymentForm();
setPaymentFormOpen(false);
}}
className="text-xs"
>
Cancel
</Button>
<Button type="submit" disabled={paymentBusy} className="text-xs">
{paymentBusy ? 'Saving...' : editingPayment ? 'Save Payment' : 'Add Payment'}
</Button>
</div>
</form>
)}
</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>
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
<Dialog
open={!!unmatchTarget && !bulkUnmatch}
onOpenChange={open => { if (!open) closeUnmatch(); }}
>
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold">Unmatch transaction</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
How would you like to proceed?
</DialogDescription>
</DialogHeader>
{unmatchTarget && (
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
<p className="text-sm font-medium text-foreground">{transactionTitle(unmatchTarget)}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
{' · '}
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)}</span>
</p>
</div>
)}
<div className="grid gap-2.5">
<button
type="button"
onClick={() => setUnmatchConfirmOpen(true)}
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Unmatch this payment only</p>
<p className="mt-0.5 text-xs text-muted-foreground">Remove just this one transaction from the bill.</p>
</div>
</button>
<button
type="button"
onClick={handleOpenBulkUnmatch}
className="flex items-start gap-3 rounded-lg border border-border/60 bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Layers className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div>
<p className="text-sm font-medium">Review all similar matches</p>
<p className="mt-0.5 text-xs text-muted-foreground">
See all transactions with a similar payee name and manage them together. Optionally remove a merchant rule too.
</p>
</div>
</button>
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Single unmatch confirm ────────────────────────────────── */}
<AlertDialog
open={!!unmatchTarget && unmatchConfirmOpen}
onOpenChange={open => { if (!open) setUnmatchConfirmOpen(false); }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unmatch this transaction?</AlertDialogTitle>
<AlertDialogDescription>
{unmatchTarget && (
<>
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
{' '}will be unlinked from this bill.
{unmatchTarget.linked_payment?.payment_source === 'provider_sync'
? ' The payment record will be removed and the balance restored.'
: ' The payment record will be removed.'}
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!transactionBusyId} onClick={() => setUnmatchConfirmOpen(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={!!transactionBusyId}
onClick={handleSingleUnmatch}
>
{transactionBusyId ? 'Unmatching…' : 'Confirm Unmatch'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── Bulk unmatch dialog ───────────────────────────────────── */}
<Dialog
open={!!unmatchTarget && !!bulkUnmatch}
onOpenChange={open => { if (!open) closeUnmatch(); }}
>
<DialogContent className="sm:max-w-lg border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold">Review similar matches</DialogTitle>
{unmatchTarget && (
<DialogDescription className="text-sm text-muted-foreground">
Transactions similar to <span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>.
Uncheck any you want to keep matched.
</DialogDescription>
)}
</DialogHeader>
{bulkUnmatch && (
<>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Quick select:</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))}
>
All
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-6 px-2 text-xs"
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))}
>
None
</Button>
<span className="ml-auto text-xs text-muted-foreground">
{bulkUnmatch.checkedIds.size} of {bulkUnmatch.similar.length} selected
</span>
</div>
<div className="max-h-60 space-y-2 overflow-y-auto pr-1">
{bulkUnmatch.similar.map(tx => (
<label
key={tx.id}
className={cn(
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
bulkUnmatch.checkedIds.has(tx.id)
? 'border-primary/30 bg-primary/5'
: 'border-border/60 bg-background/35 opacity-60',
)}
>
<Checkbox
checked={bulkUnmatch.checkedIds.has(tx.id)}
onCheckedChange={checked => {
setBulkUnmatch(p => {
const next = new Set(p.checkedIds);
checked ? next.add(tx.id) : next.delete(tx.id);
return { ...p, checkedIds: next };
});
}}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{transactionTitle(tx)}</p>
<p className="text-xs text-muted-foreground">
{transactionDate(tx) ? fmtDate(transactionDate(tx)) : 'No date'}
{tx.account_name && ` · ${tx.account_name}`}
</p>
</div>
<p className={cn(
'shrink-0 font-mono text-sm font-semibold tabular-nums',
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
)}>
{fmtTransactionAmount(tx.amount, tx.currency)}
</p>
</label>
))}
</div>
{bulkUnmatch.rules.length > 0 && (
<div className="rounded-lg border border-amber-500/25 bg-amber-500/5 px-3 py-2.5">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">
Merchant rules
</p>
{bulkUnmatch.rules.map(rule => (
<label key={rule.id} className="flex cursor-pointer items-start gap-2.5">
<Checkbox
className="mt-0.5"
checked={bulkUnmatch.removeRuleId === rule.id}
onCheckedChange={checked =>
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null }))
}
/>
<div>
<p className="text-sm">
Remove rule <span className="font-mono font-semibold">"{rule.merchant}"</span>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Future syncs won't auto-match this pattern to this bill.
</p>
</div>
</label>
))}
</div>
)}
</>
)}
<DialogFooter className="gap-2">
<Button type="button" variant="ghost" onClick={closeUnmatch} className="text-xs">
Cancel
</Button>
<Button
type="button"
variant="outline"
onClick={() => setBulkUnmatch(null)}
className="text-xs"
>
Back
</Button>
<Button
type="button"
disabled={bulkBusy || !bulkUnmatch || bulkUnmatch.checkedIds.size === 0}
onClick={handleBulkConfirm}
className="text-xs"
>
{bulkBusy
? 'Unmatching'
: `Unmatch ${bulkUnmatch?.checkedIds?.size ?? 0} transaction${bulkUnmatch?.checkedIds?.size !== 1 ? 's' : ''}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}