Updated collections dialogs

X-Lovable-Edit-ID: edt-43c50a7f-ff98-4765-b17f-7b13ae946321
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-20 00:03:37 +00:00
co-authored by renee-png
2 changed files with 380 additions and 180 deletions
@@ -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<any[]>([]);
const [contactRows, setContactRows] = useState<ContactRow[]>([]);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(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<string, number>();
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<string, string>();
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<string, string>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -203,8 +302,8 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
<Users className="h-4 w-4" /> Designate cases as collections
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -230,14 +329,14 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
{!clientId ? (
<div className="rounded-md border p-4 text-sm text-muted-foreground italic">
None of the selected cases have a client, so no homeowners can be loaded.
None of the selected cases have a client.
</div>
) : (
<>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search homeowners…"
placeholder="Search contacts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -247,9 +346,10 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
<div className="border rounded-md max-h-72 overflow-y-auto">
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : homeowners.length === 0 ? (
) : contactRows.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground italic">
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.
</p>
) : (
<>
@@ -266,31 +366,42 @@ export function BulkConvertToCollectionsDialog({ open, onOpenChange, cases, onCr
</span>
</div>
<ul className="divide-y">
{filtered.map((h) => (
<li
key={h.id}
className="flex items-center gap-3 px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer"
onClick={() => toggle(h.id)}
>
<Checkbox
checked={selected.has(h.id)}
onCheckedChange={() => toggle(h.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1">
<div className="font-medium">
{h.last_name}, {h.first_name}
</div>
{(h.unit_number || h.address) && (
<div className="text-xs text-muted-foreground">
{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 (
<li
key={key}
className="flex items-center gap-3 px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer"
onClick={() => toggle(key)}
>
<Checkbox
checked={selected.has(key)}
onCheckedChange={() => toggle(key)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{r.name}</span>
<Badge variant="outline" className="text-[10px] capitalize">
{r.effective_role}
</Badge>
{caseLabel && (
<span className="text-xs text-muted-foreground">
Case {caseLabel}
</span>
)}
</div>
)}
</div>
</li>
))}
{(r.email || r.address_line1) && (
<div className="text-xs text-muted-foreground truncate">
{[r.email, r.address_line1].filter(Boolean).join(" · ")}
</div>
)}
</div>
</li>
);
})}
{filtered.length === 0 && (
<li className="p-3 text-sm text-muted-foreground italic">No matches.</li>
)}
@@ -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<any[]>([]);
const [existingIds, setExistingIds] = useState<Set<string>>(new Set());
const [contacts, setContacts] = useState<ContactRow[]>([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(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<string, ContactRow>();
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -196,7 +287,7 @@ export function ConvertToCollectionsDialog({
<Users className="h-4 w-4" /> Convert case to collections
</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -220,7 +311,7 @@ export function ConvertToCollectionsDialog({
<div>
<Label>Selected</Label>
<div className="h-9 px-3 flex items-center text-sm border rounded-md bg-muted/40">
{selected.size} of {available.length} available
{selected.size} of {contacts.length} eligible
</div>
</div>
</div>
@@ -228,7 +319,7 @@ export function ConvertToCollectionsDialog({
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search homeowners…"
placeholder="Search contacts…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -238,11 +329,10 @@ export function ConvertToCollectionsDialog({
<div className="border rounded-md max-h-80 overflow-y-auto">
{loading ? (
<p className="p-4 text-sm text-muted-foreground">Loading…</p>
) : available.length === 0 ? (
) : contacts.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground italic">
{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.
</p>
) : (
<>
@@ -256,35 +346,34 @@ export function ConvertToCollectionsDialog({
</span>
</div>
<ul className="divide-y">
{filtered.map((h) => (
{filtered.map((c) => (
<li
key={h.id}
key={c.id}
className="flex items-center gap-3 px-3 py-2 text-sm hover:bg-muted/50 cursor-pointer"
onClick={() => toggle(h.id)}
onClick={() => toggle(c.id)}
>
<Checkbox
checked={selected.has(h.id)}
onCheckedChange={() => toggle(h.id)}
checked={selected.has(c.id)}
onCheckedChange={() => toggle(c.id)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1">
<div className="font-medium">
{h.last_name}, {h.first_name}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{c.name}</span>
<Badge variant="outline" className="text-[10px] capitalize">
{c.effective_role}
</Badge>
</div>
{(h.unit_number || h.address) && (
<div className="text-xs text-muted-foreground">
{h.unit_number ? `Unit ${h.unit_number}` : ""}
{h.unit_number && h.address ? " · " : ""}
{h.address ?? ""}
{(c.email || c.address_line1) && (
<div className="text-xs text-muted-foreground truncate">
{[c.email, c.address_line1].filter(Boolean).join(" · ")}
</div>
)}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="p-3 text-sm text-muted-foreground italic">
No matches.
</li>
<li className="p-3 text-sm text-muted-foreground italic">No matches.</li>
)}
</ul>
</>