Auto-created archived cases
X-Lovable-Edit-ID: edt-acc17e17-2606-476b-9189-d6c3aee6b427 Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
@@ -342,7 +342,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
{
|
||||
key: "time_entries",
|
||||
label: "Time entries → Time",
|
||||
description: "Billable time records. Columns: Date, Case (number/name), Description/Activity, Time/Hours, Rate, Flat rate/Flat fee, Total, User, Billable (yes/no) or Nonbillable (yes/no).",
|
||||
description: "Billable time records. Columns: Date, Case (number/name), Description/Activity, Time/Hours, Rate, Flat rate/Flat fee, Total, User, Billable (yes/no) or Nonbillable (yes/no). Missing clients/cases are auto-created as archived; rows are marked already-invoiced.",
|
||||
table: "time_entries",
|
||||
conflict: "external_id",
|
||||
required: ["case_id", "description", "hours"],
|
||||
@@ -365,14 +365,15 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
boolCols: ["billable"],
|
||||
dateCols: ["work_date"],
|
||||
transform: (r, ctx) => {
|
||||
// Resolve case
|
||||
// Resolve case (keep hints on row so the async pre-insert step can
|
||||
// auto-create an archived case + client when no match is found).
|
||||
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;
|
||||
// NOTE: do NOT delete _caseext/_casenum/_casename here — async step needs them.
|
||||
// NOTE: do NOT bail on missing case — async step will create one.
|
||||
|
||||
// Resolve hours from "Time" (e.g. "1.5", "1:30", "01:30:00") if needed
|
||||
if (r.hours == null && r._time != null) {
|
||||
@@ -436,7 +437,7 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
{
|
||||
key: "expenses",
|
||||
label: "Expenses → Expenses",
|
||||
description: "Case-related expenses.",
|
||||
description: "Case-related expenses. Missing clients/cases are auto-created as archived; rows are marked already-invoiced.",
|
||||
table: "expenses",
|
||||
conflict: "external_id",
|
||||
required: ["case_id", "description", "amount"],
|
||||
@@ -458,8 +459,8 @@ const IMPORTERS: ImporterConfig[] = [
|
||||
(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.amount == null) return null;
|
||||
// Keep hints; async pre-insert step may create an archived case.
|
||||
if (r.amount == null) return null;
|
||||
r.user_id = ctx.userId;
|
||||
return r;
|
||||
},
|
||||
@@ -800,8 +801,15 @@ function ImportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// required check
|
||||
const missing = cfg.required.find((k) => !transformed[k] && transformed[k] !== 0);
|
||||
// required check (defer case_id for time/expense — async step may create one)
|
||||
const deferCaseId =
|
||||
(cfg.table === "time_entries" || cfg.table === "expenses") &&
|
||||
!transformed.case_id &&
|
||||
(transformed._caseext || transformed._casenum || transformed._casename);
|
||||
const missing = cfg.required.find((k) => {
|
||||
if (k === "case_id" && deferCaseId) return false;
|
||||
return !transformed[k] && transformed[k] !== 0;
|
||||
});
|
||||
if (missing) { bumpSkip(`missing required: ${missing}`); continue; }
|
||||
|
||||
// attach created_by where not present
|
||||
@@ -811,6 +819,148 @@ function ImportPage() {
|
||||
mapped.push(transformed);
|
||||
}
|
||||
|
||||
// Async pre-insert: for time/expense rows missing a case, auto-create an
|
||||
// archived client + archived case from the row hints, then attach a
|
||||
// placeholder voided invoice so the row imports as "already invoiced".
|
||||
if (cfg.table === "time_entries" || cfg.table === "expenses") {
|
||||
const placeholderInvoiceByClient = new Map<string, string>();
|
||||
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 time/expense 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 time/expense 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 ensurePreInvoicedInvoice = async (clientId: string, caseId: string): Promise<string | null> => {
|
||||
const cached = placeholderInvoiceByClient.get(clientId);
|
||||
if (cached) return cached;
|
||||
const invNumber = `IMPORT-PREBILLED-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
const { data, error } = await supabase
|
||||
.from("invoices")
|
||||
.insert({
|
||||
client_id: clientId,
|
||||
case_id: caseId,
|
||||
invoice_number: invNumber,
|
||||
status: "void",
|
||||
issue_date: new Date().toISOString().slice(0, 10),
|
||||
subtotal: 0,
|
||||
tax: 0,
|
||||
total: 0,
|
||||
notes: "Placeholder invoice for items imported as already invoiced.",
|
||||
created_by: user.id,
|
||||
} as any)
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) {
|
||||
errors.push(`Auto-create placeholder invoice: ${error?.message ?? "unknown"}`);
|
||||
return null;
|
||||
}
|
||||
placeholderInvoiceByClient.set(clientId, data.id);
|
||||
return data.id;
|
||||
};
|
||||
|
||||
const resolved: Row[] = [];
|
||||
const caseClientCache = new Map<string, string>(); // case_id -> client_id (for created cases)
|
||||
for (const row of mapped) {
|
||||
let caseId: string | null = row.case_id ?? null;
|
||||
let clientId: string | null = null;
|
||||
|
||||
if (!caseId) {
|
||||
// Build display strings from hints
|
||||
const extId = row._caseext ? String(row._caseext) : null;
|
||||
const caseNum = row._casenum ? String(row._casenum).trim() : null;
|
||||
const caseName = row._casename ? String(row._casename).trim() : null;
|
||||
const titleGuess = caseName || caseNum || extId || "Imported case";
|
||||
const numberGuess = caseNum || extId || `IMPORTED-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
||||
// Auto-create an "Imported" archived client to host the case
|
||||
clientId = await ensureArchivedClient("Imported (no client)", "imported-no-client");
|
||||
if (!clientId) { bumpSkip("auto-create client failed"); continue; }
|
||||
caseId = await ensureArchivedCase(clientId, titleGuess, numberGuess, extId);
|
||||
if (!caseId) { bumpSkip("auto-create case failed"); continue; }
|
||||
caseClientCache.set(caseId, clientId);
|
||||
}
|
||||
|
||||
// Clean hint columns before insert
|
||||
delete row._caseext; delete row._casenum; delete row._casename;
|
||||
row.case_id = caseId;
|
||||
|
||||
// Look up client_id for the case to attach a placeholder voided invoice
|
||||
if (!clientId) {
|
||||
clientId = caseClientCache.get(caseId) ?? null;
|
||||
if (!clientId) {
|
||||
const { data: caseRow } = await supabase
|
||||
.from("cases")
|
||||
.select("client_id")
|
||||
.eq("id", caseId)
|
||||
.maybeSingle();
|
||||
clientId = (caseRow?.client_id as string | null) ?? null;
|
||||
if (clientId) caseClientCache.set(caseId, clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as already invoiced via placeholder voided invoice (per client)
|
||||
if (clientId && !row.invoice_id) {
|
||||
const invId = await ensurePreInvoicedInvoice(clientId, caseId);
|
||||
if (invId) row.invoice_id = invId;
|
||||
}
|
||||
|
||||
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