diff --git a/src/components/cases/payment-plans-tab.tsx b/src/components/cases/payment-plans-tab.tsx new file mode 100644 index 0000000..bba1adb --- /dev/null +++ b/src/components/cases/payment-plans-tab.tsx @@ -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([]); + const [installments, setInstallments] = useState>({}); + const [requests, setRequests] = useState>({}); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [busyId, setBusyId] = useState(null); + + // create form + const [name, setName] = useState("Payment Plan"); + const [total, setTotal] = useState(""); + const [count, setCount] = useState("3"); + const [frequency, setFrequency] = useState("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 = {}; + (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 = {}; + (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) => { + 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 ( + + Loading payment plans… + + ); + } + + return ( +
+
+
+

Payment Plans

+

Schedule installments and collect online or manually.

+
+ +
+ + {plans.length === 0 ? ( + + + No payment plans yet for this case. + + ) : 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 ( + + +
+
+
{plan.name || "Payment Plan"}
+
+ Total {formatCurrency(plan.total_amount)} • {plan.installment_count} {plan.frequency} payments • starts {formatDate(plan.start_date)} +
+ {plan.down_payment > 0 && ( +
Down payment: {formatCurrency(plan.down_payment)}
+ )} +
+ Paid: {formatCurrency(paidTotal)} / {formatCurrency(plan.total_amount - plan.down_payment)} +
+
+
+ {plan.status} + +
+
+ + + + + # + Due date + Amount + Status + Paid on / method + Actions + + + + {ins.map((i) => { + const req = i.payment_request_id ? requests[i.payment_request_id] : null; + return ( + + {i.sort_order} + + updateInstallment(i.id, { due_date: e.target.value })} + className="h-8 w-[140px]" + /> + + + updateInstallment(i.id, { amount: parseFloat(e.target.value) || 0 })} + className="h-8 w-[110px]" + /> + + + {i.paid ? ( + Paid + ) : req ? ( + {req.status} + ) : ( + Scheduled + )} + + + {i.paid ? ( + {i.paid_on ? formatDate(i.paid_on) : ""}{i.paid_method ? ` • ${i.paid_method}` : ""}{i.paid_reference ? ` (${i.paid_reference})` : ""} + ) : "—"} + + +
+ {!i.paid && !req && ( + + )} + {!i.paid && req && ( + <> + {req.stripe_checkout_url && ( + + )} + + + )} + {!i.paid ? ( + + ) : ( + + )} +
+
+
+ ); + })} +
+
+
+
+ ); + })} + + + + + New Payment Plan + Total amount divided into installments. You can edit each installment afterwards. + +
+
+ + setName(e.target.value)} /> +
+
+
+ + setTotal(e.target.value)} placeholder="0.00" /> +
+
+ + setDownPayment(e.target.value)} /> +
+
+
+
+ + setCount(e.target.value)} /> +
+
+ + +
+
+ + setStartDate(e.target.value)} /> +
+
+
+ Each installment will be approximately {formatCurrency(previewAmount)}. +
+
+ +