Fixed case contact linking

X-Lovable-Edit-ID: edt-c4801376-44c8-4b4f-9dd6-a9382bddc804
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:59:22 +00:00
co-authored by renee-png
+114 -10
View File
@@ -4,6 +4,13 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { toast } from "sonner";
@@ -11,7 +18,7 @@ import { Plus, Trash2, Mail, Phone, Building2, MapPin, Search, UserPlus } from "
import { ContactFormDialog, CONTACT_TYPES } from "./contact-form-dialog";
interface LinkedContact {
id: string; // join row id
id: string;
role: string | null;
contact: {
id: string;
@@ -28,6 +35,12 @@ interface LinkedContact {
};
}
const CASE_ROLE_OPTIONS = [
{ value: "other", label: "Other" },
{ value: "homeowner", label: "Homeowner" },
{ value: "tenant", label: "Tenant" },
] as const;
export function ContactsLinkTab({
parentId,
parentTable,
@@ -37,15 +50,19 @@ export function ContactsLinkTab({
}) {
const { user } = useAuth();
const fkColumn = parentTable === "case_contacts" ? "case_id" : "client_id";
const isCaseContacts = parentTable === "case_contacts";
const [linked, setLinked] = useState<LinkedContact[]>([]);
const [loading, setLoading] = useState(true);
const [pickerOpen, setPickerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [caseLinkRole, setCaseLinkRole] = useState<string>("other");
const load = async () => {
setLoading(true);
const { data, error } = await (supabase.from(parentTable) as any)
.select("id, role, contact:contacts(id, name, company, title, email, phone, contact_type, address_line1, city, state, postal_code)")
.select(
"id, role, contact:contacts(id, name, company, title, email, phone, contact_type, address_line1, city, state, postal_code)",
)
.eq(fkColumn, parentId);
if (error) toast.error(error.message);
setLinked(((data ?? []) as any[]).filter((d) => d.contact));
@@ -56,12 +73,15 @@ export function ContactsLinkTab({
load();
}, [parentId, parentTable]);
const linkContact = async (contactId: string) => {
const linkContact = async (contactId: string, roleOverride?: string | null) => {
const payload: Record<string, unknown> = {
contact_id: contactId,
created_by: user?.id,
};
payload[fkColumn] = parentId;
if (isCaseContacts) {
payload.role = roleOverride && roleOverride !== "other" ? roleOverride : null;
}
const { error } = await supabase.from(parentTable).insert(payload as any);
if (error) {
if (error.code === "23505") toast.info("Already linked");
@@ -73,6 +93,46 @@ export function ContactsLinkTab({
load();
};
const relinkCaseRole = async (row: LinkedContact, nextRole: string) => {
const normalizedRole = nextRole === "other" ? null : nextRole;
const { error: updateError } = await supabase
.from("case_contacts")
.update({ role: normalizedRole })
.eq("id", row.id);
if (!updateError) {
toast.success("Case role updated");
load();
return;
}
const { error: deleteError } = await supabase
.from("case_contacts")
.delete()
.eq("id", row.id);
if (deleteError) {
toast.error(deleteError.message);
return;
}
const { error: insertError } = await supabase.from("case_contacts").insert({
case_id: parentId,
contact_id: row.contact.id,
role: normalizedRole,
created_by: user?.id,
});
if (insertError) {
toast.error(insertError.message);
await load();
return;
}
toast.success("Case role updated");
load();
};
const unlink = async (joinId: string) => {
const { error } = await supabase.from(parentTable).delete().eq("id", joinId);
if (error) {
@@ -87,7 +147,7 @@ export function ContactsLinkTab({
return (
<Card className="border-border/60">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{parentTable === "client_contacts" ? "Additional contacts" : "Contacts"}
@@ -96,8 +156,27 @@ export function ContactsLinkTab({
{linked.length} linked contact{linked.length === 1 ? "" : "s"}
{parentTable === "client_contacts" && " — accountants, vendors, board liaisons, etc."}
</div>
{isCaseContacts && (
<div className="text-xs text-muted-foreground mt-1">
Collections uses contacts linked to this case as homeowner or tenant.
</div>
)}
</div>
<div className="flex gap-2">
<div className="flex gap-2 items-center flex-wrap">
{isCaseContacts && (
<Select value={caseLinkRole} onValueChange={setCaseLinkRole}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Link as" />
</SelectTrigger>
<SelectContent>
{CASE_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
Link as {option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<UserPlus className="h-3.5 w-3.5 mr-1.5" /> New contact
</Button>
@@ -117,12 +196,18 @@ export function ContactsLinkTab({
const c = row.contact;
const typeLabel = CONTACT_TYPES.find((t) => t.value === c.contact_type)?.label ?? c.contact_type;
const addr = [c.address_line1, c.city, c.state, c.postal_code].filter(Boolean).join(", ");
const roleValue = row.role ?? "other";
return (
<li key={row.id} className="py-3 flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{c.name}</span>
<Badge variant="outline" className="text-[10px]">{typeLabel}</Badge>
{isCaseContacts && row.role && (
<Badge variant="outline" className="text-[10px]">
Case role: {row.role}
</Badge>
)}
{c.title && <span className="text-xs text-muted-foreground">{c.title}</span>}
</div>
<div className="mt-1 grid sm:grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
@@ -132,9 +217,28 @@ export function ContactsLinkTab({
{addr && <span className="flex items-center gap-1.5"><MapPin className="h-3 w-3" />{addr}</span>}
</div>
</div>
<Button variant="ghost" size="icon" onClick={() => unlink(row.id)} title="Unlink">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
<div className="flex items-start gap-2 shrink-0">
{isCaseContacts && (
<Select
value={roleValue}
onValueChange={(value) => relinkCaseRole(row, value)}
>
<SelectTrigger className="w-[140px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CASE_ROLE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="icon" onClick={() => unlink(row.id)} title="Unlink">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</li>
);
})}
@@ -145,13 +249,13 @@ export function ContactsLinkTab({
open={pickerOpen}
onOpenChange={setPickerOpen}
excludeIds={linkedIds}
onPick={linkContact}
onPick={(contactId) => linkContact(contactId, caseLinkRole)}
/>
<ContactFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSaved={async (saved) => {
await linkContact(saved.id);
await linkContact(saved.id, caseLinkRole);
}}
/>
</CardContent>