Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
30475881bf
commit
e8aff8c90a
@@ -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