import { useState } from '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'; // Helper function to get ordinal suffix (1st, 2nd, 3rd, etc.) 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'; 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 [busy, setBusy] = useState(false); // Validation state const [errors, setErrors] = useState({}); // Real-time validation helpers 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 validateForm = () => { const newErrors = { name: validateName(name), dueDay: validateDueDay(dueDay), expectedAmount: validateExpectedAmount(expectedAmount), interestRate: validateInterestRate(interestRate), }; setErrors(newErrors); return Object.values(newErrors).every(err => err === ''); }; // Validation on blur const handleBlur = (field, validator) => { setErrors(prev => ({ ...prev, [field]: validator(field === 'name' ? name : field === 'dueDay' ? dueDay : field === 'expectedAmount' ? expectedAmount : interestRate) })); }; // Validation on change - debounce for better UX const handleChange = (field, value, validator) => { if (field === 'name') setName(value); if (field === 'dueDay') setDueDay(value); if (field === 'expectedAmount') setExpected(value); if (field === 'interestRate') setInterestRate(value); // Only validate after input, not every keystroke setTimeout(() => { setErrors(prev => ({ ...prev, [field]: validator(value) })); }, 300); }; async function handleSubmit(e) { e.preventDefault(); // Run form validation if (!validateForm()) { toast.error('Please fix the form errors before saving.'); return; } // Additional server-side validation checks 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, }; 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} )}
{/* Interest Rate */}
{ setInterestRate(e.target.value); setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300); }} onBlur={() => handleBlur('interestRate', validateInterestRate)} /> {errors.interestRate && ( {errors.interestRate} )}

Optional, useful for credit cards. Enter 29.99 for 29.99%.

{/* 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'}

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