BillTracker/client/components/BillModal.jsx

268 lines
11 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';
// 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 [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);
async function handleSubmit(e) {
e.preventDefault();
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,
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';
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 grid-cols-2 gap-x-5 gap-y-4 py-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={inp}
placeholder="e.g. Electricity"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</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={inp}
type="number" min="1" max="31" required
value={dueDay}
onChange={e => setDueDay(e.target.value)}
/>
<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')}
type="number" min="0" step="0.01" placeholder="0.00"
value={expectedAmount}
onChange={e => setExpected(e.target.value)}
/>
</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')}
type="number" min="0" max="100" step="0.01" placeholder="Optional"
value={interestRate}
onChange={e => setInterestRate(e.target.value)}
/>
<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>
{/* 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>
);
}