import { useState } from 'react'; import { ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select'; import { api } from '@/api'; import { cn } from '@/lib/utils'; 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']; 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; } export default function BillModal({ bill, categories, onClose, onSave }) { const isNew = !bill; const [name, setName] = useState(bill?.name || ''); const [categoryId, setCategoryId] = useState(bill?.category_id ? String(bill.category_id) : CAT_NONE); const [dueDay, setDueDay] = useState(String(bill?.due_day || '')); const [expectedAmount, setExpected] = useState(String(bill?.expected_amount || '')); const [interestRate, setInterestRate] = useState(bill?.interest_rate == null ? '' : String(bill.interest_rate)); const [billingCycle, setCycle] = useState(bill?.billing_cycle || 'monthly'); const [cycleType, setCycleType] = useState(bill?.cycle_type || 'monthly'); const [cycleDay, setCycleDay] = useState(bill?.cycle_day || '1'); const [autopay, setAutopay] = useState(!!bill?.autopay_enabled); const [has2fa, setHas2fa] = useState(!!bill?.has_2fa); const [website, setWebsite] = useState(bill?.website || ''); const [username, setUsername] = useState(bill?.username || ''); const [accountInfo, setAccountInfo] = useState(bill?.account_info || ''); const [notes, setNotes] = useState(bill?.notes || ''); const [currentBalance, setCurrentBalance] = useState(bill?.current_balance == null ? '' : String(bill.current_balance)); const [minimumPayment, setMinimumPayment] = useState(bill?.minimum_payment == null ? '' : String(bill.minimum_payment)); const [snowballInclude, setSnowballInclude] = useState(!!bill?.snowball_include); const [snowballExempt, setSnowballExempt] = useState(!!bill?.snowball_exempt); const [showDebtSection, setShowDebtSection] = useState( () => isDebtCat(categories, bill?.category_id ? String(bill.category_id) : CAT_NONE) || !!bill?.snowball_include || !!bill?.snowball_exempt || bill?.current_balance != null || bill?.minimum_payment != null ); const [busy, setBusy] = useState(false); const [errors, setErrors] = useState({}); const isDebtCategory = isDebtCat(categories, categoryId); const showOnSnowball = snowballInclude || (isDebtCategory && !snowballExempt); const validateName = (val) => { if (!val || val.trim() === '') return 'Name is required'; if (val.trim().length < 2) return 'Name must be at least 2 characters'; return ''; }; const validateDueDay = (val) => { if (!val || val.trim() === '') return 'Due day is required'; const num = parseInt(val, 10); if (isNaN(num) || num < 1 || num > 31) return 'Due day must be between 1 and 31'; return ''; }; const validateExpectedAmount = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Amount must be a positive number'; return ''; }; const validateInterestRate = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num)) return 'Invalid number'; if (num < 0 || num > 100) return 'Interest rate must be between 0 and 100'; return ''; }; const validateCurrentBalance = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Balance must be a non-negative number'; return ''; }; const validateMinimumPayment = (val) => { if (val === '' || val === null) return ''; const num = parseFloat(val); if (isNaN(num) || num < 0) return 'Min payment must be a non-negative number'; return ''; }; const validateForm = () => { const newErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), interestRate: validateInterestRate(interestRate), currentBalance: validateCurrentBalance(currentBalance), minimumPayment: validateMinimumPayment(minimumPayment), }; setErrors(newErrors); return Object.values(newErrors).every(err => err === ''); }; const handleBlur = (field, validator) => { setErrors(prev => ({ ...prev, [field]: validator( field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate )})); }; const handleCategoryChange = (val) => { setCategoryId(val); if (isDebtCat(categories, val)) { setShowDebtSection(true); } else { setSnowballExempt(false); } }; const handleSnowballVisibilityChange = (checked) => { if (checked) { setSnowballExempt(false); setSnowballInclude(!isDebtCategory); } else { setSnowballInclude(false); setSnowballExempt(isDebtCategory); } }; async function handleSubmit(e) { e.preventDefault(); if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } const parsedDueDay = Number(dueDay); if (!Number.isInteger(parsedDueDay) || parsedDueDay < 1 || parsedDueDay > 31) { toast.error('Due day must be a whole number from 1 to 31.'); return; } const trimmedInterestRate = interestRate.trim(); const parsedInterestRate = trimmedInterestRate === '' ? null : Number(trimmedInterestRate); if (parsedInterestRate !== null && (!Number.isFinite(parsedInterestRate) || parsedInterestRate < 0 || parsedInterestRate > 100)) { toast.error('Interest rate must be blank or a number from 0 to 100.'); return; } const data = { name: name.trim(), category_id: categoryId === CAT_NONE ? null : parseInt(categoryId, 10), due_day: parsedDueDay, expected_amount: parseFloat(expectedAmount) || 0, interest_rate: parsedInterestRate, billing_cycle: billingCycle, cycle_type: cycleType, cycle_day: cycleDay, autopay_enabled: autopay, has_2fa: has2fa, website: website || null, username: username || null, account_info: accountInfo || null, notes: notes || null, current_balance: currentBalance === '' ? null : parseFloat(currentBalance), minimum_payment: minimumPayment === '' ? null : parseFloat(minimumPayment), snowball_include: snowballInclude, snowball_exempt: snowballExempt, }; setBusy(true); try { if (isNew) { await api.createBill(data); toast.success('Bill added'); } else { await api.updateBill(bill.id, data); toast.success('Bill updated'); } onSave(); onClose(); } catch (err) { toast.error(err.message); } finally { setBusy(false); } } const inp = 'bg-background/50 border-border/60 h-9 text-sm w-full'; return ( { if (!v) onClose(); }}> {isNew ? 'Add Bill' : 'Edit Bill'}
{/* Name */}
{ setName(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, name: validateName(e.target.value) })), 300); }} onBlur={() => handleBlur('name', validateName)} required /> {errors.name && ( {errors.name} )}
{/* Category */}
{/* Due Day */}
{ setDueDay(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, dueDay: validateDueDay(e.target.value) })), 300); }} onBlur={() => handleBlur('dueDay', validateDueDay)} /> {errors.dueDay && ( {errors.dueDay} )}

