diff --git a/src/components/cases/bulk-convert-to-collections-dialog.tsx b/src/components/cases/bulk-convert-to-collections-dialog.tsx index 6fce9bd..f1a89d6 100644 --- a/src/components/cases/bulk-convert-to-collections-dialog.tsx +++ b/src/components/cases/bulk-convert-to-collections-dialog.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/lib/auth"; import { toast } from "sonner"; @@ -31,25 +32,47 @@ interface Props { onCreated?: () => void; } +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 { + case_id: string; + contact_id: string; + name: string; + email: string | null; + phone: string | null; + address_line1: string | null; + effective_role: "homeowner" | "tenant"; +} + /** - * Bulk variant of ConvertToCollectionsDialog: opens the homeowner picker once - * and applies the same selection to every selected case. Only cases that - * share the dominant client are eligible — the rest are skipped (and listed). - * - * Default initial status is "none"; existing collections on a case are - * preserved and not duplicated for already-attached homeowners. + * Bulk variant: opens a contact picker scoped to case_contacts (homeowner/tenant) + * across the selected cases that share the dominant client. A row is keyed by + * `${case_id}::${contact_id}` so each link is independent. */ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCreated }: Props) { const { user } = useAuth(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); - const [homeowners, setHomeowners] = useState([]); + const [contactRows, setContactRows] = useState([]); const [search, setSearch] = useState(""); const [selected, setSelected] = useState>(new Set()); const [includeEmpty, setIncludeEmpty] = useState(false); const [emptyName, setEmptyName] = useState(""); - // Determine the dominant client across selected cases. const { eligibleCases, skippedCases, clientId } = useMemo(() => { const counts = new Map(); for (const c of cases) { @@ -72,38 +95,63 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr setSearch(""); setIncludeEmpty(false); setEmptyName(""); - if (!clientId) { setHomeowners([]); return; } + if (eligibleCases.length === 0) { + setContactRows([]); + return; + } (async () => { setLoading(true); - const { data: hos, error } = await supabase - .from("homeowners") - .select("id, first_name, last_name, unit_number, address") - .eq("client_id", clientId) - .is("archived_at", null) - .order("last_name"); - if (error) toast.error(error.message); - setHomeowners(hos ?? []); + const caseIds = eligibleCases.map((c) => c.id); + const { data: ccs, error } = await supabase + .from("case_contacts") + .select("case_id, role, contact:contacts(id, name, email, phone, address_line1, contact_type)") + .in("case_id", caseIds); + if (error) { + toast.error(error.message); + setContactRows([]); + 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({ + case_id: cc.case_id, + contact_id: cc.contact.id, + name: cc.contact.name, + email: cc.contact.email, + phone: cc.contact.phone, + address_line1: cc.contact.address_line1, + effective_role: effective, + }); + } + setContactRows(rows.sort((a, b) => a.name.localeCompare(b.name))); setLoading(false); })(); - }, [open, clientId]); + }, [open, eligibleCases]); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); - if (!q) return homeowners; - return homeowners.filter((h) => - `${h.last_name ?? ""} ${h.first_name ?? ""} ${h.unit_number ?? ""} ${h.address ?? ""}` - .toLowerCase() - .includes(q), + if (!q) return contactRows; + return contactRows.filter((r) => + `${r.name} ${r.email ?? ""} ${r.address_line1 ?? ""}`.toLowerCase().includes(q), ); - }, [homeowners, search]); + }, [contactRows, search]); - const allChecked = filtered.length > 0 && filtered.every((h) => selected.has(h.id)); - const someChecked = filtered.some((h) => selected.has(h.id)); + const keyOf = (r: ContactRow) => `${r.case_id}::${r.contact_id}`; - const toggle = (id: string) => { + const allChecked = filtered.length > 0 && filtered.every((r) => selected.has(keyOf(r))); + const someChecked = filtered.some((r) => selected.has(keyOf(r))); + + const toggle = (key: string) => { setSelected((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); else next.add(id); + if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; @@ -112,13 +160,13 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr if (allChecked) { setSelected((prev) => { const next = new Set(prev); - filtered.forEach((h) => next.delete(h.id)); + filtered.forEach((r) => next.delete(keyOf(r))); return next; }); } else { setSelected((prev) => { const next = new Set(prev); - filtered.forEach((h) => next.add(h.id)); + filtered.forEach((r) => next.add(keyOf(r))); return next; }); } @@ -130,36 +178,87 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr return; } 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); try { - // Preload existing (case_id, homeowner_id) pairs to skip duplicates. const caseIds = eligibleCases.map((c) => c.id); - const { data: existing, error: exErr } = await supabase + + // Existing collections to skip duplicates on (case_id, homeowner_id) + const { data: existing } = await supabase .from("collections") .select("case_id, homeowner_id") .in("case_id", caseIds); - if (exErr) throw exErr; const existingPairs = new Set( (existing ?? []) .filter((row: any) => !!row.homeowner_id) .map((row: any) => `${row.case_id}::${row.homeowner_id}`), ); - const rows: any[] = []; - for (const c of eligibleCases) { - for (const homeownerId of selected) { - if (existingPairs.has(`${c.id}::${homeownerId}`)) continue; - rows.push({ - case_id: c.id, - homeowner_id: homeownerId, - status: "none", - created_by: user?.id, - }); + // Preload existing homeowners on this client to dedupe by name + const { data: hos } = clientId + ? await supabase + .from("homeowners") + .select("id, first_name, last_name") + .eq("client_id", clientId) + : { data: [] as any[] }; + const homeownerByKey = new Map(); + for (const h of (hos ?? []) as any[]) { + const k = `${(h.first_name || "").toLowerCase().trim()}|${(h.last_name || "").toLowerCase().trim()}`; + homeownerByKey.set(k, h.id); + } + + // Resolve each selected (case, contact) -> homeowner_id (find or create per client) + const selectedRows = contactRows.filter((r) => selected.has(keyOf(r))); + const contactToHomeowner = new Map(); + for (const r of selectedRows) { + if (contactToHomeowner.has(r.contact_id)) continue; + const { first, last } = splitName(r.name); + const key = `${first.toLowerCase()}|${last.toLowerCase()}`; + const existingId = homeownerByKey.get(key); + if (existingId) { + contactToHomeowner.set(r.contact_id, existingId); + continue; } - if (includeEmpty) { + if (!clientId) continue; + const { data: newHo, error: hoErr } = await supabase + .from("homeowners") + .insert({ + client_id: clientId, + first_name: first || r.name || "Unknown", + last_name: last || "", + email: r.email || null, + phone: r.phone || null, + address: r.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; + } + contactToHomeowner.set(r.contact_id, newHo.id); + homeownerByKey.set(key, newHo.id); + } + + const rows: any[] = []; + for (const r of selectedRows) { + const homeownerId = contactToHomeowner.get(r.contact_id); + if (!homeownerId) continue; + if (existingPairs.has(`${r.case_id}::${homeownerId}`)) continue; + rows.push({ + case_id: r.case_id, + homeowner_id: homeownerId, + status: "none", + created_by: user?.id, + }); + } + + if (includeEmpty) { + for (const c of eligibleCases) { rows.push({ case_id: c.id, homeowner_id: null, @@ -171,7 +270,7 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr } if (rows.length === 0) { - toast.info("Every selected homeowner already has a collection on these cases"); + toast.info("Selected contacts already have collections on these cases"); setSubmitting(false); return; } @@ -193,7 +292,7 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr const dominantClientName = eligibleCases[0]?.client?.name ?? (clientId ? "shared client" : "—"); - const projectedCount = eligibleCases.length * (selected.size + (includeEmpty ? 1 : 0)); + const projectedCount = selected.size + (includeEmpty ? eligibleCases.length : 0); return ( @@ -203,8 +302,8 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr Designate cases as collections - Pick homeowners once and create matching collections under every selected case that - shares the same client. Initial status defaults to "None"; duplicates are skipped. + Select case contacts tagged as homeowner or tenant. One collection + ledger is created per (case, contact) pair. Duplicates are skipped. @@ -230,14 +329,14 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr {!clientId ? (
- None of the selected cases have a client, so no homeowners can be loaded. + None of the selected cases have a client.
) : ( <>
setSearch(e.target.value)} className="pl-8" @@ -247,9 +346,10 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
{loading ? (

Loading…

- ) : homeowners.length === 0 ? ( + ) : contactRows.length === 0 ? (

- No homeowners on this client yet — add some on the client page first. + No contacts tagged as homeowner or tenant on the selected + cases. Open a case's Contacts tab to link one.

) : ( <> @@ -266,31 +366,42 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
    - {filtered.map((h) => ( -
  • toggle(h.id)} - > - toggle(h.id)} - onClick={(e) => e.stopPropagation()} - /> -
    -
    - {h.last_name}, {h.first_name} -
    - {(h.unit_number || h.address) && ( -
    - {h.unit_number ? `Unit ${h.unit_number}` : ""} - {h.unit_number && h.address ? " · " : ""} - {h.address ?? ""} + {filtered.map((r) => { + const key = keyOf(r); + const caseLabel = + eligibleCases.find((c) => c.id === r.case_id)?.case_number ?? ""; + return ( +
  • toggle(key)} + > + toggle(key)} + onClick={(e) => e.stopPropagation()} + /> +
    +
    + {r.name} + + {r.effective_role} + + {caseLabel && ( + + Case {caseLabel} + + )}
    - )} -
    -
  • - ))} + {(r.email || r.address_line1) && ( +
    + {[r.email, r.address_line1].filter(Boolean).join(" · ")} +
    + )} +
+ + ); + })} {filtered.length === 0 && (
  • No matches.
  • )}