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.
  • )} 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.
    • )}