BillTracker/client/components/tracker/PaymentLedgerDialog.tsx

255 lines
9.5 KiB
TypeScript

import { useState } from 'react';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';
import { createPaymentOrConfirm } from '@/lib/paymentActions';
import { fmt, fmtDate, errMessage } 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';
import type { Dollars } from '@/lib/money';
import type { Payment, TrackerRow } from '@/types';
interface PaymentLedgerDialogProps {
row: TrackerRow;
year: number;
month: number;
threshold: Dollars;
defaultPaymentDate: string;
onClose: () => void;
onSaved?: () => void;
}
export function PaymentLedgerDialog({
row,
year,
month,
threshold,
defaultPaymentDate,
onClose,
onSaved,
}: PaymentLedgerDialogProps) {
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<Payment | null>(null);
const payments = [...(row.payments || [])].sort((a, b) =>
String(b.paid_date).localeCompare(String(a.paid_date)),
);
async function handleAdd(e: React.FormEvent) {
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 createPaymentOrConfirm(
{
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(errMessage(err, '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>
) : (
<div className="flex flex-col items-center gap-1 rounded-md bg-muted/20 px-3 py-7 text-center">
<p className="text-sm font-medium text-muted-foreground">No payments yet</p>
<p className="text-xs text-muted-foreground/60">Add one below.</p>
</div>
)}
</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);
}}
/>
)}
</>
);
}