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:
gpt-engineer-app[bot]
2026-04-19 23:37:43 +00:00
co-authored by renee-png
+123 -18
View File
@@ -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