Enter the day of the month this bill is due.

{/* Expected Amount */}
{ setExpected(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, expectedAmount: validateExpectedAmount(e.target.value) })), 300); }} onBlur={() => handleBlur('expectedAmount', validateExpectedAmount)} /> {errors.expectedAmount && ( {errors.expectedAmount} )}
{/* Billing Cycle */}
{/* Cycle Type */}
{/* Cycle Day */}
{cycleType === 'monthly' ? ( ) : cycleType === 'weekly' || cycleType === 'biweekly' ? ( ) : ( setCycleDay(e.target.value)} /> )}

{cycleType === 'monthly' ? 'Day of the month' : cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' : 'Day of the period'}

{/* Debt / Snowball Details — collapsible */}
{showDebtSection && (
{/* Interest Rate */}
{ setInterestRate(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); }} onBlur={() => handleBlur('interestRate', validateInterestRate)} /> {errors.interestRate && ( {errors.interestRate} )}

Enter 29.99 for 29.99%.

{/* Current Balance */}
{ setCurrentBalance(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(e.target.value) })), 300); }} onBlur={() => setErrors(prev => ({ ...prev, currentBalance: validateCurrentBalance(currentBalance) }))} /> {errors.currentBalance && ( {errors.currentBalance} )}

Outstanding debt balance.

{/* Minimum Payment */}
{ setMinimumPayment(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(e.target.value) })), 300); }} onBlur={() => setErrors(prev => ({ ...prev, minimumPayment: validateMinimumPayment(minimumPayment) }))} /> {errors.minimumPayment && ( {errors.minimumPayment} )}

Required minimum monthly payment.

{/* Include in Snowball */}

Uncheck to exempt an auto-detected debt bill, or check to include a non-debt bill.

)}
{/* Website */}
setWebsite(e.target.value)} />
{/* Username */}
setUsername(e.target.value)} />
{/* Account Info */}
setAccountInfo(e.target.value)} />
{/* Checkboxes */}
{/* Notes */}