2026-05-31 15:06:10 -05:00
|
|
|
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>
|
|
|
|
|
) : (
|
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>
|
|
|
|
|
|
|
|
|
|
<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}
|
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);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|