Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user