Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
5520ba109e
commit
2a10b934c0
@@ -0,0 +1,499 @@
|
||||
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 { 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CalendarClock, CreditCard, ExternalLink, Loader2, Plus, Trash2, CheckCircle2, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatCurrency, formatDate } from "@/lib/format";
|
||||
import { createPaymentRequest, refreshPaymentStatus } from "@/lib/payments.functions";
|
||||
|
||||
type Frequency = "weekly" | "biweekly" | "monthly";
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
case_id: string | null;
|
||||
collection_id: string | null;
|
||||
name: string | null;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
down_payment: number;
|
||||
installment_count: number;
|
||||
installment_amount: number;
|
||||
frequency: Frequency;
|
||||
start_date: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
paid_method: string | null;
|
||||
paid_reference: string | null;
|
||||
payment_request_id: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface PaymentRequestRow {
|
||||
id: string;
|
||||
status: string;
|
||||
stripe_checkout_url: string | null;
|
||||
paid_at: string | null;
|
||||
}
|
||||
|
||||
function addPeriod(dateISO: string, frequency: Frequency, idx: number): string {
|
||||
const d = new Date(dateISO + "T00:00:00");
|
||||
if (frequency === "weekly") d.setDate(d.getDate() + 7 * idx);
|
||||
else if (frequency === "biweekly") d.setDate(d.getDate() + 14 * idx);
|
||||
else d.setMonth(d.getMonth() + idx);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function CasePaymentPlansTab({ caseRecord }: { caseRecord: any }) {
|
||||
const caseId: string = caseRecord.id;
|
||||
const { user } = useAuth();
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [installments, setInstallments] = useState<Record<string, Installment[]>>({});
|
||||
const [requests, setRequests] = useState<Record<string, PaymentRequestRow>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
// create form
|
||||
const [name, setName] = useState("Payment Plan");
|
||||
const [total, setTotal] = useState("");
|
||||
const [count, setCount] = useState("3");
|
||||
const [frequency, setFrequency] = useState<Frequency>("monthly");
|
||||
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [downPayment, setDownPayment] = useState("0");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const { data: pls, error } = await supabase
|
||||
.from("payment_plans")
|
||||
.select("*")
|
||||
.eq("case_id", caseId)
|
||||
.order("created_at", { ascending: false });
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setPlans((pls ?? []) as Plan[]);
|
||||
const planIds = (pls ?? []).map((p) => p.id);
|
||||
if (planIds.length) {
|
||||
const { data: ins } = await supabase
|
||||
.from("payment_plan_installments")
|
||||
.select("*")
|
||||
.in("plan_id", planIds)
|
||||
.order("sort_order");
|
||||
const grouped: Record<string, Installment[]> = {};
|
||||
(ins ?? []).forEach((i: any) => {
|
||||
(grouped[i.plan_id] ||= []).push(i as Installment);
|
||||
});
|
||||
setInstallments(grouped);
|
||||
|
||||
const reqIds = (ins ?? []).map((i: any) => i.payment_request_id).filter(Boolean);
|
||||
if (reqIds.length) {
|
||||
const { data: prs } = await supabase
|
||||
.from("payment_requests")
|
||||
.select("id, status, stripe_checkout_url, paid_at")
|
||||
.in("id", reqIds);
|
||||
const map: Record<string, PaymentRequestRow> = {};
|
||||
(prs ?? []).forEach((r: any) => (map[r.id] = r));
|
||||
setRequests(map);
|
||||
} else {
|
||||
setRequests({});
|
||||
}
|
||||
} else {
|
||||
setInstallments({});
|
||||
setRequests({});
|
||||
}
|
||||
setLoading(false);
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const previewAmount = useMemo(() => {
|
||||
const t = parseFloat(total) || 0;
|
||||
const dp = parseFloat(downPayment) || 0;
|
||||
const c = Math.max(1, parseInt(count) || 1);
|
||||
return Math.max(0, (t - dp) / c);
|
||||
}, [total, downPayment, count]);
|
||||
|
||||
const resetCreate = () => {
|
||||
setName("Payment Plan");
|
||||
setTotal("");
|
||||
setCount("3");
|
||||
setFrequency("monthly");
|
||||
setStartDate(new Date().toISOString().slice(0, 10));
|
||||
setDownPayment("0");
|
||||
setNotes("");
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const t = parseFloat(total);
|
||||
const c = parseInt(count);
|
||||
const dp = parseFloat(downPayment) || 0;
|
||||
if (!t || t <= 0) return toast.error("Enter a total amount");
|
||||
if (!c || c < 1) return toast.error("At least 1 installment");
|
||||
const installmentAmount = +((t - dp) / c).toFixed(2);
|
||||
const { data: plan, error } = await supabase
|
||||
.from("payment_plans")
|
||||
.insert({
|
||||
case_id: caseId,
|
||||
name,
|
||||
total_amount: t,
|
||||
down_payment: dp,
|
||||
installment_count: c,
|
||||
installment_amount: installmentAmount,
|
||||
frequency,
|
||||
start_date: startDate,
|
||||
notes: notes || null,
|
||||
status: "active",
|
||||
created_by: user?.id ?? null,
|
||||
})
|
||||
.select("*")
|
||||
.single();
|
||||
if (error || !plan) return toast.error(error?.message ?? "Failed to create plan");
|
||||
|
||||
const rows = Array.from({ length: c }).map((_, i) => ({
|
||||
plan_id: plan.id,
|
||||
sort_order: i + 1,
|
||||
due_date: addPeriod(startDate, frequency, i),
|
||||
amount: installmentAmount,
|
||||
paid: false,
|
||||
}));
|
||||
const { error: insErr } = await supabase.from("payment_plan_installments").insert(rows);
|
||||
if (insErr) return toast.error(insErr.message);
|
||||
toast.success("Payment plan created");
|
||||
setCreateOpen(false);
|
||||
resetCreate();
|
||||
load();
|
||||
};
|
||||
|
||||
const updateInstallment = async (id: string, patch: Partial<Installment>) => {
|
||||
const { error } = await supabase.from("payment_plan_installments").update(patch).eq("id", id);
|
||||
if (error) toast.error(error.message);
|
||||
else load();
|
||||
};
|
||||
|
||||
const deletePlan = async (id: string) => {
|
||||
if (!confirm("Delete this payment plan and all installments?")) return;
|
||||
const { error } = await supabase.from("payment_plans").delete().eq("id", id);
|
||||
if (error) toast.error(error.message);
|
||||
else { toast.success("Plan deleted"); load(); }
|
||||
};
|
||||
|
||||
const generatePayment = async (inst: Installment, plan: Plan) => {
|
||||
setBusyId(inst.id);
|
||||
try {
|
||||
const res = await createPaymentRequest({
|
||||
data: {
|
||||
recipient_name: caseRecord.client?.name || caseRecord.title || "Payment",
|
||||
description: `${plan.name || "Payment Plan"} – Installment ${inst.sort_order}`,
|
||||
base_amount_cents: Math.round(Number(inst.amount) * 100),
|
||||
case_id: caseId,
|
||||
client_id: caseRecord.client_id ?? undefined,
|
||||
},
|
||||
});
|
||||
await supabase
|
||||
.from("payment_plan_installments")
|
||||
.update({ payment_request_id: res.id })
|
||||
.eq("id", inst.id);
|
||||
toast.success("Payment link generated");
|
||||
if (res.checkout_url) window.open(res.checkout_url, "_blank");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to generate payment");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStatus = async (inst: Installment) => {
|
||||
if (!inst.payment_request_id) return;
|
||||
setBusyId(inst.id);
|
||||
try {
|
||||
const res = await refreshPaymentStatus({ data: { id: inst.payment_request_id } });
|
||||
if (res.status === "paid" && !inst.paid) {
|
||||
await supabase
|
||||
.from("payment_plan_installments")
|
||||
.update({
|
||||
paid: true,
|
||||
paid_on: new Date().toISOString().slice(0, 10),
|
||||
paid_amount: inst.amount,
|
||||
paid_method: "online",
|
||||
})
|
||||
.eq("id", inst.id);
|
||||
toast.success("Marked as paid");
|
||||
} else {
|
||||
toast.message(`Status: ${res.status}`);
|
||||
}
|
||||
load();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to refresh");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const markPaidManual = async (inst: Installment) => {
|
||||
const dateStr = prompt("Paid on (YYYY-MM-DD)?", new Date().toISOString().slice(0, 10));
|
||||
if (!dateStr) return;
|
||||
const method = prompt("Payment method (check, cash, ACH, etc.)?", "check") || "manual";
|
||||
const reference = prompt("Reference / check #? (optional)", "") || null;
|
||||
await updateInstallment(inst.id, {
|
||||
paid: true,
|
||||
paid_on: dateStr,
|
||||
paid_amount: inst.amount,
|
||||
paid_method: method,
|
||||
paid_reference: reference,
|
||||
});
|
||||
};
|
||||
|
||||
const unmarkPaid = async (inst: Installment) => {
|
||||
if (!confirm("Mark this installment as unpaid?")) return;
|
||||
await updateInstallment(inst.id, {
|
||||
paid: false,
|
||||
paid_on: null,
|
||||
paid_amount: null,
|
||||
paid_method: null,
|
||||
paid_reference: null,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card><CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto h-5 w-5 animate-spin mb-2" /> Loading payment plans…
|
||||
</CardContent></Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Payment Plans</h3>
|
||||
<p className="text-xs text-muted-foreground">Schedule installments and collect online or manually.</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)} size="sm"><Plus className="h-4 w-4 mr-1" />New plan</Button>
|
||||
</div>
|
||||
|
||||
{plans.length === 0 ? (
|
||||
<Card><CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
<CalendarClock className="mx-auto h-6 w-6 mb-2 opacity-60" />
|
||||
No payment plans yet for this case.
|
||||
</CardContent></Card>
|
||||
) : plans.map((plan) => {
|
||||
const ins = installments[plan.id] ?? [];
|
||||
const paidTotal = ins.filter((i) => i.paid).reduce((s, i) => s + Number(i.paid_amount ?? i.amount), 0);
|
||||
return (
|
||||
<Card key={plan.id}>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="font-semibold">{plan.name || "Payment Plan"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Total {formatCurrency(plan.total_amount)} • {plan.installment_count} {plan.frequency} payments • starts {formatDate(plan.start_date)}
|
||||
</div>
|
||||
{plan.down_payment > 0 && (
|
||||
<div className="text-xs text-muted-foreground">Down payment: {formatCurrency(plan.down_payment)}</div>
|
||||
)}
|
||||
<div className="text-xs mt-1">
|
||||
Paid: <span className="font-medium">{formatCurrency(paidTotal)}</span> / {formatCurrency(plan.total_amount - plan.down_payment)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{plan.status}</Badge>
|
||||
<Button size="sm" variant="ghost" onClick={() => deletePlan(plan.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Due date</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Paid on / method</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ins.map((i) => {
|
||||
const req = i.payment_request_id ? requests[i.payment_request_id] : null;
|
||||
return (
|
||||
<TableRow key={i.id}>
|
||||
<TableCell>{i.sort_order}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
value={i.due_date}
|
||||
disabled={i.paid}
|
||||
onChange={(e) => updateInstallment(i.id, { due_date: e.target.value })}
|
||||
className="h-8 w-[140px]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={i.amount}
|
||||
disabled={i.paid}
|
||||
onChange={(e) => updateInstallment(i.id, { amount: parseFloat(e.target.value) || 0 })}
|
||||
className="h-8 w-[110px]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{i.paid ? (
|
||||
<Badge className="bg-green-600 hover:bg-green-600 text-white">Paid</Badge>
|
||||
) : req ? (
|
||||
<Badge variant="outline">{req.status}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Scheduled</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{i.paid ? (
|
||||
<span>{i.paid_on ? formatDate(i.paid_on) : ""}{i.paid_method ? ` • ${i.paid_method}` : ""}{i.paid_reference ? ` (${i.paid_reference})` : ""}</span>
|
||||
) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{!i.paid && !req && (
|
||||
<Button size="sm" variant="outline" disabled={busyId === i.id} onClick={() => generatePayment(i, plan)}>
|
||||
{busyId === i.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CreditCard className="h-3.5 w-3.5" />}
|
||||
<span className="ml-1">Generate</span>
|
||||
</Button>
|
||||
)}
|
||||
{!i.paid && req && (
|
||||
<>
|
||||
{req.stripe_checkout_url && (
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<a href={req.stripe_checkout_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" disabled={busyId === i.id} onClick={() => refreshStatus(i)}>
|
||||
{busyId === i.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!i.paid ? (
|
||||
<Button size="sm" variant="outline" onClick={() => markPaidManual(i)}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />Mark paid
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="ghost" onClick={() => unmarkPaid(i)}>Unpay</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Payment Plan</DialogTitle>
|
||||
<DialogDescription>Total amount divided into installments. You can edit each installment afterwards.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>Total amount</Label>
|
||||
<Input type="number" step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Down payment</Label>
|
||||
<Input type="number" step="0.01" value={downPayment} onChange={(e) => setDownPayment(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<Label># of installments</Label>
|
||||
<Input type="number" min={1} value={count} onChange={(e) => setCount(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>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>Start date</Label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Each installment will be approximately <strong>{formatCurrency(previewAmount)}</strong>.
|
||||
</div>
|
||||
<div>
|
||||
<Label>Notes</Label>
|
||||
<Textarea rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleCreate}>Create plan</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user