From 09351957def9aa1c8ef3bbe97a890a76cffed39c Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:44:45 +0000 Subject: [PATCH 1/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 201 ++++++++++++++++++ ...0_788dad00-10b3-4523-b2e9-abaa72dec456.sql | 112 ++++++++++ 2 files changed, 313 insertions(+) create mode 100644 supabase/migrations/20260417024440_788dad00-10b3-4523-b2e9-abaa72dec456.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index c1c5e05..1638011 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -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 diff --git a/supabase/migrations/20260417024440_788dad00-10b3-4523-b2e9-abaa72dec456.sql b/supabase/migrations/20260417024440_788dad00-10b3-4523-b2e9-abaa72dec456.sql new file mode 100644 index 0000000..8cd2ba4 --- /dev/null +++ b/supabase/migrations/20260417024440_788dad00-10b3-4523-b2e9-abaa72dec456.sql @@ -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(); \ No newline at end of file From 38c6f72bd27f1549c9f22f3a901dafef35dde531 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 02:46:25 +0000 Subject: [PATCH 2/3] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/cases/call-logs-tab.tsx | 322 ++++++++++++++ .../collections/payment-plans-panel.tsx | 406 ++++++++++++++++++ 2 files changed, 728 insertions(+) create mode 100644 src/components/cases/call-logs-tab.tsx create mode 100644 src/components/collections/payment-plans-panel.tsx diff --git a/src/components/cases/call-logs-tab.tsx b/src/components/cases/call-logs-tab.tsx new file mode 100644 index 0000000..d4ad726 --- /dev/null +++ b/src/components/cases/call-logs-tab.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(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 ( +
+
+
+ +

Call log

+ {logs.length > 0 && {logs.length}} +
+ +
+ + {loading ? ( +

Loading…

+ ) : logs.length === 0 ? ( + + + No calls logged yet. + + + ) : ( +
+ {logs.map((c) => { + const canEdit = isAdmin || c.created_by === user?.id; + const Icon = c.direction === "inbound" ? PhoneIncoming : PhoneOutgoing; + return ( + + +
+
+ +
+
+
+ + {c.billable && Billable} + {c.follow_up_required && ( + + Follow-up{c.follow_up_date ? ` ${c.follow_up_date}` : ""} + + )} +
+
+ {formatDateTime(c.call_date)} + {c.caller_name && ` · ${c.caller_name}`} + {c.caller_phone && ` · ${c.caller_phone}`} + {c.duration_minutes != null && ` · ${c.duration_minutes} min`} +
+ {c.notes && ( +

{c.notes}

+ )} +
+ {canEdit && ( + + )} +
+
+
+ ); + })} +
+ )} + + +
+ ); +} + +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("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 ( + + + + {editing ? "Edit call log" : "Log a call"} + Track inbound and outbound calls related to this case. + +
+
+
+ + setDate(e.target.value)} /> +
+
+ + +
+
+
+ + setSubject(e.target.value)} placeholder="What was the call about?" /> +
+
+
+ + setCallerName(e.target.value)} /> +
+
+ + setCallerPhone(e.target.value)} /> +
+
+
+ + setDuration(e.target.value)} /> +
+
+ +