BillTracker/client/components/tracker/PaymentModal.jsx

172 lines
7.1 KiB
React
Raw Permalink Normal View History

import React, { useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api.js';
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';
const METHOD_NONE = 'none';
function PaymentModal({ payment, autopayEnabled, onClose, onSave }) {
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) {
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(err.message);
} 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(err.message || 'Failed to restore payment');
}
},
},
});
onSave(); onClose();
} catch (err) {
toast.error(err.message);
} 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;