441 lines
18 KiB
JavaScript
441 lines
18 KiB
JavaScript
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 (
|
|
<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}>
|
|
<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', 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={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
|
|
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', 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', validateExpectedAmount)}
|
|
/>
|
|
{errors.expectedAmount && (
|
|
<span className="text-[10px] text-red-500 font-medium">{errors.expectedAmount}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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">
|
|
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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|