From f11a2858b4ee940d20ec3a6e0300c27d569c13bc 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 22:01:32 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/integrations/supabase/types.ts | 6 ++++++ ...0260419220129_9fa525a9-b364-4866-bd89-d4d1da029d08.sql | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 supabase/migrations/20260419220129_9fa525a9-b364-4866-bd89-d4d1da029d08.sql diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 229b2b4..4947872 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1497,6 +1497,7 @@ export type Database = { Row: { amount: number billable: boolean + billed: boolean case_id: string created_at: string description: string @@ -1511,6 +1512,7 @@ export type Database = { Insert: { amount: number billable?: boolean + billed?: boolean case_id: string created_at?: string description: string @@ -1525,6 +1527,7 @@ export type Database = { Update: { amount?: number billable?: boolean + billed?: boolean case_id?: string created_at?: string description?: string @@ -2911,6 +2914,7 @@ export type Database = { time_entries: { Row: { billable: boolean + billed: boolean case_id: string created_at: string description: string @@ -2925,6 +2929,7 @@ export type Database = { } Insert: { billable?: boolean + billed?: boolean case_id: string created_at?: string description: string @@ -2939,6 +2944,7 @@ export type Database = { } Update: { billable?: boolean + billed?: boolean case_id?: string created_at?: string description?: string diff --git a/supabase/migrations/20260419220129_9fa525a9-b364-4866-bd89-d4d1da029d08.sql b/supabase/migrations/20260419220129_9fa525a9-b364-4866-bd89-d4d1da029d08.sql new file mode 100644 index 0000000..956be12 --- /dev/null +++ b/supabase/migrations/20260419220129_9fa525a9-b364-4866-bd89-d4d1da029d08.sql @@ -0,0 +1,8 @@ +ALTER TABLE public.time_entries + ADD COLUMN IF NOT EXISTS billed boolean NOT NULL DEFAULT false; + +ALTER TABLE public.expenses + ADD COLUMN IF NOT EXISTS billed boolean NOT NULL DEFAULT false; + +UPDATE public.time_entries SET billed = true WHERE invoice_id IS NOT NULL AND billed = false; +UPDATE public.expenses SET billed = true WHERE invoice_id IS NOT NULL AND billed = false; \ No newline at end of file From 67fbbfd161c7459d54a841642205b716edab3f0e 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 22:02:06 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 51 ++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 68908fb..d0af9ae 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -1020,40 +1020,44 @@ function ImportPage() { if (extId) ctx.caseByExt.set(String(extId), data.id); return data.id; }; - // Look up an existing invoice by number for this client, or create a - // new "sent" invoice with that exact number to bucket imported items. - const invoiceByClientAndNumber = new Map(); // `${clientId}::${number}` -> invoiceId - const ensureNamedInvoice = async ( - clientId: string, - caseId: string, + // Look up an existing invoice by number (globally, not per-client) and + // create a draft invoice with status='draft' if none exists. Time/expense + // import drives invoice creation; the invoices CSV will later update + // header fields but never replace these auto-created draft invoices. + const invoiceByNumber = new Map(); // invoice_number -> invoiceId + const ensureDraftInvoice = async ( invoiceNumber: string, + clientId: string | null, + caseId: string | null, ): Promise => { - const cacheKey = `${clientId}::${invoiceNumber}`; - const cached = invoiceByClientAndNumber.get(cacheKey); + const cached = invoiceByNumber.get(invoiceNumber); if (cached) return cached; - // First try existing invoice with this number for this client const { data: existing } = await supabase .from("invoices") .select("id") - .eq("client_id", clientId) .eq("invoice_number", invoiceNumber) .maybeSingle(); if (existing?.id) { - invoiceByClientAndNumber.set(cacheKey, existing.id); + invoiceByNumber.set(invoiceNumber, existing.id); return existing.id; } + if (!clientId) { + // invoices.client_id is NOT NULL — fall back to "Imported (no client)" + clientId = await ensureArchivedClient("Imported (no client)", "imported-no-client"); + if (!clientId) return null; + } const { data, error } = await supabase .from("invoices") .insert({ client_id: clientId, case_id: caseId, invoice_number: invoiceNumber, - status: "sent", + status: "draft", issue_date: new Date().toISOString().slice(0, 10), subtotal: 0, tax: 0, total: 0, - notes: "Auto-created from import to hold items linked to this invoice number.", + notes: "Auto-created from time/expense import — header will be updated when invoices CSV is uploaded.", created_by: user.id, } as any) .select("id") @@ -1062,7 +1066,7 @@ function ImportPage() { errors.push(`Auto-create invoice "${invoiceNumber}": ${error?.message ?? "unknown"}`); return null; } - invoiceByClientAndNumber.set(cacheKey, data.id); + invoiceByNumber.set(invoiceNumber, data.id); return data.id; }; @@ -1098,7 +1102,7 @@ function ImportPage() { 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 + // Look up client_id for the case so a draft invoice can be created if (!clientId) { clientId = caseClientCache.get(caseId) ?? null; if (!clientId) { @@ -1112,15 +1116,20 @@ function ImportPage() { } } - // Link to a real invoice ONLY when the source row provided an - // invoice number. Otherwise leave invoice_id null so the user can - // bill these items normally later. + // If the source row gave an invoice number, ensure a draft invoice + // exists and link this row. Mark billed=true so it's tracked as + // already on an invoice. Otherwise leave invoice_id null and + // billed=false — these are open charges awaiting invoicing. const invNumber = row._invoice ? String(row._invoice).trim() : ""; delete row._invoice; - if (invNumber && clientId && !row.invoice_id) { - const invId = await ensureNamedInvoice(clientId, caseId, invNumber); - if (invId) row.invoice_id = invId; + if (invNumber && !row.invoice_id) { + const invId = await ensureDraftInvoice(invNumber, clientId, caseId); + if (invId) { + row.invoice_id = invId; + row.billed = true; + } } + if (row.billed == null) row.billed = !!row.invoice_id; resolved.push(row); } From 4bda7b97656b8e17b764b5fe2e70f04f4e0186fd 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 22:02:18 +0000 Subject: [PATCH 3/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index d0af9ae..baa04b7 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -853,6 +853,11 @@ function ImportPage() { "external_id", "created_by", ]); + // Allow internal columns we set in transforms but never read from CSV. + if (cfg.table === "time_entries" || cfg.table === "expenses") { + allowedTargets.add("billed"); + allowedTargets.add("invoice_id"); + } // Build header → db column map (only keep known targets) const headerMap: Record = {}; const unknownHeaders: string[] = []; From 3d92e220e0fa2ba3d1940818400174c6beeceb3b 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 22:02:49 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 65 ++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index baa04b7..29c79f5 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -1374,6 +1374,71 @@ function ImportPage() { if (cfg.postInsert) cfg.postInsert(inserted_rows, ctx); + // For time/expense imports: create invoice_line_items for any inserted + // row that was attached to an invoice (billed=true), then recompute the + // affected invoices' subtotal/total = sum of all their lines. + if ((cfg.table === "time_entries" || cfg.table === "expenses") && inserted_rows.length > 0) { + type LinkedRow = Row & { id: string; invoice_id: string; case_id: string }; + const linked = inserted_rows.filter( + (r) => r.invoice_id && r.id && r.case_id, + ) as LinkedRow[]; + if (linked.length > 0) { + const lineItems = linked.map((r) => { + if (cfg.table === "time_entries") { + const qty = Number(r.hours ?? 0); + const rate = Number(r.hourly_rate ?? 0); + return { + invoice_id: r.invoice_id, + case_id: r.case_id, + kind: "time", + description: String(r.description ?? "Time entry"), + work_date: r.work_date ?? null, + quantity: qty, + rate, + amount: +(qty * rate).toFixed(2), + time_entry_id: r.id, + user_id: r.user_id ?? null, + }; + } + const amt = Number(r.amount ?? 0); + return { + invoice_id: r.invoice_id, + case_id: r.case_id, + kind: "expense", + description: String(r.description ?? "Expense"), + work_date: r.expense_date ?? null, + quantity: 1, + rate: amt, + amount: amt, + expense_id: r.id, + user_id: r.user_id ?? null, + }; + }); + for (let i = 0; i < lineItems.length; i += 500) { + const slice = lineItems.slice(i, i + 500); + const { error: liErr } = await supabase + .from("invoice_line_items") + .insert(slice as any); + if (liErr) errors.push(`Line items (batch ${i / 500 + 1}): ${liErr.message}`); + } + const invoiceIds = Array.from(new Set(linked.map((r) => r.invoice_id))); + for (const invId of invoiceIds) { + const { data: lines } = await supabase + .from("invoice_line_items") + .select("amount") + .eq("invoice_id", invId); + const subtotal = (lines ?? []).reduce( + (s, l) => s + Number((l as { amount: number }).amount ?? 0), + 0, + ); + await supabase + .from("invoices") + .update({ subtotal, total: subtotal } as any) + .eq("id", invId); + } + } + } + // Mirror imported "client" contacts into the clients table so they appear on the Clients page. let mirrored = 0; if (cfg.key === "contacts" && contactGroup === "client" && inserted_rows.length > 0) { From 0462ffb3ec182974f0f8f196d6b148d94e302b51 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 22:03:38 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/routes/settings.import.tsx | 83 ++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/src/routes/settings.import.tsx b/src/routes/settings.import.tsx index 29c79f5..addf9c7 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -1271,6 +1271,89 @@ function ImportPage() { const chunkSize = 200; let inserted = 0; const inserted_rows: Row[] = []; + + // INVOICES: special merge — match existing invoices by invoice_number + // (preferred) or external_id and UPDATE only header fields. Subtotal, + // total, tax, and amount_paid are left alone so totals stay = sum of + // line items already attached from the time/expense imports. + if (cfg.table === "invoices" && mapped.length > 0) { + const numbers = mapped.map((r) => String(r.invoice_number ?? "")).filter(Boolean); + const externalIds = mapped.map((r) => String(r.external_id ?? "")).filter(Boolean); + const byNumber = new Map(); + const byExt = new Map(); + for (let k = 0; k < numbers.length; k += 500) { + const slice = numbers.slice(k, k + 500); + const { data } = await supabase + .from("invoices") + .select("id, invoice_number") + .in("invoice_number", slice); + for (const row of (data ?? []) as { id: string; invoice_number: string }[]) { + byNumber.set(row.invoice_number, row.id); + } + } + for (let k = 0; k < externalIds.length; k += 500) { + const slice = externalIds.slice(k, k + 500); + const { data } = await supabase + .from("invoices") + .select("id, external_id") + .in("external_id", slice); + for (const row of (data ?? []) as { id: string; external_id: string }[]) { + byExt.set(row.external_id, row.id); + } + } + + const HEADER_FIELDS = [ + "status", "issue_date", "due_date", "notes", + "client_id", "case_id", "external_id", "invoice_number", + ] as const; + const toUpdateInv: Array<{ id: string; payload: Row }> = []; + const toInsertInv: Row[] = []; + for (const r of mapped) { + const num = r.invoice_number ? String(r.invoice_number) : ""; + const ext = r.external_id ? String(r.external_id) : ""; + const matchId = (num && byNumber.get(num)) || (ext && byExt.get(ext)) || null; + if (matchId) { + const payload: Row = {}; + for (const f of HEADER_FIELDS) { + if (r[f] != null && r[f] !== "") payload[f] = r[f]; + } + toUpdateInv.push({ id: matchId, payload }); + } else { + toInsertInv.push(r); + } + } + + for (let k = 0; k < toUpdateInv.length; k += 50) { + const slice = toUpdateInv.slice(k, k + 50); + const results = await Promise.all( + slice.map(({ id, payload }) => + supabase.from("invoices").update(payload as any).eq("id", id).select("*").single(), + ), + ); + for (const result of results) { + if (result.error) { + errors.push(`Invoice merge update: ${result.error.message}`); + } else if (result.data) { + inserted += 1; + inserted_rows.push(result.data as Row); + } + } + } + + for (let k = 0; k < toInsertInv.length; k += chunkSize) { + const slice = toInsertInv.slice(k, k + chunkSize); + const { data, error } = await supabase.from("invoices").insert(slice as any).select("*"); + if (error) errors.push(`Invoice merge insert: ${error.message}`); + else if (data) { + inserted += data.length; + inserted_rows.push(...(data as Row[])); + } + } + + // Skip the generic chunk loop below for invoices. + mapped.length = 0; + } + for (let i = 0; i < mapped.length; i += chunkSize) { const chunk = mapped.slice(i, i + chunkSize); // Filter out rows missing the conflict column for upsert; insert plain From 10337c4848c1ebe6ea3a1216c20967315fd857a0 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 22:03:51 +0000 Subject: [PATCH 6/7] 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 addf9c7..018df02 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -569,7 +569,7 @@ const IMPORTERS: ImporterConfig[] = [ { key: "invoices", label: "Invoices → Invoices", - 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.", + description: "Invoice headers. Matched against existing invoices by invoice number (preferred) or external ID — when a match exists, only header fields (status, dates, notes) are updated and totals are left intact (totals = sum of attached time/expense line items). Non-matching invoices are inserted as new. Import time entries and expenses FIRST so their invoices are auto-created as drafts; this CSV then promotes them to sent/paid/etc.", table: "invoices", conflict: "external_id", // Only invoice_number is strictly required at the row level. client_id is From 786aa664928e35bcc954156e7fd1c2757644f1d7 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 22:04:03 +0000 Subject: [PATCH 7/7] 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 018df02..ca77f3e 100644 --- a/src/routes/settings.import.tsx +++ b/src/routes/settings.import.tsx @@ -433,7 +433,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. 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.", + description: "Expected columns: Date, Case, Time, Rate, Total, Description, User, Invoice. If a row has an Invoice number, a draft invoice is auto-created (or matched) and the entry is added as a line item with billed=true. Rows without an Invoice number are saved as open charges (billed=false) ready to be invoiced. Only ACTIVE (non-archived, non-closed) cases are imported.", table: "time_entries", conflict: "external_id", required: ["case_id", "description", "hours"], @@ -532,7 +532,7 @@ const IMPORTERS: ImporterConfig[] = [ { key: "expenses", label: "Expenses → Expenses", - 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.", + description: "Case-related expenses. If the row has an Invoice number, a draft invoice is auto-created (or matched) and the expense is added as a line item with billed=true. Rows without an Invoice number stay as open charges (billed=false). Only ACTIVE (non-archived, non-closed) cases are imported.", table: "expenses", conflict: "external_id", required: ["case_id", "description", "amount"],