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:
gpt-engineer-app[bot]
2026-04-19 01:11:36 +00:00
co-authored by renee-png
3 changed files with 172 additions and 20 deletions
+36
View File
@@ -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
View File
@@ -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()));