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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user