Added case-linked homeowners
X-Lovable-Edit-ID: edt-3814092d-ccd1-4817-89d0-e280083ea574 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -140,6 +140,7 @@ export function CaseCollectionsTab({
|
||||
|
||||
const [collections, setCollections] = useState<any[]>([]);
|
||||
const [homeowners, setHomeowners] = useState<any[]>([]);
|
||||
const [contactHomeowners, setContactHomeowners] = useState<any[]>([]);
|
||||
const [balances, setBalances] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
@@ -151,7 +152,7 @@ export function CaseCollectionsTab({
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const [{ data: cols }, { data: hos }] = await Promise.all([
|
||||
const [{ data: cols }, { data: hos }, { data: ccs }] = await Promise.all([
|
||||
supabase
|
||||
.from("collections")
|
||||
.select("*, homeowner:homeowners(*)")
|
||||
@@ -162,10 +163,23 @@ export function CaseCollectionsTab({
|
||||
.select("*")
|
||||
.eq("client_id", clientId)
|
||||
.order("last_name"),
|
||||
supabase
|
||||
.from("case_contacts")
|
||||
.select("role, contact:contacts(*)")
|
||||
.eq("case_id", caseId),
|
||||
]);
|
||||
const list = cols ?? [];
|
||||
setCollections(list);
|
||||
setHomeowners(hos ?? []);
|
||||
// Filter: case_contacts.role = 'homeowner' OR contact.contact_type = 'homeowner'
|
||||
const hoContacts = (ccs ?? [])
|
||||
.filter((cc: any) => {
|
||||
const roleMatch = (cc.role || "").toLowerCase() === "homeowner";
|
||||
const typeMatch = (cc.contact?.contact_type || "").toLowerCase() === "homeowner";
|
||||
return cc.contact && (roleMatch || typeMatch);
|
||||
})
|
||||
.map((cc: any) => ({ ...cc.contact, _link_role: cc.role }));
|
||||
setContactHomeowners(hoContacts);
|
||||
|
||||
// pull balances per collection
|
||||
if (list.length) {
|
||||
@@ -343,6 +357,7 @@ export function CaseCollectionsTab({
|
||||
clientId={clientId}
|
||||
caseTitle={caseRecord.title || ""}
|
||||
homeowners={homeowners}
|
||||
contactHomeowners={contactHomeowners}
|
||||
existingHomeownerIds={collections.map((c) => c.homeowner_id)}
|
||||
onCreated={load}
|
||||
/>
|
||||
@@ -1072,6 +1087,7 @@ function AddCollectionDialog({
|
||||
clientId,
|
||||
caseTitle,
|
||||
homeowners,
|
||||
contactHomeowners,
|
||||
existingHomeownerIds,
|
||||
onCreated,
|
||||
}: {
|
||||
@@ -1081,11 +1097,13 @@ function AddCollectionDialog({
|
||||
clientId: string;
|
||||
caseTitle: string;
|
||||
homeowners: any[];
|
||||
contactHomeowners: any[];
|
||||
existingHomeownerIds: string[];
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [homeownerId, setHomeownerId] = useState("");
|
||||
// Picker value uses prefix: "ho:<id>" for homeowner row, "ct:<id>" for contact
|
||||
const [pickerValue, setPickerValue] = useState("");
|
||||
const [status, setStatus] = useState("none");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -1094,11 +1112,41 @@ function AddCollectionDialog({
|
||||
(h) => !existingHomeownerIds.includes(h.id),
|
||||
);
|
||||
|
||||
// Build combined options. Skip contacts that already have a matching homeowner row
|
||||
// attached as a collection (best-effort dedupe by name+unit).
|
||||
const homeownerKeySet = new Set(
|
||||
homeowners
|
||||
.filter((h) => existingHomeownerIds.includes(h.id))
|
||||
.map((h) =>
|
||||
`${(h.first_name || "").toLowerCase().trim()}|${(h.last_name || "").toLowerCase().trim()}|${(h.unit_number || "").toLowerCase().trim()}`,
|
||||
),
|
||||
);
|
||||
|
||||
const splitName = (full: 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)! : "" };
|
||||
};
|
||||
|
||||
const contactOptions = (contactHomeowners ?? [])
|
||||
.map((c: any) => {
|
||||
const { first, last } = splitName(c.name);
|
||||
return { contact: c, first, last };
|
||||
})
|
||||
.filter(({ first, last }) => {
|
||||
const key = `${first.toLowerCase()}|${last.toLowerCase()}|`;
|
||||
return !homeownerKeySet.has(key);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Try to pre-select a homeowner whose surname matches the case title
|
||||
const match = findUniqueHomeownerByTitle(caseTitle, available);
|
||||
setHomeownerId(match ? match.id : "");
|
||||
setPickerValue(match ? `ho:${match.id}` : "");
|
||||
setStatus("none");
|
||||
setNotes("");
|
||||
}
|
||||
@@ -1106,11 +1154,56 @@ function AddCollectionDialog({
|
||||
}, [open, caseTitle]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!homeownerId) {
|
||||
if (!pickerValue) {
|
||||
toast.error("Pick a homeowner");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
let homeownerId = "";
|
||||
|
||||
if (pickerValue.startsWith("ho:")) {
|
||||
homeownerId = pickerValue.slice(3);
|
||||
} else if (pickerValue.startsWith("ct:")) {
|
||||
// Find or create a homeowner row for this contact
|
||||
const contactId = pickerValue.slice(3);
|
||||
const contact = contactHomeowners.find((c: any) => c.id === contactId);
|
||||
if (!contact) {
|
||||
setSubmitting(false);
|
||||
toast.error("Contact not found");
|
||||
return;
|
||||
}
|
||||
const { first, last } = splitName(contact.name);
|
||||
// Try match an existing homeowner on this client by name
|
||||
const existing = homeowners.find(
|
||||
(h) =>
|
||||
(h.first_name || "").toLowerCase().trim() === first.toLowerCase() &&
|
||||
(h.last_name || "").toLowerCase().trim() === last.toLowerCase(),
|
||||
);
|
||||
if (existing) {
|
||||
homeownerId = existing.id;
|
||||
} else {
|
||||
const { data: newHo, error: hoErr } = await supabase
|
||||
.from("homeowners")
|
||||
.insert({
|
||||
client_id: clientId,
|
||||
first_name: first || contact.name || "Unknown",
|
||||
last_name: last || "",
|
||||
email: contact.email || null,
|
||||
phone: contact.phone || null,
|
||||
address: contact.address_line1 || null,
|
||||
created_by: user?.id,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (hoErr || !newHo) {
|
||||
setSubmitting(false);
|
||||
toast.error("Could not create homeowner", { description: hoErr?.message });
|
||||
return;
|
||||
}
|
||||
homeownerId = newHo.id;
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase.from("collections").insert({
|
||||
case_id: caseId,
|
||||
homeowner_id: homeownerId,
|
||||
@@ -1128,36 +1221,48 @@ function AddCollectionDialog({
|
||||
onCreated();
|
||||
};
|
||||
|
||||
const hoOptions = available.map((h) => ({
|
||||
value: `ho:${h.id}`,
|
||||
label: `${h.last_name}, ${h.first_name}${h.unit_number ? ` — Unit ${h.unit_number}` : ""}`,
|
||||
keywords: `${h.first_name} ${h.last_name} ${h.unit_number ?? ""}`,
|
||||
group: "HOA homeowners",
|
||||
}));
|
||||
const ctOptions = contactOptions.map(({ contact, first, last }) => ({
|
||||
value: `ct:${contact.id}`,
|
||||
label: `${last || contact.name}${first ? `, ${first}` : ""} — Case contact`,
|
||||
keywords: `${first} ${last} ${contact.name} ${contact.email ?? ""}`,
|
||||
group: "Case contacts (homeowner)",
|
||||
}));
|
||||
const allOptions = [...hoOptions, ...ctOptions];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-serif">Add homeowner collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Attach a homeowner from this HOA to this matter. Each homeowner can
|
||||
only have one collection per case.
|
||||
Attach a homeowner to this matter. You can pick from this HOA's
|
||||
homeowners or any case contact tagged as a homeowner. Each
|
||||
homeowner can only have one collection per case.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Homeowner</Label>
|
||||
{available.length === 0 ? (
|
||||
{allOptions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic mt-1">
|
||||
{homeowners.length === 0
|
||||
? "No homeowners yet on this HOA. Use 'New homeowner' first."
|
||||
: "All homeowners on this HOA already have a collection on this matter."}
|
||||
{homeowners.length === 0 && contactHomeowners.length === 0
|
||||
? "No homeowners yet. Use 'New homeowner' or link a homeowner contact to this case."
|
||||
: "All available homeowners already have a collection on this matter."}
|
||||
</p>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
value={homeownerId}
|
||||
onValueChange={setHomeownerId}
|
||||
value={pickerValue}
|
||||
onValueChange={setPickerValue}
|
||||
placeholder="Pick a homeowner…"
|
||||
searchPlaceholder="Search homeowners…"
|
||||
emptyText="No homeowners found."
|
||||
options={available.map((h) => {
|
||||
const label = `${h.last_name}, ${h.first_name}${h.unit_number ? ` — Unit ${h.unit_number}` : ""}`;
|
||||
return { value: h.id, label, keywords: `${h.first_name} ${h.last_name} ${h.unit_number ?? ""}` };
|
||||
})}
|
||||
options={allOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1191,7 +1296,7 @@ function AddCollectionDialog({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitting || !homeownerId}
|
||||
disabled={submitting || !pickerValue}
|
||||
>
|
||||
{submitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Add
|
||||
|
||||
Reference in New Issue
Block a user