Files
acmcc/src/pages/PaymentPlansPage.tsx
T
2026-06-01 20:19:26 -04:00

505 lines
24 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useToast } from "@/hooks/use-toast";
import { DollarSign, Plus, MoreHorizontal, Edit, Trash2, Check, ChevronDown, ChevronRight, CalendarCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { useAuth } from "@/contexts/AuthContext";
import { format } from "date-fns";
export default function PaymentPlansPage() {
const { toast } = useToast();
const { user } = useAuth();
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [associations, setAssociations] = useState<any[]>([]);
const [owners, setOwners] = useState<any[]>([]);
const [units, setUnits] = useState<any[]>([]);
const [expandedPlan, setExpandedPlan] = useState<string | null>(null);
const [installments, setInstallments] = useState<Record<string, any[]>>({});
const [form, setForm] = useState({
association_id: "",
owner_id: "",
unit_id: "",
total_amount: "",
monthly_payment: "",
start_date: "",
end_date: "",
status: "active",
notes: "",
});
const fetchData = useCallback(async () => {
setLoading(true);
const { data } = await supabase
.from("payment_plans")
.select("*, associations(name), owners(first_name, last_name), units:unit_id(unit_number, address)")
.order("created_at", { ascending: false });
setPlans(data || []);
setLoading(false);
}, []);
const fetchAssociations = useCallback(async () => {
const { data } = await supabase.from("associations").select("id, name").eq("status", "active").order("name");
setAssociations(data || []);
}, []);
useEffect(() => { fetchData(); fetchAssociations(); }, [fetchData, fetchAssociations]);
// Load owners and units when association changes
const loadAssociationData = useCallback(async (assocId: string) => {
if (!assocId) { setOwners([]); setUnits([]); return; }
const [ownersRes, unitsRes] = await Promise.all([
supabase.from("owners").select("id, first_name, last_name, account_number").eq("association_id", assocId).eq("status", "active").order("last_name"),
supabase.from("units").select("id, unit_number, address").eq("association_id", assocId).order("unit_number"),
]);
setOwners(ownersRes.data || []);
setUnits(unitsRes.data || []);
}, []);
const openNew = () => {
setEditing(null);
setForm({ association_id: "", owner_id: "", unit_id: "", total_amount: "", monthly_payment: "", start_date: "", end_date: "", status: "active", notes: "" });
setOwners([]);
setUnits([]);
setDialogOpen(true);
};
const openEdit = (p: any) => {
setEditing(p);
setForm({
association_id: p.association_id || "",
owner_id: p.owner_id || "",
unit_id: p.unit_id || "",
total_amount: p.total_amount?.toString() || "",
monthly_payment: p.monthly_payment?.toString() || "",
start_date: p.start_date || "",
end_date: p.end_date || "",
status: p.status,
notes: p.notes || "",
});
if (p.association_id) loadAssociationData(p.association_id);
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.association_id) {
toast({ variant: "destructive", title: "Error", description: "Please select an association." });
return;
}
const payload: any = {
association_id: form.association_id,
owner_id: form.owner_id || null,
unit_id: form.unit_id || null,
total_amount: parseFloat(form.total_amount) || 0,
monthly_payment: parseFloat(form.monthly_payment) || 0,
start_date: form.start_date || null,
end_date: form.end_date || null,
status: form.status,
notes: form.notes || null,
};
if (editing) {
await supabase.from("payment_plans").update(payload).eq("id", editing.id);
toast({ title: "Updated" });
} else {
payload.created_by = user?.id;
const { data, error } = await supabase.from("payment_plans").insert(payload).select().single();
if (error) { toast({ variant: "destructive", title: "Error", description: error.message }); return; }
// Auto-generate installments if we have monthly payment and start/end dates
if (data && form.start_date && form.end_date && parseFloat(form.monthly_payment) > 0) {
await generateInstallments(data.id, form.start_date, form.end_date, parseFloat(form.monthly_payment));
}
toast({ title: "Plan created" });
}
setDialogOpen(false);
fetchData();
};
const generateInstallments = async (planId: string, startDate: string, endDate: string, monthlyAmount: number) => {
const installmentRows: any[] = [];
let current = new Date(startDate + "T00:00:00");
const end = new Date(endDate + "T00:00:00");
let num = 1;
while (current <= end) {
installmentRows.push({
payment_plan_id: planId,
installment_number: num,
due_date: current.toISOString().split("T")[0],
amount_due: monthlyAmount,
status: "pending",
});
num++;
current.setMonth(current.getMonth() + 1);
}
if (installmentRows.length > 0) {
await supabase.from("payment_plan_installments").insert(installmentRows);
}
};
const handleDelete = async (id: string) => {
await supabase.from("payment_plans").delete().eq("id", id);
toast({ title: "Deleted" });
fetchData();
};
// Fetch installments for a plan
const fetchInstallments = async (planId: string) => {
const { data } = await supabase
.from("payment_plan_installments")
.select("*")
.eq("payment_plan_id", planId)
.order("installment_number");
setInstallments((prev) => ({ ...prev, [planId]: data || [] }));
};
const toggleExpand = (planId: string) => {
if (expandedPlan === planId) {
setExpandedPlan(null);
} else {
setExpandedPlan(planId);
if (!installments[planId]) fetchInstallments(planId);
}
};
// Mark installment as paid
const markInstallmentPaid = async (inst: any) => {
const newStatus = inst.status === "paid" ? "pending" : "paid";
const updates: any = {
status: newStatus,
paid_date: newStatus === "paid" ? new Date().toISOString().split("T")[0] : null,
amount_paid: newStatus === "paid" ? inst.amount_due : 0,
};
await supabase.from("payment_plan_installments").update(updates).eq("id", inst.id);
fetchInstallments(inst.payment_plan_id);
toast({ title: newStatus === "paid" ? "Marked as Paid" : "Marked as Pending" });
};
// Add a single installment
const [addInstDialog, setAddInstDialog] = useState<string | null>(null);
const [instForm, setInstForm] = useState({ due_date: "", amount_due: "" });
const handleAddInstallment = async () => {
if (!addInstDialog) return;
const existing = installments[addInstDialog] || [];
const nextNum = existing.length > 0 ? Math.max(...existing.map((i: any) => i.installment_number)) + 1 : 1;
await supabase.from("payment_plan_installments").insert({
payment_plan_id: addInstDialog,
installment_number: nextNum,
due_date: instForm.due_date,
amount_due: parseFloat(instForm.amount_due) || 0,
status: "pending",
});
fetchInstallments(addInstDialog);
setAddInstDialog(null);
setInstForm({ due_date: "", amount_due: "" });
toast({ title: "Installment added" });
};
const statusColors: Record<string, string> = {
active: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
completed: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
defaulted: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
};
const fmt = (n: number) => `$${Number(n).toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<DollarSign className="h-6 w-6 text-primary" /> Payment Plans
</h1>
<p className="text-sm text-muted-foreground mt-1">Set up and manage installment payment plans.</p>
</div>
<Button className="gap-2" onClick={openNew}><Plus className="h-4 w-4" /> New Plan</Button>
</div>
{loading ? (
<div className="flex justify-center py-12"><div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" /></div>
) : plans.length === 0 ? (
<Card><CardContent className="py-12 text-center text-muted-foreground">No payment plans found.</CardContent></Card>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Owner</TableHead>
<TableHead>Property</TableHead>
<TableHead>Association</TableHead>
<TableHead>Total</TableHead>
<TableHead>Monthly</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{plans.map((p) => {
const isExpanded = expandedPlan === p.id;
const planInstallments = installments[p.id] || [];
const paidCount = planInstallments.filter((i: any) => i.status === "paid").length;
const totalCount = planInstallments.length;
const paidAmount = planInstallments.reduce((sum: number, i: any) => sum + Number(i.amount_paid || 0), 0);
return (
<>
<TableRow key={p.id} className="cursor-pointer hover:bg-muted/50" onClick={() => toggleExpand(p.id)}>
<TableCell>
{isExpanded ? <ChevronDown className="h-4 w-4 text-muted-foreground" /> : <ChevronRight className="h-4 w-4 text-muted-foreground" />}
</TableCell>
<TableCell className="font-medium">
{p.owners ? `${p.owners.first_name} ${p.owners.last_name}` : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
{p.units ? (p.units.unit_number || p.units.address) : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-sm">{p.associations?.name || "—"}</TableCell>
<TableCell>{fmt(p.total_amount)}</TableCell>
<TableCell>{fmt(p.monthly_payment)}</TableCell>
<TableCell>
{totalCount > 0 ? (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden max-w-[80px]">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${(paidCount / totalCount) * 100}%` }} />
</div>
<span className="text-xs text-muted-foreground">{paidCount}/{totalCount}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">No installments</span>
)}
</TableCell>
<TableCell><Badge className={statusColors[p.status] || "bg-muted text-muted-foreground"}>{p.status}</Badge></TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => openEdit(p)}><Edit className="h-4 w-4 mr-2" /> Edit</DropdownMenuItem>
<DropdownMenuItem className="text-destructive" onClick={() => handleDelete(p.id)}><Trash2 className="h-4 w-4 mr-2" /> Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
{/* Expanded installments */}
{isExpanded && (
<TableRow key={`${p.id}-inst`}>
<TableCell colSpan={9} className="bg-muted/20 p-0">
<div className="px-6 py-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-2">
<CalendarCheck className="h-4 w-4" /> Payment Schedule
{totalCount > 0 && <span className="text-muted-foreground font-normal"> {fmt(paidAmount)} paid of {fmt(p.total_amount)}</span>}
</h3>
<Button size="sm" variant="outline" className="gap-1.5 text-xs" onClick={() => { setAddInstDialog(p.id); setInstForm({ due_date: "", amount_due: p.monthly_payment?.toString() || "" }); }}>
<Plus className="h-3 w-3" /> Add Installment
</Button>
</div>
{planInstallments.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">No installments yet. Add installments or they'll be auto-generated when creating a new plan with start/end dates.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">#</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Amount Due</TableHead>
<TableHead>Amount Paid</TableHead>
<TableHead>Paid Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{planInstallments.map((inst: any) => (
<TableRow key={inst.id} className={inst.status === "paid" ? "bg-emerald-50/50 dark:bg-emerald-900/10" : ""}>
<TableCell className="text-xs text-muted-foreground">{inst.installment_number}</TableCell>
<TableCell className="text-sm">{inst.due_date ? format(new Date(inst.due_date + "T00:00:00"), "MMM d, yyyy") : "—"}</TableCell>
<TableCell className="text-sm">{fmt(inst.amount_due)}</TableCell>
<TableCell className="text-sm">{inst.amount_paid > 0 ? fmt(inst.amount_paid) : "—"}</TableCell>
<TableCell className="text-sm">{inst.paid_date ? format(new Date(inst.paid_date + "T00:00:00"), "MMM d, yyyy") : "—"}</TableCell>
<TableCell>
<Badge className={inst.status === "paid" ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300" : "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"}>
{inst.status}
</Badge>
</TableCell>
<TableCell>
<Button
size="sm"
variant={inst.status === "paid" ? "outline" : "default"}
className="gap-1 text-xs h-7"
onClick={() => markInstallmentPaid(inst)}
>
<Check className="h-3 w-3" />
{inst.status === "paid" ? "Undo" : "Paid"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
})}
</TableBody>
</Table>
</Card>
)}
{/* Create / Edit Plan Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? "Edit" : "New"} Payment Plan</DialogTitle>
<DialogDescription>Configure the payment plan details, owner, and property.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Association */}
<div>
<Label className="text-xs">Association</Label>
<Select
value={form.association_id}
onValueChange={(v) => {
setForm({ ...form, association_id: v, owner_id: "", unit_id: "" });
loadAssociationData(v);
}}
>
<SelectTrigger className="h-9 text-sm"><SelectValue placeholder="Select association" /></SelectTrigger>
<SelectContent>
{associations.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* Owner & Unit */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Owner</Label>
<Select value={form.owner_id} onValueChange={(v) => setForm({ ...form, owner_id: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue placeholder="Select owner" /></SelectTrigger>
<SelectContent>
{owners.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.last_name}, {o.first_name} {o.account_number ? `(${o.account_number})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Property / Unit</Label>
<Select value={form.unit_id} onValueChange={(v) => setForm({ ...form, unit_id: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue placeholder="Select unit" /></SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.unit_number || u.address}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Amounts */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Total Amount</Label>
<Input type="number" value={form.total_amount} onChange={(e) => setForm({ ...form, total_amount: e.target.value })} className="h-9 text-sm" />
</div>
<div>
<Label className="text-xs">Monthly Payment</Label>
<Input type="number" value={form.monthly_payment} onChange={(e) => setForm({ ...form, monthly_payment: e.target.value })} className="h-9 text-sm" />
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs">Start Date</Label>
<Input type="date" value={form.start_date} onChange={(e) => setForm({ ...form, start_date: e.target.value })} className="h-9 text-sm" />
</div>
<div>
<Label className="text-xs">End Date</Label>
<Input type="date" value={form.end_date} onChange={(e) => setForm({ ...form, end_date: e.target.value })} className="h-9 text-sm" />
</div>
</div>
{/* Status */}
<div>
<Label className="text-xs">Status</Label>
<Select value={form.status} onValueChange={(v) => setForm({ ...form, status: v })}>
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="defaulted">Defaulted</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes */}
<div>
<Label className="text-xs">Notes</Label>
<Textarea value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} rows={2} className="text-sm" />
</div>
{!editing && form.start_date && form.end_date && parseFloat(form.monthly_payment) > 0 && (
<p className="text-xs text-muted-foreground bg-muted/50 rounded p-2">
💡 Installments will be auto-generated monthly from {form.start_date} to {form.end_date}.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave}>{editing ? "Update" : "Create"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Installment Dialog */}
<Dialog open={!!addInstDialog} onOpenChange={() => setAddInstDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add Installment</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-xs">Due Date</Label>
<Input type="date" value={instForm.due_date} onChange={(e) => setInstForm({ ...instForm, due_date: e.target.value })} className="h-9 text-sm" />
</div>
<div>
<Label className="text-xs">Amount Due</Label>
<Input type="number" value={instForm.amount_due} onChange={(e) => setInstForm({ ...instForm, amount_due: e.target.value })} className="h-9 text-sm" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddInstDialog(null)}>Cancel</Button>
<Button onClick={handleAddInstallment} disabled={!instForm.due_date}>Add</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}