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