2026-05-16 20:26:09 -05:00
|
|
|
import { useEffect, useState } from 'react';
|
2026-05-29 04:19:20 -05:00
|
|
|
import { ChevronDown, Copy, Link2, Link2Off, Loader2, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
import {
|
|
|
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
|
|
|
} 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-06-03 21:21:38 -05:00
|
|
|
import BillMerchantRules from '@/components/BillMerchantRules';
|
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-16 20:26:09 -05:00
|
|
|
const PAYMENT_METHODS = [
|
|
|
|
|
['manual', 'Manual'],
|
|
|
|
|
['bank', 'Bank Transfer'],
|
|
|
|
|
['card', 'Card'],
|
|
|
|
|
['autopay', 'Autopay'],
|
|
|
|
|
['check', 'Check'],
|
|
|
|
|
['cash', 'Cash'],
|
|
|
|
|
];
|
2026-05-03 19:51:57 -05:00
|
|
|
|
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
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 09:44:16 -05:00
|
|
|
function paymentSourceLabel(source) {
|
|
|
|
|
const labels = {
|
|
|
|
|
manual: 'Manual',
|
|
|
|
|
file_import: 'File import',
|
|
|
|
|
provider_sync: 'Sync',
|
|
|
|
|
transaction_match: 'Transaction',
|
2026-05-29 04:19:20 -05:00
|
|
|
auto_match: 'SimpleFIN',
|
2026-05-18 09:44:16 -05:00
|
|
|
};
|
|
|
|
|
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',
|
2026-05-29 04:19:20 -05:00
|
|
|
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
2026-05-18 09:44:16 -05:00
|
|
|
};
|
|
|
|
|
return tones[source] || tones.manual;
|
|
|
|
|
}
|
|
|
|
|
|
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-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-03 19:51:57 -05:00
|
|
|
const [busy, setBusy] = useState(false);
|
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-05-09 13:03:36 -05:00
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
const isDebtCategory = isDebtCat(categories, categoryId);
|
2026-05-16 10:17:24 -05:00
|
|
|
const isSnowballCategory = isSnowballCat(categories, categoryId);
|
|
|
|
|
const showOnSnowball = snowballInclude || (isSnowballCategory && !snowballExempt);
|
2026-05-16 15:38:28 -05:00
|
|
|
const canAutoMarkPaid = autopay && autodraftStatus === 'assumed_paid';
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
async function loadPayments() {
|
|
|
|
|
if (isNew || !bill?.id) return;
|
|
|
|
|
setPaymentsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await api.billPayments(bill.id, 1, 100);
|
|
|
|
|
setPayments(data.payments || []);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to load payment history.');
|
|
|
|
|
} finally {
|
|
|
|
|
setPaymentsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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-05-16 20:26:09 -05:00
|
|
|
}, [bill?.id]);
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
const validateName = (val) => {
|
|
|
|
|
if (!val || val.trim() === '') return 'Name is required';
|
|
|
|
|
if (val.trim().length < 2) return 'Name must be at least 2 characters';
|
|
|
|
|
return '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validateDueDay = (val) => {
|
|
|
|
|
if (!val || val.trim() === '') return 'Due day is required';
|
|
|
|
|
const num = parseInt(val, 10);
|
|
|
|
|
if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31';
|
|
|
|
|
return '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 '';
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-14 02:11:54 -05:00
|
|
|
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 '';
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
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 === '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBlur = (field, validator) => {
|
2026-05-14 02:11:54 -05:00
|
|
|
setErrors(prev => ({ ...prev, [field]: validator(
|
|
|
|
|
field === 'name' ? name :
|
|
|
|
|
field === 'dueDay' ? dueDay :
|
|
|
|
|
field === 'expectedAmount' ? expectedAmount :
|
|
|
|
|
interestRate
|
|
|
|
|
)}));
|
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-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-05-16 21:36:04 -05:00
|
|
|
async function handleUnmatchTransaction(transaction) {
|
|
|
|
|
if (!transaction?.id) return;
|
|
|
|
|
setTransactionBusyId(transaction.id);
|
|
|
|
|
try {
|
|
|
|
|
await api.unmatchTransaction(transaction.id);
|
|
|
|
|
toast.success('Transaction unmatched');
|
|
|
|
|
await Promise.all([loadPayments(), loadLinkedTransactions()]);
|
|
|
|
|
onSave?.();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Transaction could not be unmatched.');
|
|
|
|
|
} finally {
|
|
|
|
|
setTransactionBusyId(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
async function handleSubmit(e) {
|
|
|
|
|
e.preventDefault();
|
2026-05-14 02:11:54 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
if (!validateForm()) {
|
|
|
|
|
toast.error('Please fix the form errors before saving.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
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 = {
|
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,
|
|
|
|
|
reminder_days_before: isSubscription ? parseInt(reminderDaysBefore || '3', 10) : 3,
|
|
|
|
|
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
|
|
|
};
|
|
|
|
|
setBusy(true);
|
|
|
|
|
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-05-03 19:51:57 -05:00
|
|
|
toast.success('Bill added');
|
|
|
|
|
} else {
|
2026-05-29 18:06:12 -05:00
|
|
|
savedBill = await api.updateBill(bill.id, data);
|
2026-05-03 19:51:57 -05:00
|
|
|
toast.success('Bill updated');
|
|
|
|
|
}
|
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-05-03 19:51:57 -05:00
|
|
|
onClose();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open onOpenChange={v => { if (!v) onClose(); }}>
|
2026-05-16 20:26:09 -05:00
|
|
|
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
|
2026-05-03 19:51:57 -05:00
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base font-semibold tracking-tight">
|
|
|
|
|
{isNew ? 'Add Bill' : 'Edit Bill'}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<form id="bill-modal-form" onSubmit={handleSubmit}>
|
2026-05-04 13:14:32 -05:00
|
|
|
<div className="grid gap-x-5 gap-y-4 py-2 sm:grid-cols-2">
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
{/* Name */}
|
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Name *</Label>
|
|
|
|
|
<Input
|
2026-05-09 13:03:36 -05:00
|
|
|
className={cn(inp, errors.name && 'border-red-500 focus-visible:ring-red-500')}
|
2026-05-03 19:51:57 -05:00
|
|
|
placeholder="e.g. Electricity"
|
|
|
|
|
value={name}
|
2026-05-09 13:03:36 -05:00
|
|
|
onChange={e => {
|
|
|
|
|
setName(e.target.value);
|
|
|
|
|
setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300);
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => handleBlur('name', validateName)}
|
2026-05-03 19:51:57 -05:00
|
|
|
required
|
|
|
|
|
/>
|
2026-05-09 13:03:36 -05:00
|
|
|
{errors.name && (
|
|
|
|
|
<span className="text-[10px] text-red-500 font-medium">{errors.name}</span>
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Category */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Category</Label>
|
2026-05-14 02:11:54 -05:00
|
|
|
<Select value={categoryId} onValueChange={handleCategoryChange}>
|
2026-05-03 19:51:57 -05:00
|
|
|
<SelectTrigger className={cn(inp, 'w-full')}>
|
|
|
|
|
<SelectValue placeholder="— none —" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value={CAT_NONE}>— none —</SelectItem>
|
|
|
|
|
{categories.map(c => (
|
|
|
|
|
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Due Day */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Due day of month *</Label>
|
|
|
|
|
<Input
|
2026-05-09 13:03:36 -05:00
|
|
|
className={cn(inp, errors.dueDay && 'border-red-500 focus-visible:ring-red-500')}
|
2026-05-03 19:51:57 -05:00
|
|
|
type="number" min="1" max="31" required
|
|
|
|
|
value={dueDay}
|
2026-05-09 13:03:36 -05:00
|
|
|
onChange={e => {
|
|
|
|
|
setDueDay(e.target.value);
|
|
|
|
|
setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300);
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => handleBlur('dueDay', validateDueDay)}
|
2026-05-03 19:51:57 -05:00
|
|
|
/>
|
2026-05-09 13:03:36 -05:00
|
|
|
{errors.dueDay && (
|
|
|
|
|
<span className="text-[10px] text-red-500 font-medium">{errors.dueDay}</span>
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
<p className="text-[10px] text-muted-foreground/70">
|
|
|
|
|
Enter the day of the month this bill is due.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Expected Amount */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Expected Amount ($)</Label>
|
|
|
|
|
<Input
|
2026-05-09 13:03:36 -05:00
|
|
|
className={cn(inp, 'font-mono', errors.expectedAmount && 'border-red-500 focus-visible:ring-red-500')}
|
2026-05-03 19:51:57 -05:00
|
|
|
type="number" min="0" step="0.01" placeholder="0.00"
|
|
|
|
|
value={expectedAmount}
|
2026-05-09 13:03:36 -05:00
|
|
|
onChange={e => {
|
|
|
|
|
setExpected(e.target.value);
|
|
|
|
|
setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300);
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => handleBlur('expectedAmount', 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 && (
|
|
|
|
|
<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>
|
2026-05-29 04:19:20 -05:00
|
|
|
{!isNew && (sourceBill?.has_merchant_rule || ['simplefin_recommendation', 'catalog_match'].includes(sourceBill?.subscription_source)) ? (
|
|
|
|
|
<div className="col-span-2 border-t border-border/40 pt-3">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground">SimpleFIN payment history</p>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
|
|
|
|
|
Scan unmatched bank transactions and backfill any missing payments.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="shrink-0 gap-1.5"
|
|
|
|
|
disabled={syncingPayments}
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
setSyncingPayments(true);
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.syncBillSimplefinPayments(sourceBill.id);
|
|
|
|
|
if (result.added > 0) {
|
|
|
|
|
toast.success(`Synced ${result.added} payment${result.added !== 1 ? 's' : ''} from SimpleFIN.`);
|
|
|
|
|
} else {
|
|
|
|
|
toast.info('No new payments found in SimpleFIN history.');
|
|
|
|
|
}
|
|
|
|
|
} 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 payments</>}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-28 22:54:07 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-14 03:00:01 -05:00
|
|
|
{/* Debt / Snowball Details — collapsible */}
|
2026-05-14 02:11:54 -05:00
|
|
|
<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')}
|
|
|
|
|
/>
|
2026-05-14 03:00:01 -05:00
|
|
|
Debt / Snowball Details
|
2026-05-16 10:17:24 -05:00
|
|
|
{isSnowballCategory && (
|
2026-05-14 02:11:54 -05:00
|
|
|
<span className="ml-1 text-[9px] text-emerald-400 font-semibold tracking-normal normal-case">
|
2026-05-16 10:17:24 -05:00
|
|
|
· snowball auto-detected
|
2026-05-14 02:11:54 -05:00
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-16 10:17:24 -05:00
|
|
|
{!showOnSnowball && isSnowballCategory && (
|
2026-05-14 03:00:01 -05:00
|
|
|
<span className="ml-1 text-[9px] text-amber-400 font-semibold tracking-normal normal-case">
|
|
|
|
|
· exempt
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-14 02:11:54 -05:00
|
|
|
</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"
|
2026-05-14 03:00:01 -05:00
|
|
|
checked={showOnSnowball}
|
|
|
|
|
onChange={e => handleSnowballVisibilityChange(e.target.checked)}
|
2026-05-14 02:11:54 -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">
|
2026-05-14 03:00:01 -05:00
|
|
|
Show on Debt Snowball
|
2026-05-14 02:11:54 -05:00
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/70 pl-6">
|
2026-05-16 10:17:24 -05:00
|
|
|
Uncheck to exempt an auto-detected snowball bill, or check to include this bill manually.
|
2026-05-14 02:11:54 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
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-05-03 19:51:57 -05:00
|
|
|
{/* Notes */}
|
|
|
|
|
<div className="col-span-2 space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
|
|
|
|
|
<textarea
|
|
|
|
|
rows={2}
|
|
|
|
|
value={notes}
|
|
|
|
|
onChange={e => setNotes(e.target.value)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full rounded-md border border-border/60 bg-background/50 px-3 py-2',
|
|
|
|
|
'text-sm text-foreground placeholder:text-muted-foreground/50',
|
|
|
|
|
'resize-none outline-none focus:ring-1 focus:ring-ring transition-shadow',
|
|
|
|
|
)}
|
|
|
|
|
placeholder="Any additional notes…"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
|
|
|
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={saveTemplate}
|
|
|
|
|
onChange={e => setSaveTemplate(e.target.checked)}
|
|
|
|
|
className="h-4 w-4 rounded border-border accent-primary"
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
|
|
|
|
|
Save this setup as a reusable template
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
{saveTemplate && (
|
|
|
|
|
<div className="mt-2 space-y-1.5">
|
|
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Template Name</Label>
|
|
|
|
|
<Input
|
|
|
|
|
className={inp}
|
|
|
|
|
value={templateName}
|
|
|
|
|
onChange={e => setTemplateName(e.target.value)}
|
|
|
|
|
placeholder={name || 'My bill template'}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
{!isNew && (
|
|
|
|
|
<div className="mt-4 border-t border-border/50 pt-4">
|
|
|
|
|
<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="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
No payments recorded for this bill.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="max-h-52 space-y-2 overflow-y-auto pr-1">
|
2026-05-16 21:36:04 -05:00
|
|
|
{payments.map(payment => {
|
|
|
|
|
const linkedPayment = isTransactionLinkedPayment(payment);
|
|
|
|
|
return (
|
|
|
|
|
<div key={payment.id} className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/35 px-3 py-2.5">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
|
2026-05-18 09:44:16 -05:00
|
|
|
<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)}
|
2026-05-16 21:36:04 -05:00
|
|
|
</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">
|
|
|
|
|
{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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-16 20:26:09 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-16 21:36:04 -05:00
|
|
|
);
|
|
|
|
|
})}
|
2026-05-16 20:26:09 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-03 21:21:38 -05:00
|
|
|
{/* Bank Matching Rules */}
|
|
|
|
|
{!isNew && (
|
|
|
|
|
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
2026-06-03 23:21:48 -05:00
|
|
|
<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>
|
|
|
|
|
{sourceBill?.has_merchant_rule && (
|
|
|
|
|
<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?.();
|
|
|
|
|
refetch?.();
|
|
|
|
|
} else {
|
|
|
|
|
toast.info('No new matching transactions found.');
|
|
|
|
|
}
|
|
|
|
|
} 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>
|
|
|
|
|
)}
|
2026-06-03 21:21:38 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="px-3 py-3">
|
|
|
|
|
<BillMerchantRules
|
|
|
|
|
billId={sourceBill?.id}
|
|
|
|
|
onRulesChanged={() => {
|
|
|
|
|
refetch?.();
|
|
|
|
|
loadLinkedTransactions?.();
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-16 21:36:04 -05:00
|
|
|
<div className="mt-4 rounded-lg border border-border/60 bg-background/35">
|
|
|
|
|
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2.5">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Linked transactions</p>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground/75">{linkedTransactions.length} confirmed matches</p>
|
|
|
|
|
</div>
|
2026-05-18 09:44:16 -05:00
|
|
|
<span className={cn(
|
|
|
|
|
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[11px] font-medium',
|
|
|
|
|
linkedTransactions.length > 0
|
|
|
|
|
? 'border-primary/20 bg-primary/5 text-primary'
|
|
|
|
|
: 'border-border/60 bg-muted/30 text-muted-foreground',
|
|
|
|
|
)}>
|
2026-05-16 21:36:04 -05:00
|
|
|
<Link2 className="h-3.5 w-3.5" />
|
2026-05-18 09:44:16 -05:00
|
|
|
{linkedTransactions.length}
|
2026-05-16 21:36:04 -05:00
|
|
|
</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={() => handleUnmatchTransaction(transaction)}
|
|
|
|
|
className="h-8 gap-1.5 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Link2Off className="h-3.5 w-3.5" />
|
|
|
|
|
{transactionBusyId === transaction.id ? 'Unmatching...' : 'Unmatch'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
{paymentFormOpen && (
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-16 15:38:28 -05:00
|
|
|
<DialogFooter className="mt-2 gap-2 sm:justify-between">
|
|
|
|
|
{!isNew && onDuplicate && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => onDuplicate(bill)}
|
|
|
|
|
className="gap-2 text-xs"
|
|
|
|
|
>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
Duplicate
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex gap-2">
|
2026-05-03 19:51:57 -05:00
|
|
|
<Button type="button" variant="ghost" disabled={busy} onClick={onClose} className="text-xs">
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" form="bill-modal-form" disabled={busy} className="text-xs">
|
|
|
|
|
{isNew ? 'Add Bill' : 'Save Changes'}
|
|
|
|
|
</Button>
|
2026-05-16 15:38:28 -05:00
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</DialogFooter>
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
<AlertDialog
|
|
|
|
|
open={!!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-05-03 19:51:57 -05:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|