Added archived client/case flow
X-Lovable-Edit-ID: edt-341a08df-6c8c-46cf-8a7b-341ab42798dd Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -552,7 +552,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
{
|
||||
key: "invoices",
|
||||
label: "Invoices → Invoices",
|
||||
description: "Invoice headers (line items not imported).",
|
||||
description: "Invoice headers (line items not imported). If the client isn't recognized, an archived client is auto-created. If the case isn't recognized, an archived placeholder case is auto-created so the reference is preserved.",
|
||||
table: "invoices",
|
||||
conflict: "external_id",
|
||||
required: ["invoice_number", "client_id"],
|
||||
@@ -573,8 +573,8 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
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;
|
||||
// Keep hints — async pre-insert step will auto-create an archived client
|
||||
// (and archived case) when no match is found.
|
||||
const s = String(r.status ?? "").toLowerCase();
|
||||
r.status = ["draft", "sent", "paid", "overdue", "void"].includes(s) ? s : "draft";
|
||||
return r;
|
||||
@@ -890,13 +890,19 @@ function ImportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// required check (defer case_id for time/expense — async step may create one)
|
||||
// required check (defer case_id for time/expense — async step may create one;
|
||||
// defer client_id for invoices — async step may auto-create an archived client)
|
||||
const deferCaseId =
|
||||
(cfg.table === "time_entries" || cfg.table === "expenses") &&
|
||||
!transformed.case_id &&
|
||||
(transformed._caseext || transformed._casenum || transformed._casename);
|
||||
const deferClientId =
|
||||
cfg.table === "invoices" &&
|
||||
!transformed.client_id &&
|
||||
(transformed._clientext || transformed._clientname);
|
||||
const missing = cfg.required.find((k) => {
|
||||
if (k === "case_id" && deferCaseId) return false;
|
||||
if (k === "client_id" && deferClientId) return false;
|
||||
return !transformed[k] && transformed[k] !== 0;
|
||||
});
|
||||
if (missing) { bumpSkip(`missing required: ${missing}`); continue; }
|
||||
@@ -1079,6 +1085,101 @@ function ImportPage() {
|
||||
mapped.push(...resolved);
|
||||
}
|
||||
|
||||
// Async pre-insert for invoices: if the row references an unknown client,
|
||||
// auto-create an archived client from the row hints. If it also references
|
||||
// an unknown case, auto-create an archived case under that client so the
|
||||
// case is recorded (but clearly archived) rather than silently dropped.
|
||||
if (cfg.table === "invoices") {
|
||||
const ensureArchivedClient = async (name: string, extId?: string | null): Promise<string | null> => {
|
||||
const key = name.toLowerCase().trim();
|
||||
const existing = (extId && ctx.clientByExt.get(String(extId))) || ctx.clientByName.get(key);
|
||||
if (existing) return existing;
|
||||
const { data, error } = await supabase
|
||||
.from("clients")
|
||||
.insert({
|
||||
name,
|
||||
external_id: extId || null,
|
||||
client_type: "hoa",
|
||||
archived_at: new Date().toISOString(),
|
||||
notes: "Auto-created from invoice import (no matching client).",
|
||||
created_by: user.id,
|
||||
} as any)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) {
|
||||
errors.push(`Auto-create client "${name}": ${error?.message ?? "unknown"}`);
|
||||
return null;
|
||||
}
|
||||
ctx.clientByName.set(key, data.id);
|
||||
if (extId) ctx.clientByExt.set(String(extId), data.id);
|
||||
return data.id;
|
||||
};
|
||||
const ensureArchivedCase = async (
|
||||
clientId: string,
|
||||
title: string,
|
||||
caseNumber: string,
|
||||
extId?: string | null,
|
||||
): Promise<string | null> => {
|
||||
const existing =
|
||||
(extId && ctx.caseByExt.get(String(extId))) ||
|
||||
ctx.caseByNumber.get(caseNumber) ||
|
||||
ctx.caseByTitle.get(title.toLowerCase().trim());
|
||||
if (existing) return existing;
|
||||
const { data, error } = await supabase
|
||||
.from("cases")
|
||||
.insert({
|
||||
client_id: clientId,
|
||||
case_number: caseNumber,
|
||||
title,
|
||||
external_id: extId || null,
|
||||
status: "closed",
|
||||
archived_at: new Date().toISOString(),
|
||||
description: "Auto-created from invoice import (no matching case).",
|
||||
created_by: user.id,
|
||||
} as any)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) {
|
||||
errors.push(`Auto-create case "${title}": ${error?.message ?? "unknown"}`);
|
||||
return null;
|
||||
}
|
||||
ctx.caseByNumber.set(caseNumber, data.id);
|
||||
ctx.caseByTitle.set(title.toLowerCase().trim(), data.id);
|
||||
if (extId) ctx.caseByExt.set(String(extId), data.id);
|
||||
return data.id;
|
||||
};
|
||||
|
||||
const resolved: Row[] = [];
|
||||
for (const row of mapped) {
|
||||
let clientId: string | null = (row.client_id as string | null) ?? null;
|
||||
if (!clientId) {
|
||||
const cExt = row._clientext ? String(row._clientext).trim() : null;
|
||||
const cName = row._clientname ? String(row._clientname).trim() : null;
|
||||
const nameGuess = cName || cExt || "Imported (no client)";
|
||||
clientId = await ensureArchivedClient(nameGuess, cExt);
|
||||
if (!clientId) { bumpSkip("auto-create client failed"); continue; }
|
||||
}
|
||||
row.client_id = clientId;
|
||||
|
||||
// If the invoice mentions a case but we don't have it, create an
|
||||
// archived placeholder case so the reference is preserved.
|
||||
let caseId: string | null = (row.case_id as string | null) ?? null;
|
||||
const caseExt = row._caseext ? String(row._caseext).trim() : null;
|
||||
const caseNum = row._casenum ? String(row._casenum).trim() : null;
|
||||
if (!caseId && (caseExt || caseNum)) {
|
||||
const titleGuess = caseNum || caseExt || "Imported case";
|
||||
const numberGuess = caseNum || caseExt || `IMPORTED-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
caseId = await ensureArchivedCase(clientId, titleGuess, numberGuess, caseExt);
|
||||
}
|
||||
if (caseId) row.case_id = caseId;
|
||||
|
||||
// Clean hint columns
|
||||
delete row._clientext; delete row._clientname; delete row._caseext; delete row._casenum;
|
||||
resolved.push(row);
|
||||
}
|
||||
mapped.length = 0;
|
||||
mapped.push(...resolved);
|
||||
}
|
||||
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."], skipReasons } }));
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user