Built Payment Plans & Calls

X-Lovable-Edit-ID: edt-acfd5f82-abbb-44e0-b791-fe3a96306e53
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:47:07 +00:00
co-authored by renee-png
6 changed files with 1049 additions and 1 deletions
+322
View File
@@ -0,0 +1,322 @@
import { useCallback, useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
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 { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Phone, PhoneIncoming, PhoneOutgoing, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { formatDateTime } from "@/lib/format";
type Direction = "inbound" | "outbound";
interface CallLog {
id: string;
case_id: string;
contact_id: string | null;
call_date: string;
direction: Direction;
duration_minutes: number | null;
caller_name: string | null;
caller_phone: string | null;
subject: string;
notes: string | null;
follow_up_required: boolean;
follow_up_date: string | null;
billable: boolean;
created_by: string | null;
created_at: string;
}
export function CaseCallLogsTab({ caseId }: { caseId: string }) {
const { user, isAdmin } = useAuth();
const [logs, setLogs] = useState<CallLog[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<CallLog | null>(null);
const load = useCallback(async () => {
setLoading(true);
const { data, error } = await supabase
.from("call_logs")
.select("*")
.eq("case_id", caseId)
.order("call_date", { ascending: false });
if (error) toast.error("Failed to load call logs", { description: error.message });
setLogs((data ?? []) as CallLog[]);
setLoading(false);
}, [caseId]);
useEffect(() => { load(); }, [load]);
const remove = async (id: string) => {
if (!confirm("Delete this call log?")) return;
const { error } = await supabase.from("call_logs").delete().eq("id", id);
if (error) toast.error(error.message);
else { toast.success("Deleted"); load(); }
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-primary" />
<h3 className="font-medium">Call log</h3>
{logs.length > 0 && <Badge variant="outline" className="text-[10px]">{logs.length}</Badge>}
</div>
<Button size="sm" onClick={() => { setEditing(null); setOpen(true); }}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Log call
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : logs.length === 0 ? (
<Card className="border-dashed border-border/60">
<CardContent className="p-6 text-center text-sm text-muted-foreground">
No calls logged yet.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{logs.map((c) => {
const canEdit = isAdmin || c.created_by === user?.id;
const Icon = c.direction === "inbound" ? PhoneIncoming : PhoneOutgoing;
return (
<Card key={c.id} className="border-border/60 hover:border-border transition-colors">
<CardContent className="p-3">
<div className="flex items-start gap-3">
<div className={`rounded-full p-2 mt-0.5 ${c.direction === "inbound" ? "bg-success/10 text-success" : "bg-primary/10 text-primary"}`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<button
className="font-medium text-sm text-left hover:underline"
onClick={() => canEdit && (setEditing(c), setOpen(true))}
>
{c.subject || "(no subject)"}
</button>
{c.billable && <Badge variant="outline" className="text-[10px]">Billable</Badge>}
{c.follow_up_required && (
<Badge variant="outline" className="text-[10px] bg-warning/15 text-warning-foreground border-warning/40">
Follow-up{c.follow_up_date ? ` ${c.follow_up_date}` : ""}
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{formatDateTime(c.call_date)}
{c.caller_name && ` · ${c.caller_name}`}
{c.caller_phone && ` · ${c.caller_phone}`}
{c.duration_minutes != null && ` · ${c.duration_minutes} min`}
</div>
{c.notes && (
<p className="text-sm mt-2 whitespace-pre-wrap">{c.notes}</p>
)}
</div>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => remove(c.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
)}
<CallLogDialog
open={open}
onOpenChange={setOpen}
caseId={caseId}
userId={user?.id}
editing={editing}
onSaved={load}
/>
</div>
);
}
function CallLogDialog({
open,
onOpenChange,
caseId,
userId,
editing,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
caseId: string;
userId?: string;
editing: CallLog | null;
onSaved: () => void;
}) {
const [date, setDate] = useState("");
const [direction, setDirection] = useState<Direction>("outbound");
const [duration, setDuration] = useState("");
const [callerName, setCallerName] = useState("");
const [callerPhone, setCallerPhone] = useState("");
const [subject, setSubject] = useState("");
const [notes, setNotes] = useState("");
const [followUp, setFollowUp] = useState(false);
const [followUpDate, setFollowUpDate] = useState("");
const [billable, setBillable] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
// datetime-local needs YYYY-MM-DDTHH:MM
const d = new Date(editing.call_date);
const pad = (n: number) => String(n).padStart(2, "0");
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
setDate(local);
setDirection(editing.direction);
setDuration(editing.duration_minutes?.toString() ?? "");
setCallerName(editing.caller_name ?? "");
setCallerPhone(editing.caller_phone ?? "");
setSubject(editing.subject ?? "");
setNotes(editing.notes ?? "");
setFollowUp(editing.follow_up_required);
setFollowUpDate(editing.follow_up_date ?? "");
setBillable(editing.billable);
} else {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
setDate(`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`);
setDirection("outbound");
setDuration("");
setCallerName("");
setCallerPhone("");
setSubject("");
setNotes("");
setFollowUp(false);
setFollowUpDate("");
setBillable(false);
}
}, [open, editing]);
const submit = async () => {
if (!subject.trim()) { toast.error("Subject required"); return; }
setSaving(true);
const payload = {
case_id: caseId,
call_date: new Date(date).toISOString(),
direction,
duration_minutes: duration ? parseInt(duration) : null,
caller_name: callerName || null,
caller_phone: callerPhone || null,
subject,
notes: notes || null,
follow_up_required: followUp,
follow_up_date: followUp && followUpDate ? followUpDate : null,
billable,
};
const { error } = editing
? await supabase.from("call_logs").update(payload).eq("id", editing.id)
: await supabase.from("call_logs").insert({ ...payload, created_by: userId });
setSaving(false);
if (error) toast.error(error.message);
else {
toast.success(editing ? "Updated" : "Logged");
onOpenChange(false);
onSaved();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editing ? "Edit call log" : "Log a call"}</DialogTitle>
<DialogDescription>Track inbound and outbound calls related to this case.</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Date / time</Label>
<Input type="datetime-local" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div>
<Label className="text-xs">Direction</Label>
<Select value={direction} onValueChange={(v) => setDirection(v as Direction)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="outbound">Outbound</SelectItem>
<SelectItem value="inbound">Inbound</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs">Subject</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="What was the call about?" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs">Caller name</Label>
<Input value={callerName} onChange={(e) => setCallerName(e.target.value)} />
</div>
<div>
<Label className="text-xs">Caller phone</Label>
<Input value={callerPhone} onChange={(e) => setCallerPhone(e.target.value)} />
</div>
</div>
<div>
<Label className="text-xs">Duration (minutes)</Label>
<Input type="number" min="0" value={duration} onChange={(e) => setDuration(e.target.value)} />
</div>
<div>
<Label className="text-xs">Notes</Label>
<Textarea rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={billable} onCheckedChange={(c) => setBillable(!!c)} />
Billable
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={followUp} onCheckedChange={(c) => setFollowUp(!!c)} />
Follow-up needed
</label>
</div>
{followUp && (
<div>
<Label className="text-xs">Follow-up date</Label>
<Input type="date" value={followUpDate} onChange={(e) => setFollowUpDate(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" />}
{editing ? "Save" : "Log call"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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>
);
}
+201
View File
@@ -14,6 +14,88 @@ export type Database = {
}
public: {
Tables: {
call_logs: {
Row: {
billable: boolean
call_date: string
caller_name: string | null
caller_phone: string | null
case_id: string
contact_id: string | null
created_at: string
created_by: string | null
direction: string
duration_minutes: number | null
follow_up_date: string | null
follow_up_required: boolean
homeowner_id: string | null
id: string
notes: string | null
subject: string
updated_at: string
}
Insert: {
billable?: boolean
call_date?: string
caller_name?: string | null
caller_phone?: string | null
case_id: string
contact_id?: string | null
created_at?: string
created_by?: string | null
direction?: string
duration_minutes?: number | null
follow_up_date?: string | null
follow_up_required?: boolean
homeowner_id?: string | null
id?: string
notes?: string | null
subject?: string
updated_at?: string
}
Update: {
billable?: boolean
call_date?: string
caller_name?: string | null
caller_phone?: string | null
case_id?: string
contact_id?: string | null
created_at?: string
created_by?: string | null
direction?: string
duration_minutes?: number | null
follow_up_date?: string | null
follow_up_required?: boolean
homeowner_id?: string | null
id?: string
notes?: string | null
subject?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "call_logs_case_id_fkey"
columns: ["case_id"]
isOneToOne: false
referencedRelation: "cases"
referencedColumns: ["id"]
},
{
foreignKeyName: "call_logs_contact_id_fkey"
columns: ["contact_id"]
isOneToOne: false
referencedRelation: "contacts"
referencedColumns: ["id"]
},
{
foreignKeyName: "call_logs_homeowner_id_fkey"
columns: ["homeowner_id"]
isOneToOne: false
referencedRelation: "homeowners"
referencedColumns: ["id"]
},
]
}
case_contacts: {
Row: {
case_id: string
@@ -1435,6 +1517,125 @@ export type Database = {
},
]
}
payment_plan_installments: {
Row: {
amount: number
created_at: string
due_date: string
id: string
ledger_entry_id: string | null
notes: string | null
paid: boolean
paid_amount: number | null
paid_on: string | null
plan_id: string
sort_order: number
updated_at: string
}
Insert: {
amount?: number
created_at?: string
due_date: string
id?: string
ledger_entry_id?: string | null
notes?: string | null
paid?: boolean
paid_amount?: number | null
paid_on?: string | null
plan_id: string
sort_order?: number
updated_at?: string
}
Update: {
amount?: number
created_at?: string
due_date?: string
id?: string
ledger_entry_id?: string | null
notes?: string | null
paid?: boolean
paid_amount?: number | null
paid_on?: string | null
plan_id?: string
sort_order?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "payment_plan_installments_ledger_entry_id_fkey"
columns: ["ledger_entry_id"]
isOneToOne: false
referencedRelation: "collection_ledger_entries"
referencedColumns: ["id"]
},
{
foreignKeyName: "payment_plan_installments_plan_id_fkey"
columns: ["plan_id"]
isOneToOne: false
referencedRelation: "payment_plans"
referencedColumns: ["id"]
},
]
}
payment_plans: {
Row: {
collection_id: string
created_at: string
created_by: string | null
down_payment: number
frequency: string
id: string
installment_amount: number
installment_count: number
name: string | null
notes: string | null
start_date: string
status: string
total_amount: number
updated_at: string
}
Insert: {
collection_id: string
created_at?: string
created_by?: string | null
down_payment?: number
frequency?: string
id?: string
installment_amount?: number
installment_count?: number
name?: string | null
notes?: string | null
start_date?: string
status?: string
total_amount?: number
updated_at?: string
}
Update: {
collection_id?: string
created_at?: string
created_by?: string | null
down_payment?: number
frequency?: string
id?: string
installment_amount?: number
installment_count?: number
name?: string | null
notes?: string | null
start_date?: string
status?: string
total_amount?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "payment_plans_collection_id_fkey"
columns: ["collection_id"]
isOneToOne: false
referencedRelation: "collections"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
created_at: string
+4 -1
View File
@@ -8,8 +8,9 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact } from "lucide-react";
import { ArrowLeft, FileText, Clock, DollarSign, Activity, Receipt, Scale, Users, Contact, Phone } from "lucide-react";
import { ContactsLinkTab } from "@/components/contacts/contacts-link-tab";
import { CaseCallLogsTab } from "@/components/cases/call-logs-tab";
import { formatCurrency, formatDate, statusBadgeClass } from "@/lib/format";
import { CaseDocumentsTab } from "@/components/cases/documents-tab";
import { CaseTimeTab } from "@/components/cases/time-tab";
@@ -162,6 +163,7 @@ function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string
<TabsTrigger value="time"><Clock className="h-3.5 w-3.5 mr-1.5" />Time</TabsTrigger>
<TabsTrigger value="expenses"><DollarSign className="h-3.5 w-3.5 mr-1.5" />Expenses</TabsTrigger>
<TabsTrigger value="invoices"><Receipt className="h-3.5 w-3.5 mr-1.5" />Invoices</TabsTrigger>
<TabsTrigger value="calls"><Phone className="h-3.5 w-3.5 mr-1.5" />Calls</TabsTrigger>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsTrigger value="collections"><Users className="h-3.5 w-3.5 mr-1.5" />Collections</TabsTrigger>
)}
@@ -173,6 +175,7 @@ function CaseTabs({ data, caseId, canManage, load }: { data: any; caseId: string
<TabsContent value="time"><CaseTimeTab caseRecord={data} onInvoice={() => setTab("invoices")} /></TabsContent>
<TabsContent value="expenses"><CaseExpensesTab caseId={caseId} /></TabsContent>
<TabsContent value="invoices"><CaseInvoicesTab caseRecord={data} /></TabsContent>
<TabsContent value="calls"><CaseCallLogsTab caseId={caseId} /></TabsContent>
{(data.client?.client_type === "hoa" || data.client?.client_type === "condo") && (
<TabsContent value="collections"><CaseCollectionsTab caseRecord={data} /></TabsContent>
)}
+4
View File
@@ -26,6 +26,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { CollectionDetail } from "@/components/cases/collections-tab";
import { PaymentPlansPanel } from "@/components/collections/payment-plans-panel";
import { formatDate } from "@/lib/format";
import {
ArrowLeft,
@@ -428,6 +429,9 @@ function CollectionDetailRoute() {
</CardContent>
</Card>
{/* Payment plans */}
<PaymentPlansPanel collectionId={collection.id} />
{/* Ledger */}
<CollectionDetail
collection={collection}
@@ -0,0 +1,112 @@
-- Payment Plans (attached to a collection)
CREATE TABLE public.payment_plans (
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
collection_id uuid NOT NULL REFERENCES public.collections(id) ON DELETE CASCADE,
name text,
status text NOT NULL DEFAULT 'active', -- active, completed, defaulted, cancelled
total_amount numeric NOT NULL DEFAULT 0,
down_payment numeric NOT NULL DEFAULT 0,
installment_count integer NOT NULL DEFAULT 1,
installment_amount numeric NOT NULL DEFAULT 0,
frequency text NOT NULL DEFAULT 'monthly', -- weekly, biweekly, monthly
start_date date NOT NULL DEFAULT CURRENT_DATE,
notes text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_payment_plans_collection ON public.payment_plans(collection_id);
ALTER TABLE public.payment_plans ENABLE ROW LEVEL SECURITY;
CREATE POLICY pp_select ON public.payment_plans FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY pp_insert ON public.payment_plans FOR INSERT TO authenticated
WITH CHECK (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY pp_update ON public.payment_plans FOR UPDATE TO authenticated
USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY pp_delete ON public.payment_plans FOR DELETE TO authenticated
USING (EXISTS (SELECT 1 FROM public.collections c WHERE c.id = collection_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE TRIGGER trg_pp_updated BEFORE UPDATE ON public.payment_plans
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Installments (schedule items; manually marked paid)
CREATE TABLE public.payment_plan_installments (
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
plan_id uuid NOT NULL REFERENCES public.payment_plans(id) ON DELETE CASCADE,
sort_order integer NOT NULL DEFAULT 0,
due_date date NOT NULL,
amount numeric NOT NULL DEFAULT 0,
paid boolean NOT NULL DEFAULT false,
paid_on date,
paid_amount numeric,
ledger_entry_id uuid REFERENCES public.collection_ledger_entries(id) ON DELETE SET NULL,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_ppi_plan ON public.payment_plan_installments(plan_id);
ALTER TABLE public.payment_plan_installments ENABLE ROW LEVEL SECURITY;
CREATE POLICY ppi_select ON public.payment_plan_installments FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM public.payment_plans p JOIN public.collections c ON c.id = p.collection_id WHERE p.id = plan_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY ppi_insert ON public.payment_plan_installments FOR INSERT TO authenticated
WITH CHECK (EXISTS (SELECT 1 FROM public.payment_plans p JOIN public.collections c ON c.id = p.collection_id WHERE p.id = plan_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY ppi_update ON public.payment_plan_installments FOR UPDATE TO authenticated
USING (EXISTS (SELECT 1 FROM public.payment_plans p JOIN public.collections c ON c.id = p.collection_id WHERE p.id = plan_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE POLICY ppi_delete ON public.payment_plan_installments FOR DELETE TO authenticated
USING (EXISTS (SELECT 1 FROM public.payment_plans p JOIN public.collections c ON c.id = p.collection_id WHERE p.id = plan_id AND public.can_access_case(c.case_id, auth.uid())));
CREATE TRIGGER trg_ppi_updated BEFORE UPDATE ON public.payment_plan_installments
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Call Logs (attached to a case; optionally to a contact/homeowner)
CREATE TABLE public.call_logs (
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
case_id uuid NOT NULL REFERENCES public.cases(id) ON DELETE CASCADE,
contact_id uuid REFERENCES public.contacts(id) ON DELETE SET NULL,
homeowner_id uuid REFERENCES public.homeowners(id) ON DELETE SET NULL,
call_date timestamptz NOT NULL DEFAULT now(),
direction text NOT NULL DEFAULT 'outbound', -- inbound, outbound
duration_minutes integer,
caller_name text,
caller_phone text,
subject text NOT NULL DEFAULT '',
notes text,
follow_up_required boolean NOT NULL DEFAULT false,
follow_up_date date,
billable boolean NOT NULL DEFAULT false,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_call_logs_case ON public.call_logs(case_id);
CREATE INDEX idx_call_logs_date ON public.call_logs(call_date DESC);
ALTER TABLE public.call_logs ENABLE ROW LEVEL SECURITY;
CREATE POLICY cl_select ON public.call_logs FOR SELECT TO authenticated
USING (public.can_access_case(case_id, auth.uid()));
CREATE POLICY cl_insert ON public.call_logs FOR INSERT TO authenticated
WITH CHECK (public.can_access_case(case_id, auth.uid()));
CREATE POLICY cl_update ON public.call_logs FOR UPDATE TO authenticated
USING (public.can_access_case(case_id, auth.uid()) AND (created_by = auth.uid() OR public.is_admin(auth.uid())));
CREATE POLICY cl_delete ON public.call_logs FOR DELETE TO authenticated
USING (public.can_access_case(case_id, auth.uid()) AND (created_by = auth.uid() OR public.is_admin(auth.uid())));
CREATE TRIGGER trg_call_logs_updated BEFORE UPDATE ON public.call_logs
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();