Added billed flag & improved import

X-Lovable-Edit-ID: edt-e17f7fca-79fa-49e3-9e9e-59e45e0566e1
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-19 22:04:14 +00:00
co-authored by renee-png
3 changed files with 200 additions and 24 deletions
+6
View File
@@ -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
+186 -24
View File
@@ -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"],
@@ -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
@@ -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<string, string> = {};
const unknownHeaders: string[] = [];
@@ -1020,40 +1025,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<string, string>(); // `${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<string, string>(); // invoice_number -> invoiceId
const ensureDraftInvoice = async (
invoiceNumber: string,
clientId: string | null,
caseId: string | null,
): Promise<string | null> => {
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 +1071,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 +1107,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 +1121,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);
}
@@ -1257,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<string, string>();
const byExt = new Map<string, string>();
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
@@ -1360,6 +1457,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) {
@@ -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;