BillTracker/client/components/BillModal.jsx

1658 lines
73 KiB
JavaScript

import { useActionState, useEffect, useState } from 'react';
import { ChevronDown, Copy, Layers, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
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') {
const cents = Number(amount || 0);
const value = Math.abs(cents) / 100;
const sign = cents < 0 ? '-' : '+';
return `${sign}${new Intl.NumberFormat(undefined, {
style: 'currency',
currency: currency || 'USD',
}).format(value)}`;
}
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
);
// 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 '';
};
const validateExpectedAmount = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Amount must be a positive number';
return '';
};
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 validateCurrentBalance = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Balance must be a non-negative number';
return '';
};
const validateMinimumPayment = (val) => {
if (val === '' || val === null) return '';
const num = parseFloat(val);
if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number';
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 === '');
};
const handleBlur = (field, validator) => {
setErrors(prev => ({ ...prev, [field]: validator(
field === 'name' ? name :
field === 'dueDay' ? dueDay :
field === 'expectedAmount' ? expectedAmount :
interestRate
)}));
};
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);
}
}
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;
}
const parsedDueDay = Number(dueDay);
if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) {
toast.error('Due day must be a whole number from 1 to 31.');
return;
}
const trimmedInterestRate = interestRate.trim();
const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate);
if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) {
toast.error('Interest rate must be blank or a number from 0 to 100.');
return;
}
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: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3,
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('Bill added');
} else {
savedBill = await api.updateBill(bill.id, data);
toast.success('Bill updated');
}
if (saveTemplate) {
const safeTemplateName = templateName.trim() || data.name;
await api.saveBillTemplate({ name: safeTemplateName, data });
toast.success('Template saved');
}
onSave(savedBill);
onClose();
} catch (err) {
toast.error(err.message);
}
}, null);
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
return (
<Dialog open 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', 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', 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', 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 grid gap-3 border-t border-border/40 pt-3 sm:grid-cols-2">
<div className="space-y-1.5">
<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>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Reminder Days</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">0-30 days before renewal.</p>
</div>
{/* Bank sync button moved to the Transactions tab → Bank Matching Rules section */}
</div>
)}
</div>
{/* Debt / Snowball Details — collapsible */}
<div className="col-span-2">
<button
type="button"
onClick={() => setShowDebtSection(s => !s)}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors w-full text-left py-1"
>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform duration-150', !showDebtSection && '-rotate-90')}
/>
Debt / Snowball Details
{isSnowballCategory && (
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
· snowball auto-detected
</span>
)}
{!showOnSnowball && isSnowballCategory && (
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
· exempt
</span>
)}
</button>
{showDebtSection && (
<div className="grid sm:grid-cols-2 gap-x-5 gap-y-4 mt-3 pt-3 border-t border-border/40">
{/* 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', 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={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))}
/>
{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={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))}
/>
{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 || '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.`);
loadLinkedTransactions?.();
} 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={() => {
setLocalHasRules(true);
loadLinkedTransactions?.();
}}
/>
</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">
{!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>
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" disabled={isPending} onClick={onClose} 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={!!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>
);
}