From e8aff8c90ac57cc3318e8406fc1f1ebbfacccc4a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:02:05 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- .../cases/convert-to-collections-dialog.tsx | 295 ++++++++++++------ 1 file changed, 192 insertions(+), 103 deletions(-) diff --git a/src/components/cases/convert-to-collections-dialog.tsx b/src/components/cases/convert-to-collections-dialog.tsx index a992b8a..4ddfdd8 100644 --- a/src/components/cases/convert-to-collections-dialog.tsx +++ b/src/components/cases/convert-to-collections-dialog.tsx @@ -18,6 +18,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; @@ -35,11 +36,31 @@ const STATUS_OPTIONS = [ { value: "closed", label: "Closed" }, ]; -function extractDefendantSurname(title: string): string | null { - if (!title) return null; - const m = title.match(/\sv\.?s?\.?\s+([A-Za-z'’\-]+)/i); - if (!m) return null; - return m[1].trim().toLowerCase(); +const ALLOWED_LABELS = new Set(["homeowner", "tenant"]); + +function splitName(full: string): { first: string; last: string } { + const s = (full || "").trim(); + if (!s) return { first: "", last: "" }; + if (s.includes(",")) { + const [last, first] = s.split(",", 2).map((x) => x.trim()); + return { first: first || "", last: last || "" }; + } + const parts = s.split(/\s+/); + return { + first: parts.slice(0, -1).join(" ") || parts[0], + last: parts.length > 1 ? parts.at(-1)! : "", + }; +} + +interface ContactRow { + id: string; + name: string; + email: string | null; + phone: string | null; + address_line1: string | null; + contact_type: string | null; + link_role: string | null; + effective_role: "homeowner" | "tenant"; } export function ConvertToCollectionsDialog({ @@ -47,7 +68,7 @@ export function ConvertToCollectionsDialog({ onOpenChange, caseId, clientId, - caseTitle, + caseTitle: _caseTitle, onCreated, }: { open: boolean; @@ -58,8 +79,7 @@ export function ConvertToCollectionsDialog({ onCreated?: () => void; }) { const { user } = useAuth(); - const [homeowners, setHomeowners] = useState([]); - const [existingIds, setExistingIds] = useState>(new Set()); + const [contacts, setContacts] = useState([]); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [selected, setSelected] = useState>(new Set()); @@ -77,54 +97,50 @@ export function ConvertToCollectionsDialog({ setEmptyName(""); (async () => { setLoading(true); - const [{ data: hos }, { data: cols }] = await Promise.all([ - supabase - .from("homeowners") - .select("id, first_name, last_name, unit_number, address, opening_balance") - .eq("client_id", clientId) - .is("archived_at", null) - .order("last_name"), - supabase - .from("collections") - .select("homeowner_id") - .eq("case_id", caseId), - ]); - const hoList = hos ?? []; - setHomeowners(hoList); - const existing = new Set( - (cols ?? []).map((c) => c.homeowner_id).filter((id): id is string => !!id), - ); - setExistingIds(existing); - - // Pre-select homeowner if there's exactly one surname match from the case title - const surname = extractDefendantSurname(caseTitle ?? ""); - if (surname) { - const matches = hoList.filter( - (h) => - (h.last_name || "").toLowerCase() === surname && !existing.has(h.id), - ); - if (matches.length === 1) { - setSelected(new Set([matches[0].id])); - } + const { data: ccs, error } = await supabase + .from("case_contacts") + .select("role, contact:contacts(id, name, email, phone, address_line1, contact_type)") + .eq("case_id", caseId); + if (error) { + toast.error(error.message); + setContacts([]); + setLoading(false); + return; } + const rows: ContactRow[] = []; + for (const cc of (ccs ?? []) as any[]) { + if (!cc.contact) continue; + const role = (cc.role || "").toLowerCase(); + const type = (cc.contact.contact_type || "").toLowerCase(); + const isAllowed = ALLOWED_LABELS.has(role) || ALLOWED_LABELS.has(type); + if (!isAllowed) continue; + const effective = (ALLOWED_LABELS.has(role) ? role : type) as "homeowner" | "tenant"; + rows.push({ + id: cc.contact.id, + name: cc.contact.name, + email: cc.contact.email, + phone: cc.contact.phone, + address_line1: cc.contact.address_line1, + contact_type: cc.contact.contact_type, + link_role: cc.role ?? null, + effective_role: effective, + }); + } + // Dedupe by contact id + const map = new Map(); + for (const r of rows) map.set(r.id, r); + setContacts(Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name))); setLoading(false); })(); - }, [open, caseId, clientId, caseTitle]); - - const available = useMemo( - () => homeowners.filter((h) => !existingIds.has(h.id)), - [homeowners, existingIds], - ); + }, [open, caseId]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); - if (!q) return available; - return available.filter((h) => - `${h.last_name} ${h.first_name} ${h.unit_number ?? ""} ${h.address ?? ""}` - .toLowerCase() - .includes(q), + if (!q) return contacts; + return contacts.filter((c) => + `${c.name} ${c.email ?? ""} ${c.address_line1 ?? ""}`.toLowerCase().includes(q), ); - }, [available, search]); + }, [contacts, search]); const toggle = (id: string) => { setSelected((prev) => { @@ -136,16 +152,16 @@ export function ConvertToCollectionsDialog({ }; const toggleAll = () => { - if (filtered.every((h) => selected.has(h.id))) { + if (filtered.every((c) => selected.has(c.id))) { setSelected((prev) => { const next = new Set(prev); - filtered.forEach((h) => next.delete(h.id)); + filtered.forEach((c) => next.delete(c.id)); return next; }); } else { setSelected((prev) => { const next = new Set(prev); - filtered.forEach((h) => next.add(h.id)); + filtered.forEach((c) => next.add(c.id)); return next; }); } @@ -153,40 +169,115 @@ export function ConvertToCollectionsDialog({ const submit = async () => { if (selected.size === 0 && !includeEmpty) { - toast.error("Pick at least one homeowner or enable an empty collection"); + toast.error("Pick at least one contact or enable an empty collection"); return; } setSubmitting(true); - const rows: any[] = Array.from(selected).map((homeowner_id) => ({ - case_id: caseId, - homeowner_id, - status, - created_by: user?.id, - })); - if (includeEmpty) { - rows.push({ - case_id: caseId, - homeowner_id: null, - status, - name: emptyName.trim() || null, - created_by: user?.id, - }); + try { + // Resolve each selected contact to a homeowner row (find by name on this client, else create). + const selectedContacts = contacts.filter((c) => selected.has(c.id)); + + let existingHomeowners: any[] = []; + if (clientId && selectedContacts.length > 0) { + const { data: hos } = await supabase + .from("homeowners") + .select("id, first_name, last_name") + .eq("client_id", clientId); + existingHomeowners = hos ?? []; + } + + const homeownerIds: string[] = []; + for (const c of selectedContacts) { + const { first, last } = splitName(c.name); + const match = existingHomeowners.find( + (h) => + (h.first_name || "").toLowerCase().trim() === first.toLowerCase() && + (h.last_name || "").toLowerCase().trim() === last.toLowerCase(), + ); + if (match) { + homeownerIds.push(match.id); + continue; + } + if (!clientId) { + toast.error("Case has no client; cannot create homeowner record"); + setSubmitting(false); + return; + } + const { data: newHo, error: hoErr } = await supabase + .from("homeowners") + .insert({ + client_id: clientId, + first_name: first || c.name || "Unknown", + last_name: last || "", + email: c.email || null, + phone: c.phone || null, + address: c.address_line1 || null, + created_by: user?.id, + }) + .select("id") + .single(); + if (hoErr || !newHo) { + toast.error("Could not create homeowner", { description: hoErr?.message }); + setSubmitting(false); + return; + } + homeownerIds.push(newHo.id); + } + + // Skip homeowners that already have a collection on this case + const { data: existingCols } = await supabase + .from("collections") + .select("homeowner_id") + .eq("case_id", caseId); + const existingSet = new Set( + (existingCols ?? []) + .map((r: any) => r.homeowner_id) + .filter((id: string | null): id is string => !!id), + ); + + const rows: any[] = homeownerIds + .filter((id) => !existingSet.has(id)) + .map((homeowner_id) => ({ + case_id: caseId, + homeowner_id, + status, + created_by: user?.id, + })); + + if (includeEmpty) { + rows.push({ + case_id: caseId, + homeowner_id: null, + status, + name: emptyName.trim() || null, + created_by: user?.id, + }); + } + + if (rows.length === 0) { + toast.info("Selected contacts already have collections on this case"); + setSubmitting(false); + return; + } + + const { error } = await supabase.from("collections").insert(rows); + if (error) { + toast.error("Could not create collections", { description: error.message }); + setSubmitting(false); + return; + } + toast.success(`Created ${rows.length} collection${rows.length === 1 ? "" : "s"}`); + onOpenChange(false); + onCreated?.(); + } catch (e: any) { + toast.error(e?.message ?? "Could not create collections"); + } finally { + setSubmitting(false); } - const { error } = await supabase.from("collections").insert(rows); - setSubmitting(false); - if (error) { - toast.error("Could not create collections", { description: error.message }); - return; - } - toast.success( - `Created ${rows.length} collection${rows.length === 1 ? "" : "s"}`, - ); - onOpenChange(false); - onCreated?.(); }; - const allChecked = filtered.length > 0 && filtered.every((h) => selected.has(h.id)); - const someChecked = filtered.some((h) => selected.has(h.id)); + const allChecked = filtered.length > 0 && filtered.every((c) => selected.has(c.id)); + const someChecked = filtered.some((c) => selected.has(c.id)); return ( @@ -196,7 +287,7 @@ export function ConvertToCollectionsDialog({ Convert case to collections - Select homeowners from this client to create one collection per homeowner under this case. + Select case contacts tagged as homeowner or tenant to create a collection ledger for each. @@ -220,7 +311,7 @@ export function ConvertToCollectionsDialog({
- {selected.size} of {available.length} available + {selected.size} of {contacts.length} eligible
@@ -228,7 +319,7 @@ export function ConvertToCollectionsDialog({
setSearch(e.target.value)} className="pl-8" @@ -238,11 +329,10 @@ export function ConvertToCollectionsDialog({
{loading ? (

Loading…

- ) : available.length === 0 ? ( + ) : contacts.length === 0 ? (

- {homeowners.length === 0 - ? "No homeowners on this client yet — add some on the client page first." - : "All homeowners on this client already have a collection on this case."} + No case contacts tagged as homeowner or tenant. Open the + Contacts tab on this case to link one and set its case role.

) : ( <> @@ -256,35 +346,34 @@ export function ConvertToCollectionsDialog({
    - {filtered.map((h) => ( + {filtered.map((c) => (
  • toggle(h.id)} + onClick={() => toggle(c.id)} > toggle(h.id)} + checked={selected.has(c.id)} + onCheckedChange={() => toggle(c.id)} onClick={(e) => e.stopPropagation()} /> -
    -
    - {h.last_name}, {h.first_name} +
    +
    + {c.name} + + {c.effective_role} +
    - {(h.unit_number || h.address) && ( -
    - {h.unit_number ? `Unit ${h.unit_number}` : ""} - {h.unit_number && h.address ? " · " : ""} - {h.address ?? ""} + {(c.email || c.address_line1) && ( +
    + {[c.email, c.address_line1].filter(Boolean).join(" · ")}
    )}
  • ))} {filtered.length === 0 && ( -
  • - No matches. -
  • +
  • No matches.
  • )}