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:10 +00:00
co-authored by renee-png
parent e8aff8c90a
commit 5b0c8d8ea9
@@ -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>
)}