diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index acea8bb..ed51685 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -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 => { + 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 => { + 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;