BillTracker/client/components/tracker/PaymentLedgerDialog.jsx

187 lines
8.1 KiB
JavaScript

import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api.js';
import { fmt, fmtDate } from '@/lib/utils';
import { METHOD_NONE, paymentSummary } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import PaymentModal from '@/components/tracker/PaymentModal';
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
export function PaymentLedgerDialog({ row, year, month, threshold, defaultPaymentDate, onClose, onSaved }) {
const summary = paymentSummary(row, threshold);
const [amount, setAmount] = useState(String(summary.remaining || summary.target || ''));
const [date, setDate] = useState(defaultPaymentDate);
const [method, setMethod] = useState(METHOD_NONE);
const [notes, setNotes] = useState('');
const [busy, setBusy] = useState(false);
const [editPayment, setEditPayment] = useState(null);
const payments = [...(row.payments || [])].sort((a, b) => String(b.paid_date).localeCompare(String(a.paid_date)));
async function handleAdd(e) {
e.preventDefault();
const parsedAmount = parseFloat(amount);
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
toast.error('Enter a positive payment amount');
return;
}
if (!date) {
toast.error('Choose a payment date');
return;
}
setBusy(true);
try {
await api.createPayment({
bill_id: row.id,
amount: parsedAmount,
paid_date: date,
method: method === METHOD_NONE ? null : method,
notes: notes || null,
});
toast.success('Partial payment added');
onSaved?.();
onClose?.();
} catch (err) {
toast.error(err.message || 'Failed to add payment');
} finally {
setBusy(false);
}
}
return (
<>
<Dialog open onOpenChange={value => { if (!value) onClose(); }}>
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base font-semibold tracking-tight">{row.name} Payments</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-border/60 bg-muted/25 p-3">
<PaymentProgress row={row} threshold={threshold} onOpen={() => {}} />
<div className="mt-2 flex justify-end">
<LowerThisMonthButton
row={row}
year={year}
month={month}
refresh={onSaved}
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Payment History</p>
{payments.length > 0 ? (
<div className="space-y-2">
{payments.map(payment => (
<div key={payment.id} className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-card/60 px-3 py-2">
<div className="min-w-0">
<p className="font-mono text-sm font-semibold text-foreground">{fmt(payment.amount)}</p>
<p className="truncate text-xs text-muted-foreground">
{fmtDate(payment.paid_date)}
{payment.method ? ` · ${payment.method}` : ''}
</p>
{payment.notes && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">{payment.notes}</p>
)}
</div>
<Button type="button" size="sm" variant="ghost" className="h-8 px-2.5 text-xs" onClick={() => setEditPayment(payment)}>
Edit
</Button>
</div>
))}
</div>
) : (
<p className="rounded-md border border-dashed border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
No payments recorded for this month.
</p>
)}
</div>
<form onSubmit={handleAdd} className="rounded-lg border border-border/60 bg-background/45 p-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Add Partial Payment</p>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor={`partial-amount-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Amount</Label>
<Input
id={`partial-amount-${row.id}`}
type="number"
min="0"
step="0.01"
value={amount}
onChange={e => setAmount(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`partial-date-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Paid Date</Label>
<Input
id={`partial-date-${row.id}`}
type="date"
value={date}
onChange={e => setDate(e.target.value)}
className="font-mono bg-background/70 border-border/60"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Method</Label>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger className="bg-background/70 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={`partial-notes-${row.id}`} className="text-xs uppercase tracking-wider text-muted-foreground">Notes</Label>
<Input
id={`partial-notes-${row.id}`}
value={notes}
onChange={e => setNotes(e.target.value)}
className="bg-background/70 border-border/60"
/>
</div>
<Button type="submit" disabled={busy} className="w-full gap-2">
<Plus className="h-4 w-4" />
{busy ? 'Adding...' : 'Add Payment'}
</Button>
</div>
</form>
</div>
</div>
</DialogContent>
</Dialog>
{editPayment && (
<PaymentModal
payment={editPayment}
autopayEnabled={!!row.autopay_enabled}
onClose={() => setEditPayment(null)}
onSave={() => {
onSaved?.();
setEditPayment(null);
}}
/>
)}
</>
);
}