2026-05-31 15:06:10 -05:00
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { Plus } from 'lucide-react';
|
|
|
|
|
import { toast } from 'sonner';
|
2026-07-05 11:51:06 -05:00
|
|
|
import { createPaymentOrConfirm } from '@/lib/paymentActions';
|
2026-07-04 19:29:42 -05:00
|
|
|
import { fmt, fmtDate, errMessage } from '@/lib/utils';
|
2026-05-31 15:06:10 -05:00
|
|
|
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';
|
2026-07-06 14:23:53 -05:00
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
2026-05-31 15:06:10 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Select,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
2026-05-31 15:06:10 -05:00
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
|
|
|
|
import { PaymentProgress } from '@/components/tracker/PaymentProgress';
|
|
|
|
|
import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton';
|
2026-07-04 19:29:42 -05:00
|
|
|
import type { Dollars } from '@/lib/money';
|
|
|
|
|
import type { Payment, TrackerRow } from '@/types';
|
2026-05-31 15:06:10 -05:00
|
|
|
|
2026-07-04 19:29:42 -05:00
|
|
|
interface PaymentLedgerDialogProps {
|
|
|
|
|
row: TrackerRow;
|
|
|
|
|
year: number;
|
|
|
|
|
month: number;
|
|
|
|
|
threshold: Dollars;
|
|
|
|
|
defaultPaymentDate: string;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSaved?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
export function PaymentLedgerDialog({
|
|
|
|
|
row,
|
|
|
|
|
year,
|
|
|
|
|
month,
|
|
|
|
|
threshold,
|
|
|
|
|
defaultPaymentDate,
|
|
|
|
|
onClose,
|
|
|
|
|
onSaved,
|
|
|
|
|
}: PaymentLedgerDialogProps) {
|
2026-05-31 15:06:10 -05:00
|
|
|
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);
|
2026-07-04 19:29:42 -05:00
|
|
|
const [editPayment, setEditPayment] = useState<Payment | null>(null);
|
2026-07-06 14:23:53 -05:00
|
|
|
const payments = [...(row.payments || [])].sort((a, b) =>
|
|
|
|
|
String(b.paid_date).localeCompare(String(a.paid_date)),
|
|
|
|
|
);
|
2026-05-31 15:06:10 -05:00
|
|
|
|
2026-07-04 19:29:42 -05:00
|
|
|
async function handleAdd(e: React.FormEvent) {
|
2026-05-31 15:06:10 -05:00
|
|
|
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 {
|
2026-07-05 11:51:06 -05:00
|
|
|
await createPaymentOrConfirm(
|
|
|
|
|
{
|
|
|
|
|
bill_id: row.id,
|
|
|
|
|
amount: parsedAmount,
|
|
|
|
|
paid_date: date,
|
|
|
|
|
method: method === METHOD_NONE ? null : method,
|
|
|
|
|
notes: notes || null,
|
|
|
|
|
},
|
2026-07-06 14:23:53 -05:00
|
|
|
() => {
|
|
|
|
|
toast.success('Partial payment added');
|
|
|
|
|
onSaved?.();
|
|
|
|
|
onClose?.();
|
|
|
|
|
},
|
2026-07-05 11:51:06 -05:00
|
|
|
);
|
2026-05-31 15:06:10 -05:00
|
|
|
} catch (err) {
|
2026-07-04 19:29:42 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to add payment'));
|
2026-05-31 15:06:10 -05:00
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Dialog
|
|
|
|
|
open
|
|
|
|
|
onOpenChange={(value) => {
|
|
|
|
|
if (!value) onClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-05-31 15:06:10 -05:00
|
|
|
<DialogContent className="max-h-[92svh] overflow-y-auto border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-2xl">
|
|
|
|
|
<DialogHeader>
|
2026-07-06 14:23:53 -05:00
|
|
|
<DialogTitle className="text-base font-semibold tracking-tight">
|
|
|
|
|
{row.name} Payments
|
|
|
|
|
</DialogTitle>
|
2026-05-31 15:06:10 -05:00
|
|
|
</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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<LowerThisMonthButton row={row} year={year} month={month} refresh={onSaved} />
|
2026-05-31 15:06:10 -05:00
|
|
|
</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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Payment History
|
|
|
|
|
</p>
|
2026-05-31 15:06:10 -05:00
|
|
|
{payments.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
{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"
|
|
|
|
|
>
|
2026-05-31 15:06:10 -05:00
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="font-mono text-sm font-semibold text-foreground">
|
|
|
|
|
{fmt(payment.amount)}
|
|
|
|
|
</p>
|
2026-05-31 15:06:10 -05:00
|
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
|
|
|
{fmtDate(payment.paid_date)}
|
|
|
|
|
{payment.method ? ` · ${payment.method}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
{payment.notes && (
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground/80">
|
|
|
|
|
{payment.notes}
|
|
|
|
|
</p>
|
2026-05-31 15:06:10 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="h-8 px-2.5 text-xs"
|
|
|
|
|
onClick={() => setEditPayment(payment)}
|
|
|
|
|
>
|
2026-05-31 15:06:10 -05:00
|
|
|
Edit
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-06-07 15:14:09 -05:00
|
|
|
<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>
|
2026-05-31 15:06:10 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<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>
|
2026-05-31 15:06:10 -05:00
|
|
|
<div className="space-y-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Label
|
|
|
|
|
htmlFor={`partial-amount-${row.id}`}
|
|
|
|
|
className="text-xs uppercase tracking-wider text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
Amount
|
|
|
|
|
</Label>
|
2026-05-31 15:06:10 -05:00
|
|
|
<Input
|
|
|
|
|
id={`partial-amount-${row.id}`}
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={amount}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setAmount(e.target.value)}
|
2026-05-31 15:06:10 -05:00
|
|
|
className="font-mono bg-background/70 border-border/60"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Label
|
|
|
|
|
htmlFor={`partial-date-${row.id}`}
|
|
|
|
|
className="text-xs uppercase tracking-wider text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
Paid Date
|
|
|
|
|
</Label>
|
2026-05-31 15:06:10 -05:00
|
|
|
<Input
|
|
|
|
|
id={`partial-date-${row.id}`}
|
|
|
|
|
type="date"
|
|
|
|
|
value={date}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setDate(e.target.value)}
|
2026-05-31 15:06:10 -05:00
|
|
|
className="font-mono bg-background/70 border-border/60"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
|
|
|
|
Method
|
|
|
|
|
</Label>
|
2026-05-31 15:06:10 -05:00
|
|
|
<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">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Label
|
|
|
|
|
htmlFor={`partial-notes-${row.id}`}
|
|
|
|
|
className="text-xs uppercase tracking-wider text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
Notes
|
|
|
|
|
</Label>
|
2026-05-31 15:06:10 -05:00
|
|
|
<Input
|
|
|
|
|
id={`partial-notes-${row.id}`}
|
|
|
|
|
value={notes}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setNotes(e.target.value)}
|
2026-05-31 15:06:10 -05:00
|
|
|
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}
|
2026-06-07 14:49:39 -05:00
|
|
|
autopayEnabled={!!row.autopay_enabled}
|
2026-05-31 15:06:10 -05:00
|
|
|
onClose={() => setEditPayment(null)}
|
|
|
|
|
onSave={() => {
|
|
|
|
|
onSaved?.();
|
|
|
|
|
setEditPayment(null);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|