Unified Contacts importer UI

X-Lovable-Edit-ID: edt-db30a20f-5072-4dbb-b680-99c8b863ca03
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 04:03:04 +00:00
co-authored by renee-png
+82 -79
View File
@@ -10,6 +10,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Upload, CheckCircle2, AlertCircle, Loader2, FileSpreadsheet } from "lucide-react";
import { toast } from "sonner";
import { CONTACT_TYPES } from "@/components/contacts/contact-form-dialog";
export const Route = createFileRoute("/settings/import")({
component: ImportPage,
@@ -54,6 +55,8 @@ interface ImportCtx {
caseByNumber: Map<string, string>;
// Optional fallback client id (e.g. for address-only locations CSV)
defaultClientId?: string | null;
// Selected contact group (for unified contacts importer)
defaultContactType?: string | null;
}
const norm = (s: string) =>
@@ -92,97 +95,43 @@ const toDateTime = (v: any): string | null => {
};
const IMPORTERS: ImporterConfig[] = [
// Companies → clients
// Unified Contacts importer (all groups). When group=client, also mirrors into clients table.
{
key: "companies",
label: "Companies → Clients",
description: "HOAs and corporate clients.",
table: "clients",
conflict: "external_id",
required: ["name"],
aliases: {
id: "external_id", companyid: "external_id", externalid: "external_id",
name: "name", companyname: "name", company: "name",
type: "client_type", clienttype: "client_type",
addressline1: "address_line1", address1: "address_line1", address: "address_line1",
addressline2: "address_line2", address2: "address_line2",
city: "city", state: "state",
zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code",
phone: "primary_contact_phone", primaryphone: "primary_contact_phone",
email: "primary_contact_email", primaryemail: "primary_contact_email",
contactname: "primary_contact_name", primarycontact: "primary_contact_name",
management: "management_company", managementcompany: "management_company",
units: "num_units", numunits: "num_units",
notes: "notes",
archived: "_archived", isarchived: "_archived", status: "_archived",
},
numeric: ["num_units", "annual_interest_rate"],
transform: (r) => {
const t = String(r.client_type ?? "").toLowerCase();
r.client_type = t.includes("condo") ? "condo" : t.includes("hoa") ? "hoa" : "hoa";
return r;
},
postInsert: (rows, ctx) => {
for (const r of rows) {
if (r.external_id && r.id) ctx.clientByExt.set(r.external_id, r.id);
if (r.name && r.id) ctx.clientByName.set(r.name.toLowerCase(), r.id);
}
},
},
// People → contacts
{
key: "people",
label: "People → Contacts",
description: "Individual contacts (board members, vendors, etc.).",
key: "contacts",
label: "Contacts → Contacts",
description: "Upload one CSV per contact group. Rows tagged 'Client' also get mirrored into the Clients table.",
table: "contacts",
conflict: "external_id",
required: ["name"],
aliases: {
id: "external_id", personid: "external_id", externalid: "external_id",
name: "name", fullname: "name",
id: "external_id", contactid: "external_id", personid: "external_id", companyid: "external_id", externalid: "external_id",
name: "name", fullname: "name", companyname: "name",
firstname: "_first", lastname: "_last",
email: "email", phone: "phone",
company: "company", title: "title",
addressline1: "address_line1", address1: "address_line1", address: "address_line1",
addressline2: "address_line2", address2: "address_line2",
city: "city", state: "state",
email: "email", emailaddress: "email",
phone: "phone", phonenumber: "phone", mobile: "phone",
company: "company", firm: "company", organization: "company",
title: "title", role: "title",
addressline1: "address_line1", address1: "address_line1", address: "address_line1", street: "address_line1",
addressline2: "address_line2", address2: "address_line2", suite: "address_line2", apt: "address_line2",
city: "city", state: "state", province: "state",
zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code",
type: "contact_type", contacttype: "contact_type",
notes: "notes",
notes: "notes", comments: "notes",
archived: "_archived", isarchived: "_archived",
},
transform: (r) => {
transform: (r, ctx) => {
if (!r.name && (r._first || r._last)) {
r.name = [r._first, r._last].filter(Boolean).join(" ").trim();
}
delete r._first; delete r._last;
if (!r.contact_type) r.contact_type = "other";
// Force the group selected by the user (overrides any per-row contact_type column)
r.contact_type = ctx.defaultContactType || r.contact_type || "other";
return r;
},
},
// Lawyers → contacts (type=attorney)
{
key: "lawyers",
label: "Lawyers → Contacts",
description: "Opposing counsel & external attorneys (added as contacts).",
table: "contacts",
conflict: "external_id",
required: ["name"],
aliases: {
id: "external_id", lawyerid: "external_id", externalid: "external_id",
name: "name", fullname: "name",
firstname: "_first", lastname: "_last",
email: "email", phone: "phone",
firm: "company", company: "company",
addressline1: "address_line1", address: "address_line1",
city: "city", state: "state", zip: "postal_code", postalcode: "postal_code",
notes: "notes",
},
transform: (r) => {
if (!r.name && (r._first || r._last)) r.name = [r._first, r._last].filter(Boolean).join(" ").trim();
delete r._first; delete r._last;
r.contact_type = "attorney";
return r;
postInsert: (rows, ctx) => {
for (const r of rows) {
if (r.external_id && r.id) ctx.contactByExt.set(r.external_id, r.id);
}
},
},
// Locations → homeowners (units)
@@ -518,6 +467,7 @@ function ImportPage() {
const [results, setResults] = useState<Record<string, ImportResult | "running">>({});
const [clientList, setClientList] = useState<{ id: string; name: string }[]>([]);
const [defaultClientId, setDefaultClientId] = useState<string>("");
const [contactGroup, setContactGroup] = useState<string>("client");
const ctx = useMemo<ImportCtx>(() => ({
userId: user?.id ?? "",
clientByExt: new Map(),
@@ -528,6 +478,7 @@ function ImportPage() {
clientByName: new Map(),
caseByNumber: new Map(),
defaultClientId: null,
defaultContactType: null,
}), [user?.id]);
useEffect(() => {
@@ -589,6 +540,7 @@ function ImportPage() {
if (!user?.id) return;
setResults((r) => ({ ...r, [cfg.key]: "running" }));
ctx.defaultClientId = cfg.key === "locations" ? (defaultClientId || null) : null;
ctx.defaultContactType = cfg.key === "contacts" ? (contactGroup || "other") : null;
await ensureLookupsLoaded();
const parsed = await new Promise<Papa.ParseResult<Record<string, string>>>((resolve, reject) => {
@@ -733,10 +685,49 @@ function ImportPage() {
if (cfg.postInsert) cfg.postInsert(inserted_rows, ctx);
// Mirror imported "client" contacts into the clients table so they appear on the Clients page.
let mirrored = 0;
if (cfg.key === "contacts" && contactGroup === "client" && inserted_rows.length > 0) {
const clientRows = inserted_rows
.filter((c) => c.external_id || c.name)
.map((c) => ({
external_id: c.external_id ?? null,
name: c.name || c.company || "Unnamed client",
client_type: "hoa" as const,
primary_contact_email: c.email ?? null,
primary_contact_phone: c.phone ?? null,
primary_contact_name: c.name ?? null,
address_line1: c.address_line1 ?? null,
address_line2: c.address_line2 ?? null,
city: c.city ?? null,
state: c.state ?? null,
postal_code: c.postal_code ?? null,
notes: c.notes ?? null,
created_by: user.id,
}));
const withExt = clientRows.filter((r) => r.external_id);
const withoutExt = clientRows.filter((r) => !r.external_id);
if (withExt.length > 0) {
const { data, error } = await supabase
.from("clients")
.upsert(withExt, { onConflict: "external_id", ignoreDuplicates: false })
.select("id");
if (error) errors.push(`Client mirror (upsert): ${error.message}`);
else mirrored += data?.length ?? 0;
}
if (withoutExt.length > 0) {
const { data, error } = await supabase.from("clients").insert(withoutExt).select("id");
if (error) errors.push(`Client mirror (insert): ${error.message}`);
else mirrored += data?.length ?? 0;
}
}
const summary = { total: rawRows.length, inserted, skipped, errors, skipReasons };
setResults((r) => ({ ...r, [cfg.key]: summary }));
if (errors.length === 0) toast.success(`${cfg.label}: ${inserted} imported`);
else toast.error(`${cfg.label}: ${errors.length} batch error(s)`);
if (errors.length === 0) {
const extra = mirrored > 0 ? ` (+${mirrored} mirrored to Clients)` : "";
toast.success(`${cfg.label}: ${inserted} imported${extra}`);
} else toast.error(`${cfg.label}: ${errors.length} batch error(s)`);
};
return (
@@ -745,8 +736,8 @@ function ImportPage() {
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Headers are auto-mapped (snake_case + common aliases). Existing rows with a matching{" "}
<code className="text-xs">external_id</code> are updated. <strong>Import in order</strong>:
companies → people/lawyers → locations → cases → tasks/events/calls/time/expenses/invoices/notes/status.
<code className="text-xs">external_id</code> are updated. Pick a <strong>contact group</strong> per
upload — rows tagged <strong>Client</strong> are also mirrored into the Clients page.
</AlertDescription>
</Alert>
@@ -767,6 +758,18 @@ function ImportPage() {
<p className="text-xs text-muted-foreground mt-1">{cfg.description}</p>
</div>
<div className="shrink-0 flex items-center gap-2">
{cfg.key === "contacts" && (
<Select value={contactGroup} onValueChange={setContactGroup}>
<SelectTrigger className="h-8 w-[200px] text-xs">
<SelectValue placeholder="Contact group…" />
</SelectTrigger>
<SelectContent>
{CONTACT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
)}
{cfg.key === "locations" && (
<Select value={defaultClientId} onValueChange={setDefaultClientId}>
<SelectTrigger className="h-8 w-[220px] text-xs">