Added import_runs system
X-Lovable-Edit-ID: edt-9667efbc-7747-4f00-827f-c4e4d87e953b Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -1771,6 +1771,42 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
import_runs: {
|
||||
Row: {
|
||||
created_at: string
|
||||
error_count: number
|
||||
id: string
|
||||
importer_key: string
|
||||
inserted: number
|
||||
ran_at: string
|
||||
ran_by: string | null
|
||||
skipped: number
|
||||
total: number
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
error_count?: number
|
||||
id?: string
|
||||
importer_key: string
|
||||
inserted?: number
|
||||
ran_at?: string
|
||||
ran_by?: string | null
|
||||
skipped?: number
|
||||
total?: number
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
error_count?: number
|
||||
id?: string
|
||||
importer_key?: string
|
||||
inserted?: number
|
||||
ran_at?: string
|
||||
ran_by?: string | null
|
||||
skipped?: number
|
||||
total?: number
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
incoming_emails: {
|
||||
Row: {
|
||||
attachment_count: number
|
||||
|
||||
+112
-20
@@ -248,13 +248,16 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
duedate: "due_date", due: "due_date",
|
||||
priority: "priority", status: "status",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
},
|
||||
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;
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
const s = String(r.status ?? "").toLowerCase();
|
||||
r.status = s.includes("complete") || s.includes("done") ? "complete" : "incomplete";
|
||||
const p = String(r.priority ?? "").toLowerCase();
|
||||
@@ -280,14 +283,19 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
allday: "all_day",
|
||||
type: "event_type", eventtype: "event_type",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
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.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
r.client_id = (r._clientext && ctx.clientByExt.get(String(r._clientext))) || null;
|
||||
delete r._caseext; delete r._casenum; delete r._clientext;
|
||||
delete r._caseext; delete r._casenum; delete r._casename; delete r._clientext;
|
||||
if (!r.event_type) r.event_type = "general";
|
||||
return r;
|
||||
},
|
||||
@@ -303,6 +311,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
aliases: {
|
||||
id: "external_id", callid: "external_id", externalid: "external_id",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
direction: "direction", subject: "subject", topic: "subject",
|
||||
notes: "notes", description: "notes",
|
||||
callername: "caller_name", from: "caller_name",
|
||||
@@ -315,8 +324,12 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
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;
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
if (!r.case_id) return null;
|
||||
const d = String(r.direction ?? "").toLowerCase();
|
||||
r.direction = d.includes("in") ? "inbound" : "outbound";
|
||||
@@ -492,10 +505,15 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
id: "external_id", noteid: "external_id", externalid: "external_id",
|
||||
body: "body", content: "body", text: "body", notes: "body",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
},
|
||||
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;
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
if (!r.case_id || !r.body) return null;
|
||||
r.author_id = ctx.userId;
|
||||
r.entity_type = "case";
|
||||
@@ -516,10 +534,15 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
title: "title", subject: "title",
|
||||
body: "body", content: "body", notes: "body", description: "body",
|
||||
caseid: "_caseext", matterid: "_caseext", casenumber: "_casenum",
|
||||
casename: "_casename", casetitle: "_casename", matter: "_casename", mattername: "_casename",
|
||||
},
|
||||
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;
|
||||
r.case_id =
|
||||
(r._caseext && ctx.caseByExt.get(String(r._caseext))) ||
|
||||
(r._casenum && ctx.caseByNumber.get(String(r._casenum))) ||
|
||||
(r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) ||
|
||||
null;
|
||||
delete r._caseext; delete r._casenum; delete r._casename;
|
||||
if (!r.case_id || !r.body) return null;
|
||||
return r;
|
||||
},
|
||||
@@ -534,9 +557,18 @@ interface ImportResult {
|
||||
skipReasons?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface LastRun {
|
||||
ran_at: string;
|
||||
total: number;
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
error_count: number;
|
||||
}
|
||||
|
||||
function ImportPage() {
|
||||
const { user } = useAuth();
|
||||
const [results, setResults] = useState<Record<string, ImportResult | "running">>({});
|
||||
const [lastRuns, setLastRuns] = useState<Record<string, LastRun>>({});
|
||||
const [clientList, setClientList] = useState<{ id: string; name: string }[]>([]);
|
||||
const [defaultClientId, setDefaultClientId] = useState<string>("");
|
||||
const [contactGroup, setContactGroup] = useState<string>("client");
|
||||
@@ -565,6 +597,30 @@ function ImportPage() {
|
||||
.then(({ data }) => setClientList((data ?? []) as { id: string; name: string }[]));
|
||||
}, []);
|
||||
|
||||
// Load latest import per category
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("import_runs" as any)
|
||||
.select("importer_key, ran_at, total, inserted, skipped, error_count")
|
||||
.order("ran_at", { ascending: false });
|
||||
if (error || !data) return;
|
||||
const map: Record<string, LastRun> = {};
|
||||
for (const row of data as any[]) {
|
||||
if (!map[row.importer_key]) {
|
||||
map[row.importer_key] = {
|
||||
ran_at: row.ran_at,
|
||||
total: row.total,
|
||||
inserted: row.inserted,
|
||||
skipped: row.skipped,
|
||||
error_count: row.error_count,
|
||||
};
|
||||
}
|
||||
}
|
||||
setLastRuns(map);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Pre-load lookup caches from existing rows so subsequent imports can join.
|
||||
// Always reload (and paginate past Supabase's 1000-row default limit) so caches
|
||||
// pick up rows added since the page mounted (e.g., from a prior CSV import).
|
||||
@@ -693,6 +749,10 @@ function ImportPage() {
|
||||
// transform
|
||||
const transformed = cfg.transform ? cfg.transform(row, ctx) : row;
|
||||
if (!transformed) {
|
||||
// Tables whose rows are linked to a case via _caseext / _casenum / _casename
|
||||
const caseLinked = ["tasks", "events", "call_logs", "time_entries", "expenses", "comments", "status_updates"];
|
||||
const hasCaseHint = row._caseext != null || row._casenum != null || row._casename != null;
|
||||
|
||||
// Diagnose common transform-rejection reasons for clearer feedback
|
||||
if (cfg.table === "homeowners" && !row.client_id && !row._clientext && !row._clientname) {
|
||||
bumpSkip("no client match (missing companyid/company)");
|
||||
@@ -702,20 +762,16 @@ function ImportPage() {
|
||||
bumpSkip("client not found");
|
||||
} else if (cfg.table === "time_entries" && !row.work_date) {
|
||||
bumpSkip("missing or invalid work date");
|
||||
} else if (cfg.table === "time_entries" && !row.case_id && !row._caseext && !row._casenum && !row._casename) {
|
||||
bumpSkip("missing case reference");
|
||||
} else if (cfg.table === "time_entries" && (row._caseext || row._casenum || row._casename) && !row.case_id) {
|
||||
bumpSkip("case not found (import cases first or match case title/number)");
|
||||
} else if (cfg.table === "time_entries" && (row.hours == null || row.hours <= 0) && row._time == null) {
|
||||
bumpSkip("missing or invalid hours");
|
||||
} else if (cfg.table === "time_entries" && !row.description && !row._activity) {
|
||||
bumpSkip("missing description/activity");
|
||||
} else if (cfg.table === "expenses" && !row.case_id && !row._caseext && !row._casenum && !row._casename) {
|
||||
bumpSkip("missing case reference");
|
||||
} else if (cfg.table === "expenses" && (row._caseext || row._casenum || row._casename) && !row.case_id) {
|
||||
bumpSkip("case not found (import cases first or match case title/number)");
|
||||
} else if (cfg.table === "expenses" && row.amount == null) {
|
||||
bumpSkip("missing or invalid amount");
|
||||
} else if (caseLinked.includes(cfg.table) && !row.case_id && !hasCaseHint) {
|
||||
bumpSkip("missing case reference");
|
||||
} else if (caseLinked.includes(cfg.table) && hasCaseHint && !row.case_id) {
|
||||
bumpSkip("case not found (import cases first or match case title/number)");
|
||||
} else {
|
||||
bumpSkip("rejected by transform");
|
||||
}
|
||||
@@ -874,6 +930,33 @@ function ImportPage() {
|
||||
|
||||
const summary = { total: rawRows.length, inserted, skipped, errors, skipReasons };
|
||||
setResults((r) => ({ ...r, [cfg.key]: summary }));
|
||||
|
||||
// Persist run history (best-effort)
|
||||
const runRow = {
|
||||
importer_key: cfg.key,
|
||||
total: rawRows.length,
|
||||
inserted,
|
||||
skipped,
|
||||
error_count: errors.length,
|
||||
ran_by: user.id,
|
||||
};
|
||||
void supabase.from("import_runs" as any).insert(runRow as any).then(({ error: runErr }) => {
|
||||
if (runErr) {
|
||||
console.warn("[import] failed to record run", runErr);
|
||||
return;
|
||||
}
|
||||
setLastRuns((prev) => ({
|
||||
...prev,
|
||||
[cfg.key]: {
|
||||
ran_at: new Date().toISOString(),
|
||||
total: rawRows.length,
|
||||
inserted,
|
||||
skipped,
|
||||
error_count: errors.length,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
if (errors.length === 0) {
|
||||
const extra = mirrored > 0 ? ` (+${mirrored} mirrored to Clients)` : "";
|
||||
toast.success(`${cfg.label}: ${inserted} imported${extra}`);
|
||||
@@ -906,6 +989,15 @@ function ImportPage() {
|
||||
{cfg.label}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">{cfg.description}</p>
|
||||
{!r && lastRuns[cfg.key] && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Last import:{" "}
|
||||
{new Date(lastRuns[cfg.key].ran_at).toLocaleString()} —{" "}
|
||||
{lastRuns[cfg.key].inserted} of {lastRuns[cfg.key].total} imported
|
||||
{lastRuns[cfg.key].skipped > 0 ? `, ${lastRuns[cfg.key].skipped} skipped` : ""}
|
||||
{lastRuns[cfg.key].error_count > 0 ? `, ${lastRuns[cfg.key].error_count} error(s)` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-2">
|
||||
{cfg.key === "contacts" && (
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE public.import_runs (
|
||||
id uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
importer_key text NOT NULL,
|
||||
total integer NOT NULL DEFAULT 0,
|
||||
inserted integer NOT NULL DEFAULT 0,
|
||||
skipped integer NOT NULL DEFAULT 0,
|
||||
error_count integer NOT NULL DEFAULT 0,
|
||||
ran_by uuid,
|
||||
ran_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_import_runs_key_ran_at ON public.import_runs (importer_key, ran_at DESC);
|
||||
|
||||
ALTER TABLE public.import_runs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "import_runs_select_auth" ON public.import_runs
|
||||
FOR SELECT TO authenticated USING (true);
|
||||
|
||||
CREATE POLICY "import_runs_insert_auth" ON public.import_runs
|
||||
FOR INSERT TO authenticated WITH CHECK (auth.uid() IS NOT NULL);
|
||||
|
||||
CREATE POLICY "import_runs_delete_admin" ON public.import_runs
|
||||
FOR DELETE TO authenticated USING (public.is_admin(auth.uid()));
|
||||
Reference in New Issue
Block a user