Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
09351957de
commit
38c6f72bd2
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CalendarClock, Loader2, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
|
||||
type Frequency = "weekly" | "biweekly" | "monthly";
|
||||
type PlanStatus = "active" | "completed" | "defaulted" | "cancelled";
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
collection_id: string;
|
||||
name: string | null;
|
||||
status: PlanStatus;
|
||||
total_amount: number;
|
||||
down_payment: number;
|
||||
installment_count: number;
|
||||
installment_amount: number;
|
||||
frequency: Frequency;
|
||||
start_date: string;
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Installment {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
sort_order: number;
|
||||
due_date: string;
|
||||
amount: number;
|
||||
paid: boolean;
|
||||
paid_on: string | null;
|
||||
paid_amount: number | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
function addInterval(dateStr: string, frequency: Frequency, n: number) {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (frequency === "weekly") d.setDate(d.getDate() + 7 * n);
|
||||
else if (frequency === "biweekly") d.setDate(d.getDate() + 14 * n);
|
||||
else d.setMonth(d.getMonth() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function PaymentPlansPanel({ collectionId }: { collectionId: string }) {
|
||||
const { user } = useAuth();
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [installments, setInstallments] = useState<Record<string, Installment[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { data: ps, error } = await supabase
|
||||
.from("payment_plans")
|
||||
.select("*")
|
||||
.eq("collection_id", collectionId)
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) {
|
||||
toast.error("Failed to load payment plans", { description: error.message });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setPlans((ps ?? []) as Plan[]);
|
||||
if (ps && ps.length) {
|
||||
const { data: ins } = await supabase
|
||||
.from("payment_plan_installments")
|
||||
.select("*")
|
||||
.in("plan_id", ps.map((p) => p.id))
|
||||
.order("sort_order");
|
||||
const grouped: Record<string, Installment[]> = {};
|
||||
(ins ?? []).forEach((i) => {
|
||||
(grouped[i.plan_id] ||= []).push(i as Installment);
|
||||
});
|
||||
setInstallments(grouped);
|
||||
} else {
|
||||
setInstallments({});
|
||||
}
|
||||
setLoading(false);
|
||||
}, [collectionId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const togglePaid = async (i: Installment, checked: boolean) => {
|
||||
const { error } = await supabase
|
||||
.from("payment_plan_installments")
|
||||
.update({
|
||||
paid: checked,
|
||||
paid_on: checked ? new Date().toISOString().slice(0, 10) : null,
|
||||
paid_amount: checked ? i.amount : null,
|
||||
})
|
||||
.eq("id", i.id);
|
||||
if (error) toast.error(error.message);
|
||||
else load();
|
||||
};
|
||||
|
||||
const updatePlanStatus = async (planId: string, status: PlanStatus) => {
|
||||
const { error } = await supabase
|
||||
.from("payment_plans")
|
||||
.update({ status })
|
||||
.eq("id", planId);
|
||||
if (error) toast.error(error.message);
|
||||
else { toast.success("Plan updated"); load(); }
|
||||
};
|
||||
|
||||
const deletePlan = async (planId: string) => {
|
||||
if (!confirm("Delete this payment plan and all its installments?")) return;
|
||||
const { error } = await supabase.from("payment_plans").delete().eq("id", planId);
|
||||
if (error) toast.error(error.message);
|
||||
else { toast.success("Plan deleted"); load(); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border/60">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-medium">Payment plans</h3>
|
||||
{plans.length > 0 && (
|
||||
<Badge variant="outline" className="text-[10px]">{plans.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : plans.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No payment plans yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plans.map((p) => {
|
||||
const ins = installments[p.id] ?? [];
|
||||
const paidCount = ins.filter((i) => i.paid).length;
|
||||
const paidAmt = ins.reduce((s, i) => s + (i.paid ? Number(i.paid_amount ?? i.amount) : 0), 0);
|
||||
const remaining = Number(p.total_amount) - paidAmt - Number(p.down_payment);
|
||||
return (
|
||||
<div key={p.id} className="border rounded-md">
|
||||
<div className="p-3 flex flex-wrap items-start justify-between gap-3 border-b bg-muted/30">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">
|
||||
{p.name || `Plan started ${formatDate(p.start_date)}`}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] capitalize">{p.status}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{p.installment_count} {p.frequency} installments · started {formatDate(p.start_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={p.status} onValueChange={(v) => updatePlanStatus(p.id, v as PlanStatus)}>
|
||||
<SelectTrigger className="h-8 w-[130px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="defaulted">Defaulted</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => deletePlan(p.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 p-3 text-center text-xs border-b">
|
||||
<Stat label="Total" value={formatCurrency(p.total_amount)} />
|
||||
<Stat label="Down payment" value={formatCurrency(p.down_payment)} />
|
||||
<Stat label="Paid" value={`${formatCurrency(paidAmt)} (${paidCount}/${ins.length})`} />
|
||||
<Stat label="Remaining" value={formatCurrency(Math.max(0, remaining))} />
|
||||
</div>
|
||||
{ins.length > 0 && (
|
||||
<div className="divide-y">
|
||||
{ins.map((i) => {
|
||||
const overdue = !i.paid && new Date(i.due_date) < new Date(new Date().toDateString());
|
||||
return (
|
||||
<div key={i.id} className="flex items-center gap-3 p-2.5 px-3">
|
||||
<Checkbox
|
||||
checked={i.paid}
|
||||
onCheckedChange={(c) => togglePaid(i, !!c)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm ${i.paid ? "line-through text-muted-foreground" : ""}`}>
|
||||
Installment {i.sort_order + 1}
|
||||
{i.paid && i.paid_on && (
|
||||
<span className="ml-2 text-xs text-success">paid {formatDate(i.paid_on)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-xs ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
Due {formatDate(i.due_date)}
|
||||
{overdue && " · overdue"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums">
|
||||
{formatCurrency(i.amount)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{p.notes && (
|
||||
<div className="p-3 text-xs text-muted-foreground border-t whitespace-pre-wrap">
|
||||
{p.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreatePlanDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
collectionId={collectionId}
|
||||
userId={user?.id}
|
||||
onSaved={load}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="border rounded-md py-1.5 px-2 bg-background">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</div>
|
||||
<div className="text-sm font-medium mt-0.5 truncate tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreatePlanDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
collectionId,
|
||||
userId,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
collectionId: string;
|
||||
userId?: string;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [total, setTotal] = useState("");
|
||||
const [down, setDown] = useState("0");
|
||||
const [count, setCount] = useState("6");
|
||||
const [frequency, setFrequency] = useState<Frequency>("monthly");
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [notes, setNotes] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const installmentAmount = useMemo(() => {
|
||||
const t = parseFloat(total) || 0;
|
||||
const d = parseFloat(down) || 0;
|
||||
const c = parseInt(count) || 1;
|
||||
return c > 0 ? Math.round(((t - d) / c) * 100) / 100 : 0;
|
||||
}, [total, down, count]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setName(""); setTotal(""); setDown("0"); setCount("6");
|
||||
setFrequency("monthly"); setStartDate(new Date().toISOString().slice(0, 10));
|
||||
setNotes("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
const t = parseFloat(total);
|
||||
const c = parseInt(count);
|
||||
if (!t || t <= 0) { toast.error("Total amount required"); return; }
|
||||
if (!c || c <= 0) { toast.error("Installment count required"); return; }
|
||||
setSaving(true);
|
||||
const { data: plan, error } = await supabase
|
||||
.from("payment_plans")
|
||||
.insert({
|
||||
collection_id: collectionId,
|
||||
name: name || null,
|
||||
total_amount: t,
|
||||
down_payment: parseFloat(down) || 0,
|
||||
installment_count: c,
|
||||
installment_amount: installmentAmount,
|
||||
frequency,
|
||||
start_date: startDate,
|
||||
notes: notes || null,
|
||||
created_by: userId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error || !plan) {
|
||||
setSaving(false);
|
||||
toast.error(error?.message ?? "Could not create plan");
|
||||
return;
|
||||
}
|
||||
const rows = Array.from({ length: c }).map((_, idx) => ({
|
||||
plan_id: plan.id,
|
||||
sort_order: idx,
|
||||
due_date: addInterval(startDate, frequency, idx),
|
||||
amount: installmentAmount,
|
||||
}));
|
||||
const { error: insErr } = await supabase.from("payment_plan_installments").insert(rows);
|
||||
setSaving(false);
|
||||
if (insErr) {
|
||||
toast.error("Plan saved, but installments failed", { description: insErr.message });
|
||||
} else {
|
||||
toast.success("Payment plan created");
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New payment plan</DialogTitle>
|
||||
<DialogDescription>
|
||||
Generates a schedule. Mark installments paid as you receive funds.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Plan name (optional)</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Smith arrears plan" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Total amount</Label>
|
||||
<Input type="number" step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Down payment</Label>
|
||||
<Input type="number" step="0.01" value={down} onChange={(e) => setDown(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs"># Installments</Label>
|
||||
<Input type="number" min="1" value={count} onChange={(e) => setCount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Frequency</Label>
|
||||
<Select value={frequency} onValueChange={(v) => setFrequency(v as Frequency)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="biweekly">Biweekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Start date</Label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground bg-muted/40 rounded p-2">
|
||||
Each installment: <span className="font-medium text-foreground">{formatCurrency(installmentAmount)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Notes</Label>
|
||||
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Create plan
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user