Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
ae13dfbd66
commit
33cb737483
@@ -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<string, any>;
|
||||
|
||||
interface ImporterConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
table: string;
|
||||
conflict: string; // column for upsert
|
||||
// Maps normalized csv header → db column
|
||||
aliases: Record<string, string>;
|
||||
// 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<string, string>;
|
||||
caseByExt: Map<string, string>;
|
||||
contactByExt: Map<string, string>;
|
||||
homeownerByExt: Map<string, string>;
|
||||
invoiceByExt: Map<string, string>;
|
||||
// Name → uuid lookups (for files that reference by name only)
|
||||
clientByName: Map<string, string>;
|
||||
caseByNumber: Map<string, string>;
|
||||
}
|
||||
|
||||
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<Record<string, ImportResult | "running">>({});
|
||||
const ctx = useMemo<ImportCtx>(() => ({
|
||||
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<Papa.ParseResult<Record<string, string>>>((resolve, reject) => {
|
||||
Papa.parse<Record<string, string>>(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<string, string> = {};
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{IMPORTERS.map((cfg) => {
|
||||
const result = results[cfg.key];
|
||||
const running = result === "running";
|
||||
const r = typeof result === "object" ? result : null;
|
||||
return (
|
||||
<Card key={cfg.key}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
|
||||
{cfg.label}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">{cfg.description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
id={`file-${cfg.key}`}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(cfg, f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
disabled={running}
|
||||
/>
|
||||
<Button asChild size="sm" variant="outline" disabled={running}>
|
||||
<label htmlFor={`file-${cfg.key}`} className="cursor-pointer">
|
||||
{running ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1.5" />}
|
||||
{running ? "Importing…" : "Upload CSV"}
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{r && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
{r.errors.length === 0 ? (
|
||||
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{r.inserted} of {r.total} imported
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-destructive/10 text-destructive border-destructive/20">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{r.inserted} imported, {r.errors.length} error(s)
|
||||
</Badge>
|
||||
)}
|
||||
{r.skipped > 0 && (
|
||||
<span className="text-muted-foreground">{r.skipped} skipped (missing required fields)</span>
|
||||
)}
|
||||
</div>
|
||||
{r.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-destructive space-y-1">
|
||||
{r.errors.slice(0, 3).map((e, i) => <div key={i}>• {e}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user