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:
@@ -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
@@ -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;
|
||||
Reference in New Issue
Block a user