BillTracker/client/components/BillModal.jsx

441 lines
18 KiB
React
Raw Normal View History

2026-05-03 19:51:57 -05:00
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';
}
}
2026-05-03 19:51:57 -05:00
// 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');
2026-05-03 19:51:57 -05:00
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);
2026-05-09 13:03:36 -05:00
// 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);
};
2026-05-03 19:51:57 -05:00
async function handleSubmit(e) {
e.preventDefault();
2026-05-09 13:03:36 -05:00
// Run form validation
if (!validateForm()) {
toast.error('Please fix the form errors before saving.');
return;
}
// Additional server-side validation checks
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 = {
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,
2026-05-03 19:51:57 -05:00
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);
}
}
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(); }}>
<DialogContent className="sm:max-w-2xl border-border/60 bg-card/95 backdrop-blur-xl">
<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>
<Select value={categoryId} onValueChange={setCategoryId}>
<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>
{/* Interest Rate */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Interest rate (APR %)</Label>
<Input
2026-05-09 13:03:36 -05:00
className={cn(inp, 'font-mono', errors.interestRate && 'border-red-500 focus-visible:ring-red-500')}
2026-05-03 19:51:57 -05:00
type="number" min="0" max="100" step="0.01" placeholder="Optional"
value={interestRate}
2026-05-09 13:03:36 -05:00
onChange={e => {
setInterestRate(e.target.value);
setTimeout(() => setErrors(prev => ({ ...prev, interestRate: validateInterestRate(e.target.value) })), 300);
}}
onBlur={() => handleBlur('interestRate', validateInterestRate)}
2026-05-03 19:51:57 -05:00
/>
2026-05-09 13:03:36 -05:00
{errors.interestRate && (
<span className="text-[10px] text-red-500 font-medium">{errors.interestRate}</span>
)}
2026-05-03 19:51:57 -05:00
<p className="text-[10px] text-muted-foreground/70">
Optional, useful for credit cards. Enter 29.99 for 29.99%.
</p>
</div>
{/* Billing Cycle */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Billing Cycle</Label>
<Select value={billingCycle} onValueChange={setCycle}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annually">Annually</SelectItem>
<SelectItem value="irregular">Irregular</SelectItem>
</SelectContent>
</Select>
</div>
{/* Cycle Type */}
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Cycle Type</Label>
<Select value={cycleType} onValueChange={setCycleType}>
<SelectTrigger className={cn(inp, 'w-full')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="biweekly">Biweekly</SelectItem>
<SelectItem value="quarterly">Quarterly</SelectItem>
<SelectItem value="annual">Annual</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>
) : (
<Input
className={inp}
type="text"
placeholder="Day of period"
value={cycleDay}
onChange={e => setCycleDay(e.target.value)}
/>
)}
<p className="text-[10px] text-muted-foreground/70">
{cycleType === 'monthly' ? 'Day of the month' :
cycleType === 'weekly' || cycleType === 'biweekly' ? 'Day of the week' :
'Day of the period'}
</p>
</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}
onChange={e => setAutopay(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="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>
{/* 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>
</div>
</form>
<DialogFooter className="mt-2">
<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>
</DialogFooter>
</DialogContent>
</Dialog>
);
}