diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 00d1060..f756e1a 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -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; // 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>({}); const [clientList, setClientList] = useState<{ id: string; name: string }[]>([]); const [defaultClientId, setDefaultClientId] = useState(""); + const [contactGroup, setContactGroup] = useState("client"); const ctx = useMemo(() => ({ 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>>((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() { Headers are auto-mapped (snake_case + common aliases). Existing rows with a matching{" "} - external_id are updated. Import in order: - companies → people/lawyers → locations → cases → tasks/events/calls/time/expenses/invoices/notes/status. + external_id are updated. Pick a contact group per + upload — rows tagged Client are also mirrored into the Clients page. @@ -767,6 +758,18 @@ function ImportPage() {

{cfg.description}

+ {cfg.key === "contacts" && ( + + )} {cfg.key === "locations" && (