From 173e5aa7197b5a6297e12149642d5b49dd9bc7bb Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:51:00 +0000 Subject: [PATCH 1/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 2804c44..63bcc9c 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -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) { From 2b513e6c05cc6d3b0296a74a325407588df59ce3 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:51:09 +0000 Subject: [PATCH 2/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 63bcc9c..8a8cb0f 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -459,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; }, From 0e8cc4f2ae7bbb7acbe3172e2ebff32df748ea18 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:51:53 +0000 Subject: [PATCH 3/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 153 ++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 8a8cb0f..01c8e9e 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -801,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 @@ -812,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(); + 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 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 => { + 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 => { + 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(); // 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; From 1742858f524ddcc90d9ef27127aa74a75a7729b7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:52:04 +0000 Subject: [PATCH 4/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 01c8e9e..be4b0b8 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -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"], From 90032290405731ba969cba2c5ed66a818782f2e8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:52:12 +0000 Subject: [PATCH 5/5] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index be4b0b8..098d700 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -437,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"],