From 33cb737483a5784ddaa7304124a80ba2e1a5a6c9 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 01:18:35 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 717 +++++++++++++++++++++++++++++++++ 1 file changed, 717 insertions(+) create mode 100644 src/routes/settings.import.tsx diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx new file mode 100644 index 0000000..666a49b --- /dev/null +++ b/src/routes/settings.import.tsx @@ -0,0 +1,717 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import Papa from "papaparse"; +import { supabase } from "@/integrations/supabase/client"; +import { useAuth } from "@/lib/auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Upload, CheckCircle2, AlertCircle, Loader2, FileSpreadsheet } from "lucide-react"; +import { toast } from "sonner"; + +export const Route = createFileRoute("/settings/import")({ + component: ImportPage, +}); + +type Row = Record; + +interface ImporterConfig { + key: string; + label: string; + description: string; + table: string; + conflict: string; // column for upsert + // Maps normalized csv header → db column + aliases: Record; + // Numeric columns (parsed from string) + numeric?: string[]; + // Date-only columns (YYYY-MM-DD) + dateCols?: string[]; + // Datetime columns + datetimeCols?: string[]; + // Boolean columns + boolCols?: string[]; + // Required output columns; if missing in row, row is skipped + required: string[]; + // Per-row transform after mapping (e.g. split full name, set defaults) + transform?: (row: Row, ctx: ImportCtx) => Row | null; + // After successful insert, hook for caching ids by external_id (e.g. clients) + postInsert?: (rows: Row[], ctx: ImportCtx) => void; +} + +interface ImportCtx { + userId: string; + // Cached lookup tables: external_id → uuid + clientByExt: Map; + caseByExt: Map; + contactByExt: Map; + homeownerByExt: Map; + invoiceByExt: Map; + // Name → uuid lookups (for files that reference by name only) + clientByName: Map; + caseByNumber: Map; +} + +const norm = (s: string) => + s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +const splitName = (full: string): { first_name: string; last_name: string } => { + const parts = (full ?? "").trim().split(/\s+/); + if (parts.length === 0) return { first_name: "", last_name: "" }; + if (parts.length === 1) return { first_name: parts[0], last_name: "" }; + return { first_name: parts[0], last_name: parts.slice(1).join(" ") }; +}; + +const toBool = (v: any) => { + if (typeof v === "boolean") return v; + const s = String(v ?? "").trim().toLowerCase(); + return s === "true" || s === "yes" || s === "1" || s === "y"; +}; + +const toNum = (v: any) => { + if (v === "" || v == null) return null; + const n = Number(String(v).replace(/[$,]/g, "")); + return isNaN(n) ? null : n; +}; + +const toDate = (v: any): string | null => { + if (!v) return null; + const d = new Date(v); + if (isNaN(d.getTime())) return null; + return d.toISOString().slice(0, 10); +}; + +const toDateTime = (v: any): string | null => { + if (!v) return null; + const d = new Date(v); + return isNaN(d.getTime()) ? null : d.toISOString(); +}; + +const IMPORTERS: ImporterConfig[] = [ + // Companies → clients + { + 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", + }, + 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.).", + table: "contacts", + conflict: "external_id", + required: ["name"], + aliases: { + id: "external_id", personid: "external_id", externalid: "external_id", + name: "name", fullname: "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", + zip: "postal_code", zipcode: "postal_code", postalcode: "postal_code", + type: "contact_type", contacttype: "contact_type", + 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; + if (!r.contact_type) 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; + }, + }, + // Locations → homeowners (units) + { + key: "locations", + label: "Locations → Homeowner addresses", + description: "Property addresses tied to a client (HOA unit list).", + table: "homeowners", + conflict: "external_id", + required: ["client_id", "first_name"], + aliases: { + id: "external_id", locationid: "external_id", externalid: "external_id", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", + address: "address", street: "address", addressline1: "address", + unit: "unit_number", unitnumber: "unit_number", + ownername: "_ownername", owner: "_ownername", + firstname: "first_name", lastname: "last_name", + email: "email", phone: "phone", + notes: "notes", + }, + transform: (r, ctx) => { + const cid = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null; + const cidByName = r._clientname ? ctx.clientByName.get(String(r._clientname).toLowerCase()) : null; + r.client_id = cid ?? cidByName ?? null; + delete r._clientext; delete r._clientname; + if (!r.client_id) return null; + if (!r.first_name && r._ownername) { + const { first_name, last_name } = splitName(r._ownername); + r.first_name = first_name; r.last_name = last_name; + } + delete r._ownername; + if (!r.last_name) r.last_name = ""; + return r; + }, + }, + // Cases → cases + { + key: "cases", + label: "Cases → Cases", + description: "Matters / cases linked to a client.", + table: "cases", + conflict: "external_id", + required: ["case_number", "title", "client_id"], + aliases: { + id: "external_id", caseid: "external_id", externalid: "external_id", + casenumber: "case_number", number: "case_number", mattercode: "case_number", + name: "title", title: "title", mattername: "title", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", client: "_clientname", + description: "description", summary: "case_summary", casesummary: "case_summary", + practicearea: "practice_area", area: "practice_area", + status: "status", + opened: "opened_at", openedat: "opened_at", dateopened: "opened_at", + closed: "closed_at", closedat: "closed_at", dateclosed: "closed_at", + court: "court", judge: "judge", jurisdiction: "jurisdiction", + caseCaption: "case_caption", caption: "case_caption", + courtcasenumber: "court_case_number", + filingdate: "filing_date", + nexthearing: "next_hearing_date", nexthearingdate: "next_hearing_date", + opposingparty: "opposing_party", + opposingcounsel: "opposing_counsel", + opposingcounselfirm: "opposing_counsel_firm", + opposingcounselemail: "opposing_counsel_email", + opposingcounselphone: "opposing_counsel_phone", + claimamount: "claim_amount", settlementamount: "settlement_amount", + hourlyrate: "default_hourly_rate", rate: "default_hourly_rate", + }, + numeric: ["claim_amount", "settlement_amount", "default_hourly_rate"], + dateCols: ["opened_at", "closed_at", "filing_date", "next_hearing_date", "statute_of_limitations"], + transform: (r, ctx) => { + const cid = r._clientext ? ctx.clientByExt.get(String(r._clientext)) : null; + const cidByName = r._clientname ? ctx.clientByName.get(String(r._clientname).toLowerCase()) : null; + r.client_id = cid ?? cidByName ?? null; + delete r._clientext; delete r._clientname; + if (!r.client_id) return null; + const s = String(r.status ?? "").toLowerCase(); + r.status = s.includes("close") ? "closed" : s.includes("hold") ? "on_hold" : s.includes("intake") ? "intake" : "active"; + if (!r.case_number) r.case_number = `CASE-${Date.now()}-${Math.floor(Math.random() * 1000)}`; + if (!r.title) r.title = r.case_number; + return r; + }, + postInsert: (rows, ctx) => { + for (const r of rows) { + if (r.external_id && r.id) ctx.caseByExt.set(r.external_id, r.id); + if (r.case_number && r.id) ctx.caseByNumber.set(r.case_number, r.id); + } + }, + }, + // Case stages → not stored (informational only). Skipped. + // Tasks + { + key: "tasks", + label: "Tasks → Tasks", + description: "Task list with case linkage.", + table: "tasks", + conflict: "external_id", + required: ["title"], + aliases: { + id: "external_id", taskid: "external_id", externalid: "external_id", + title: "title", name: "title", subject: "title", + description: "description", notes: "description", + duedate: "due_date", due: "due_date", + priority: "priority", status: "status", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + dateCols: ["due_date"], + transform: (r, ctx) => { + const cid = r._caseext ? ctx.caseByExt.get(String(r._caseext)) : null; + const cidByNum = r._casenum ? ctx.caseByNumber.get(String(r._casenum)) : null; + r.case_id = cid ?? cidByNum ?? null; + delete r._caseext; delete r._casenum; + const s = String(r.status ?? "").toLowerCase(); + r.status = s.includes("complete") || s.includes("done") ? "complete" : "incomplete"; + const p = String(r.priority ?? "").toLowerCase(); + r.priority = ["low", "normal", "high", "urgent"].includes(p) ? p : "normal"; + return r; + }, + }, + // Events + { + key: "events", + label: "Events → Calendar", + description: "Calendar events (hearings, meetings, deadlines).", + table: "events", + conflict: "external_id", + required: ["title", "start_at"], + aliases: { + id: "external_id", eventid: "external_id", externalid: "external_id", + title: "title", name: "title", subject: "title", + description: "description", notes: "description", + location: "location", + start: "start_at", startat: "start_at", startdate: "start_at", startdatetime: "start_at", date: "start_at", + end: "end_at", endat: "end_at", enddate: "end_at", enddatetime: "end_at", + allday: "all_day", + type: "event_type", eventtype: "event_type", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + companyid: "_clientext", clientid: "_clientext", + }, + boolCols: ["all_day"], + datetimeCols: ["start_at", "end_at"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + r.client_id = (r._clientext && ctx.clientByExt.get(String(r._clientext))) || null; + delete r._caseext; delete r._casenum; delete r._clientext; + if (!r.event_type) r.event_type = "general"; + return r; + }, + }, + // Call logs + { + key: "call_logs", + label: "Calls → Call logs", + description: "Phone call records.", + table: "call_logs", + conflict: "external_id", + required: ["case_id"], + aliases: { + id: "external_id", callid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + direction: "direction", subject: "subject", topic: "subject", + notes: "notes", description: "notes", + callername: "caller_name", from: "caller_name", + callerphone: "caller_phone", phone: "caller_phone", + duration: "duration_minutes", durationminutes: "duration_minutes", minutes: "duration_minutes", + billable: "billable", + date: "call_date", calldate: "call_date", datetime: "call_date", + }, + numeric: ["duration_minutes"], + boolCols: ["billable"], + datetimeCols: ["call_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id) return null; + const d = String(r.direction ?? "").toLowerCase(); + r.direction = d.includes("in") ? "inbound" : "outbound"; + return r; + }, + }, + // Time entries + { + key: "time_entries", + label: "Time entries → Time", + description: "Billable time records.", + table: "time_entries", + conflict: "external_id", + required: ["case_id", "description", "hours"], + aliases: { + id: "external_id", timeid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + description: "description", notes: "description", + hours: "hours", duration: "hours", + rate: "hourly_rate", hourlyrate: "hourly_rate", + billable: "billable", + date: "work_date", workdate: "work_date", + }, + numeric: ["hours", "hourly_rate"], + boolCols: ["billable"], + dateCols: ["work_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id) return null; + r.user_id = ctx.userId; + if (r.hours == null) return null; + return r; + }, + }, + // Expenses + { + key: "expenses", + label: "Expenses → Expenses", + description: "Case-related expenses.", + table: "expenses", + conflict: "external_id", + required: ["case_id", "description", "amount"], + aliases: { + id: "external_id", expenseid: "external_id", externalid: "external_id", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + description: "description", notes: "description", + amount: "amount", cost: "amount", + billable: "billable", + date: "expense_date", expensedate: "expense_date", + }, + numeric: ["amount"], + boolCols: ["billable"], + dateCols: ["expense_date"], + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || r.amount == null) return null; + r.user_id = ctx.userId; + return r; + }, + }, + // Invoices + { + key: "invoices", + label: "Invoices → Invoices", + description: "Invoice headers (line items not imported).", + table: "invoices", + conflict: "external_id", + required: ["invoice_number", "client_id"], + aliases: { + id: "external_id", invoiceid: "external_id", externalid: "external_id", + number: "invoice_number", invoicenumber: "invoice_number", + companyid: "_clientext", clientid: "_clientext", company: "_clientname", + caseid: "_caseext", casenumber: "_casenum", + issuedate: "issue_date", date: "issue_date", + duedate: "due_date", + status: "status", + subtotal: "subtotal", tax: "tax", total: "total", amount: "total", + amountpaid: "amount_paid", paid: "amount_paid", + notes: "notes", memo: "notes", + }, + numeric: ["subtotal", "tax", "total", "amount_paid"], + dateCols: ["issue_date", "due_date"], + transform: (r, ctx) => { + r.client_id = (r._clientext && ctx.clientByExt.get(String(r._clientext))) || (r._clientname && ctx.clientByName.get(String(r._clientname).toLowerCase())) || null; + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._clientext; delete r._clientname; delete r._caseext; delete r._casenum; + if (!r.client_id) return null; + const s = String(r.status ?? "").toLowerCase(); + r.status = ["draft", "sent", "paid", "overdue", "void"].includes(s) ? s : "draft"; + return r; + }, + }, + // Notes → comments (general entity) + { + key: "notes", + label: "Notes → Comments", + description: "Free-form notes attached to cases.", + table: "comments", + conflict: "external_id", + required: ["body", "case_id"], + aliases: { + id: "external_id", noteid: "external_id", externalid: "external_id", + body: "body", content: "body", text: "body", notes: "body", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || !r.body) return null; + r.author_id = ctx.userId; + r.entity_type = "case"; + r.entity_id = r.case_id; + return r; + }, + }, + // Status updates + { + key: "status_updates", + label: "Status updates → Status", + description: "Case status reports.", + table: "status_updates", + conflict: "external_id", + required: ["title", "body", "case_id"], + aliases: { + id: "external_id", statusid: "external_id", externalid: "external_id", + title: "title", subject: "title", + body: "body", content: "body", notes: "body", description: "body", + caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum", + }, + transform: (r, ctx) => { + r.case_id = (r._caseext && ctx.caseByExt.get(String(r._caseext))) || (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || null; + delete r._caseext; delete r._casenum; + if (!r.case_id || !r.body) return null; + return r; + }, + }, +]; + +interface ImportResult { + total: number; + inserted: number; + skipped: number; + errors: string[]; +} + +function ImportPage() { + const { user } = useAuth(); + const [results, setResults] = useState>({}); + const ctx = useMemo(() => ({ + userId: user?.id ?? "", + clientByExt: new Map(), + caseByExt: new Map(), + contactByExt: new Map(), + homeownerByExt: new Map(), + invoiceByExt: new Map(), + clientByName: new Map(), + caseByNumber: new Map(), + }), [user?.id]); + + // Pre-load lookup caches from existing rows so subsequent imports can join + const ensureLookupsLoaded = async () => { + if (ctx.clientByExt.size === 0) { + const { data } = await supabase.from("clients").select("id, name, external_id"); + for (const r of data ?? []) { + if (r.external_id) ctx.clientByExt.set(r.external_id, r.id); + if (r.name) ctx.clientByName.set(r.name.toLowerCase(), r.id); + } + } + if (ctx.caseByExt.size === 0) { + const { data } = await supabase.from("cases").select("id, case_number, external_id"); + for (const r of data ?? []) { + if (r.external_id) ctx.caseByExt.set(r.external_id, r.id); + if (r.case_number) ctx.caseByNumber.set(r.case_number, r.id); + } + } + }; + + const handleFile = async (cfg: ImporterConfig, file: File) => { + if (!user?.id) return; + setResults((r) => ({ ...r, [cfg.key]: "running" })); + await ensureLookupsLoaded(); + + const parsed = await new Promise>>((resolve, reject) => { + Papa.parse>(file, { + header: true, + skipEmptyLines: true, + complete: resolve, + error: reject, + }); + }); + + const rawRows = parsed.data; + const headers = parsed.meta.fields ?? []; + // Build header → db column map + const headerMap: Record = {}; + for (const h of headers) { + const n = norm(h); + const target = cfg.aliases[n] ?? n; + headerMap[h] = target; + } + + const mapped: Row[] = []; + let skipped = 0; + const errors: string[] = []; + + for (const raw of rawRows) { + const row: Row = {}; + for (const [h, target] of Object.entries(headerMap)) { + const v = raw[h]; + if (v === undefined || v === "") continue; + row[target] = v; + } + // type coercions + for (const c of cfg.numeric ?? []) if (c in row) row[c] = toNum(row[c]); + for (const c of cfg.boolCols ?? []) if (c in row) row[c] = toBool(row[c]); + for (const c of cfg.dateCols ?? []) if (c in row) row[c] = toDate(row[c]); + for (const c of cfg.datetimeCols ?? []) if (c in row) row[c] = toDateTime(row[c]); + + // transform + const transformed = cfg.transform ? cfg.transform(row, ctx) : row; + if (!transformed) { skipped++; continue; } + + // required check + const missing = cfg.required.find((k) => !transformed[k] && transformed[k] !== 0); + if (missing) { skipped++; continue; } + + // attach created_by where not present + if (!transformed.created_by && !["time_entries", "expenses", "comments"].includes(cfg.table)) { + transformed.created_by = user.id; + } + mapped.push(transformed); + } + + if (mapped.length === 0) { + setResults((r) => ({ ...r, [cfg.key]: { total: rawRows.length, inserted: 0, skipped, errors: ["No valid rows after mapping. Check headers/required fields."] } })); + return; + } + + // Batch upsert in chunks of 200 + const chunkSize = 200; + let inserted = 0; + const inserted_rows: Row[] = []; + for (let i = 0; i < mapped.length; i += chunkSize) { + const chunk = mapped.slice(i, i + chunkSize); + // Filter out rows missing the conflict column for upsert; insert plain + const withConflict = chunk.filter((r) => r[cfg.conflict]); + const withoutConflict = chunk.filter((r) => !r[cfg.conflict]); + + if (withConflict.length > 0) { + const { data, error } = await supabase + .from(cfg.table as any) + .upsert(withConflict as any, { onConflict: cfg.conflict, ignoreDuplicates: false }) + .select("*"); + if (error) { + errors.push(`Batch ${i / chunkSize + 1} (upsert): ${error.message}`); + } else if (data) { + inserted += data.length; + inserted_rows.push(...data); + } + } + if (withoutConflict.length > 0) { + const { data, error } = await supabase + .from(cfg.table as any) + .insert(withoutConflict as any) + .select("*"); + if (error) { + errors.push(`Batch ${i / chunkSize + 1} (insert): ${error.message}`); + } else if (data) { + inserted += data.length; + inserted_rows.push(...data); + } + } + } + + if (cfg.postInsert) cfg.postInsert(inserted_rows, ctx); + + const summary = { total: rawRows.length, inserted, skipped, errors }; + 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)`); + }; + + return ( +
+ + + + 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. + + + +
+ {IMPORTERS.map((cfg) => { + const result = results[cfg.key]; + const running = result === "running"; + const r = typeof result === "object" ? result : null; + return ( + + +
+
+ + + {cfg.label} + +

{cfg.description}

+
+
+ { + const f = e.target.files?.[0]; + if (f) handleFile(cfg, f); + e.target.value = ""; + }} + disabled={running} + /> + +
+
+
+ {r && ( + +
+ {r.errors.length === 0 ? ( + + + {r.inserted} of {r.total} imported + + ) : ( + + + {r.inserted} imported, {r.errors.length} error(s) + + )} + {r.skipped > 0 && ( + {r.skipped} skipped (missing required fields) + )} +
+ {r.errors.length > 0 && ( +
+ {r.errors.slice(0, 3).map((e, i) =>
• {e}
)} +
+ )} +
+ )} +
+ ); + })} +
+
+ ); +}