diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 49a7bc3..acea8bb 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -58,6 +58,8 @@ interface ImportCtx { caseByTitle: Map; userByName: Map; userByEmail: Map; + // Set of case ids considered "active" (not closed, not archived) + activeCaseIds: Set; // Optional fallback client id (e.g. for address-only locations CSV) defaultClientId?: string | null; // Selected contact group (for unified contacts importer) @@ -414,7 +416,7 @@ const IMPORTERS: ImporterConfig[] = [ { key: "time_entries", label: "Time entries → Time", - description: "Expected columns: Date, Case, Time, Rate, Flat rate, Total, Description, User, Case Name, Invoice, Nonbillable. Missing clients/cases are auto-created as archived. Rows with an Invoice number are linked to (or create) an invoice with that number; rows without an Invoice number stay unbilled.", + description: "Expected columns: Date, Case, Time, Rate, Flat rate, Total, Description, User, Case Name, Invoice, Nonbillable. Only rows matching an ACTIVE (non-archived, non-closed) case by case name/number are imported. Rows referencing missing, archived, or closed cases are skipped silently with a count.", table: "time_entries", conflict: "external_id", required: ["case_id", "description", "hours"], @@ -444,8 +446,11 @@ const IMPORTERS: ImporterConfig[] = [ (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || (r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) || 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. + // Only accept active (non-archived, non-closed) cases. + if (r.case_id && !ctx.activeCaseIds.has(r.case_id)) { + r.case_id = null; + } + // NOTE: do NOT delete _caseext/_casenum/_casename here — pre-insert step needs them for skip reporting. // Resolve hours from "Time" (e.g. "1.5", "1:30", "01:30:00") if needed if (r.hours == null && r._time != null) { @@ -510,7 +515,7 @@ const IMPORTERS: ImporterConfig[] = [ { key: "expenses", label: "Expenses → Expenses", - description: "Case-related expenses. Missing clients/cases are auto-created as archived. Rows with an Invoice number are linked to (or create) an invoice with that number; rows without an Invoice number stay unbilled.", + description: "Case-related expenses. Only rows matching an ACTIVE (non-archived, non-closed) case are imported. Rows referencing missing, archived, or closed cases are skipped silently with a count.", table: "expenses", conflict: "external_id", required: ["case_id", "description", "amount"], @@ -533,7 +538,11 @@ const IMPORTERS: ImporterConfig[] = [ (r._casenum && ctx.caseByNumber.get(String(r._casenum))) || (r._casename && ctx.caseByTitle.get(String(r._casename).toLowerCase().trim())) || null; - // Keep hints; async pre-insert step may create an archived case. + // Only accept active (non-archived, non-closed) cases. + if (r.case_id && !ctx.activeCaseIds.has(r.case_id)) { + r.case_id = null; + } + // Keep hints; pre-insert step decides whether to skip. if (r.amount == null) return null; r.user_id = ctx.userId; return r; @@ -664,6 +673,7 @@ function ImportPage() { caseByTitle: new Map(), userByName: new Map(), userByEmail: new Map(), + activeCaseIds: new Set(), defaultClientId: null, defaultContactType: null, }), [user?.id]); @@ -737,14 +747,19 @@ function ImportPage() { ctx.caseByExt.clear(); ctx.caseByNumber.clear(); ctx.caseByTitle.clear(); - const cases = await fetchAllPaginated<{ id: string; case_number: string | null; title: string | null; external_id: string | null }>( + ctx.activeCaseIds.clear(); + const cases = await fetchAllPaginated<{ id: string; case_number: string | null; title: string | null; external_id: string | null; status: string | null; archived_at: string | null }>( "cases", - "id, case_number, title, external_id", + "id, case_number, title, external_id, status, archived_at", ); + const CLOSED_STATUSES = new Set(["closed", "closed_won", "closed_lost"]); for (const r of cases) { if (r.external_id) ctx.caseByExt.set(r.external_id, r.id); if (r.case_number) ctx.caseByNumber.set(r.case_number, r.id); if (r.title) ctx.caseByTitle.set(r.title.toLowerCase().trim(), r.id); + if (!r.archived_at && !CLOSED_STATUSES.has(String(r.status ?? "").toLowerCase())) { + ctx.activeCaseIds.add(r.id); + } } ctx.userByName.clear(); ctx.userByEmail.clear(); @@ -1009,6 +1024,13 @@ function ImportPage() { let clientId: string | null = null; if (!caseId) { + // For time entries / expenses, never auto-create cases. + // Skip silently and report a count by reason. + if (cfg.key === "time_entries" || cfg.key === "expenses") { + const hint = row._casename || row._casenum || row._caseext; + bumpSkip(hint ? "no matching active case" : "missing case reference"); + continue; + } // Build display strings from hints const extId = row._caseext ? String(row._caseext) : null; const caseNum = row._casenum ? String(row._casenum).trim() : null;