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