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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user