268 lines
11 KiB
JavaScript
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>
|
|
);
|
|
}
|