BillTracker/client/components/tracker/PaymentModal.tsx

258 lines
8.3 KiB
TypeScript

import { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api';
import { errMessage } from '@/lib/utils';
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 {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from '@/components/ui/alert-dialog';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import type { Payment } from '@/types';
const METHOD_NONE = 'none';
interface PaymentModalProps {
payment: Payment;
autopayEnabled?: boolean;
onClose: () => void;
onSave: () => void;
}
function PaymentModal({ payment, autopayEnabled, onClose, onSave }: PaymentModalProps) {
const [amount, setAmount] = useState(String(payment.amount));
const [date, setDate] = useState(payment.paid_date);
// Use METHOD_NONE sentinel — empty string value crashes Radix Select
const [method, setMethod] = useState(payment.method || METHOD_NONE);
const [notes, setNotes] = useState(payment.notes || '');
const [autopayFailure, setAutopayFailure] = useState(!!payment.autopay_failure);
const [busy, setBusy] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const showAutopayFailure = autopayEnabled || method === 'autopay';
async function handleSave(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
try {
await api.updatePayment(payment.id, {
amount: parseFloat(amount),
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
autopay_failure: showAutopayFailure ? (autopayFailure ? 1 : 0) : undefined,
});
toast.success('Payment saved');
onSave();
onClose();
} catch (err) {
toast.error(errMessage(err));
} finally {
setBusy(false);
}
}
async function handleDelete() {
setBusy(true);
try {
await api.deletePayment(payment.id);
toast.success('Payment moved to recovery. Bill is now marked as unpaid.', {
action: {
label: 'Undo',
onClick: async () => {
try {
await api.restorePayment(payment.id);
toast.success('Payment restored');
onSave();
} catch (err) {
toast.error(errMessage(err, 'Failed to restore payment'));
}
},
},
});
onSave();
onClose();
} catch (err) {
toast.error(errMessage(err));
} finally {
setBusy(false);
}
}
return (
<>
<Dialog
open
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent className="sm:max-w-sm border-border/60 bg-card/95 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">
Edit Payment
</DialogTitle>
</DialogHeader>
<form id="payment-modal-form" onSubmit={handleSave} className="space-y-4">
<div className="space-y-1.5">
<Label
htmlFor="pm-amount"
className="text-xs uppercase tracking-wider text-muted-foreground"
>
Amount ($) *
</Label>
<Input
id="pm-amount"
type="number"
min="0"
step="0.01"
required
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="pm-date"
className="text-xs uppercase tracking-wider text-muted-foreground"
>
Paid Date *
</Label>
<Input
id="pm-date"
type="date"
required
value={date}
onChange={(e) => setDate(e.target.value)}
className="font-mono bg-background/50 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label
htmlFor="pm-method"
className="text-xs uppercase tracking-wider text-muted-foreground"
>
Method
</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger id="pm-method" className="bg-background/50 border-border/60">
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
<SelectItem value={METHOD_NONE}></SelectItem>
<SelectItem value="bank">Bank Transfer</SelectItem>
<SelectItem value="card">Card</SelectItem>
<SelectItem value="autopay">Autopay</SelectItem>
<SelectItem value="check">Check</SelectItem>
<SelectItem value="cash">Cash</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
htmlFor="pm-notes"
className="text-xs uppercase tracking-wider text-muted-foreground"
>
Notes
</Label>
<Input
id="pm-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="bg-background/50 border-border/60"
/>
</div>
{showAutopayFailure && (
<label className="flex items-center gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={autopayFailure}
onChange={(e) => setAutopayFailure(e.target.checked)}
className="h-4 w-4 rounded border-border accent-amber-500"
/>
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">
Autopay failed paid manually
</span>
</label>
)}
</form>
<DialogFooter className="flex-row gap-2 sm:justify-between mt-2">
<Button
type="button"
variant="destructive"
disabled={busy}
onClick={() => setConfirmDelete(true)}
className="text-xs"
title="Removes this payment record. The bill itself is NOT deleted."
>
Remove Payment
</Button>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={onClose}
className="text-xs"
>
Cancel
</Button>
<Button type="submit" form="payment-modal-form" disabled={busy} className="text-xs">
Save
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove this payment?</AlertDialogTitle>
<AlertDialogDescription>
This marks the payment as removed and reverses any debt balance update. You can undo
it from the toast.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={busy}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={busy}
onClick={handleDelete}
>
{busy ? 'Removing...' : 'Remove Payment'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default PaymentModal;