From 7bce0a4e805dbc0e39ca8fe3b61cfcba94de6423 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 24 Aug 2026 22:03:08 +0200 Subject: [PATCH] feat(fuel): drop-in xlsx/csv import for pump sheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Aug 2026 pump sheet from The Hills (and any future sheet from the same source) uses different column names and a different date format than the legacy CSV the import route was built for. Adapts the route so the same endpoint accepts either Excel (.xlsx) or CSV, maps the new column names onto the existing FUEL_LOGS schema, and persists their 'Latest Consumption' column verbatim. What's new • SheetJS (xlsx) replaces PapaParse for parsing. Single code path handles both .xlsx and .csv uploads. • Resolves columns case-insensitively against an alias table so legacy CSVs (vehicle_description, odo_reading) still work alongside the new shape (Date Logged, Vehicle QR, Km's/ Hours, Litres Pumped, Current Site, Latest Consumption). • Date parser: DD/MM/YYYY first (with leading-space tolerance), falls back to YYYY-MM-DD, then to Excel serial numbers. • Vehicle match: QR code first (clean identifier in the source data), falls back to description text for legacy CSV. • Site match: by sites.name (one round-trip, cached for the import). project_code is derived from the resolved site; falls back to the vehicle's project_code. • Latest Consumption persisted as a new FUEL_LOGS field (latest_consumption, number, optional). Empty source cells stay empty rather than getting coerced to 0. PocketBase change • scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py adds the new field. Idempotent; follows the pattern of the 2026-08-19 migration. Run it with PB_ADMIN_EMAIL + PB_ADMIN_PASSWORD before importing the new sheets, or the import will fail with 'latest_consumption is not a field'. UI • Import button now accepts .xlsx and CSV; SAMPLE_HEADERS + SAMPLE_OPTIONAL show the new column expectations and the DD/MM/YYYY date format hint. • fuel-view.tsx shows a 'Consumption (src)' column on the row table so operators can eyeball what their fuel team sent against the app's recomputed variance. Out of scope (intentionally untouched) • vehicles, sites, data_import_batches and every other collection — no schema changes there. • PO / timesheets / delivery-notes imports. • fuel/page.tsx business logic (variance calc, vehicle summaries). Verified • Dry-run against 'The Hills Diesel 1-24 August.xlsx': 134/134 rows parse cleanly, 0 errors. 12/134 rows correctly carry an empty Latest Consumption (matches the source). • vitest: 20/20 fuel-import-parse + 35/35 PO-schema tests pass. • tsc --noEmit clean. • One pre-existing failure in src/app/api/purchase-orders/process-batch/route.test.ts unrelated to fuel; confirmed failing on main before this change. --- package-lock.json | 106 ++++++- package.json | 3 +- ...2026-08-24-fuel-logs-latest-consumption.py | 131 +++++++++ src/app/api/fuel-logs/import/route.ts | 263 ++++++++++++++---- src/app/dashboard/fuel/fuel-view.tsx | 33 ++- src/app/dashboard/fuel/page.tsx | 6 +- src/components/import-fuel-logs-button.tsx | 46 ++- src/lib/fuel-import-parse.test.ts | 123 ++++++++ src/lib/fuel-import-parse.ts | 100 +++++++ src/lib/pb-types.ts | 9 + 10 files changed, 753 insertions(+), 67 deletions(-) create mode 100755 scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py create mode 100644 src/lib/fuel-import-parse.test.ts create mode 100644 src/lib/fuel-import-parse.ts diff --git a/package-lock.json b/package-lock.json index cf6d64e..acab8af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.15.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", @@ -5805,6 +5806,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -6357,6 +6367,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -6438,6 +6461,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6501,6 +6533,18 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7801,6 +7845,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -10766,6 +10819,18 @@ "deprecated": "Please use @jridgewell/sourcemap-codec instead", "license": "MIT" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -12020,6 +12085,24 @@ "node": ">=8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12398,6 +12481,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 73883d5..264da3a 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "recharts": "^2.15.0", - "tailwind-merge": "^2.6.0" + "tailwind-merge": "^2.6.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@tailwindcss/postcss": "^4.0.0", diff --git a/scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py b/scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py new file mode 100755 index 0000000..d3c70e6 --- /dev/null +++ b/scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +One-shot PocketBase migration applied via: + + PB_URL=https://pb.mega.infra.apphub.co.za \\ + PB_ADMIN_EMAIL=admin@mega.co.za \\ + PB_ADMIN_PASSWORD=... \\ + python3 scripts/migrations/2026-08-24-fuel-logs-latest-consumption.py + +Adds a `latest_consumption` number field to FUEL_LOGS so the new +Excel/CSV import flow can persist the "Latest Consumption" column from +the pump sheet exactly as their fuel team reports it (rather than +silently overwriting with the app's recomputed value). + +Why persist at all if the UI already computes consumption? + 1. Audit trail: stores what their team actually filed, not what we + computed — useful when the computed number disagrees with theirs + on edge cases (zero distance, missing prior reading). + 2. Drift detection: later we can flag rows where stored and computed + differ by more than a tolerance. + 3. Empty cells in the source (12/134 rows in the Aug 2026 sample) + are preserved as NULL — we never invent a value. + +Idempotent: re-running is safe; existing fields are skipped. +""" +import json +import os +import sys +import urllib.error +import urllib.request + + +COLLECTION = "fuel_logs" + +NEW_FIELDS = { + "latest_consumption": { + "name": "latest_consumption", + "type": "number", + "required": False, + # No min/max / noDecimal — mirrors the existing `litres` and + # `odo_reading` fields exactly, so the same PB numeric-coercion + # rules apply on write. + }, +} + + +def admin_auth(base_url: str) -> str: + email = os.environ["PB_ADMIN_EMAIL"] + password = os.environ["PB_ADMIN_PASSWORD"] + body = json.dumps({"identity": email, "password": password}).encode() + req = urllib.request.Request( + f"{base_url}/api/collections/_superusers/auth-with-password", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read())["token"] + + +def get_collection(base_url: str, token: str, name: str): + req = urllib.request.Request( + f"{base_url}/api/collections/{name}", + headers={"Authorization": token}, + ) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +def patch_collection(base_url: str, token: str, name: str, body: dict): + req = urllib.request.Request( + f"{base_url}/api/collections/{name}", + data=json.dumps(body).encode(), + headers={ + "Authorization": token, + "Content-Type": "application/json", + }, + method="PATCH", + ) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body_txt = e.read().decode() if e.fp else "" + raise RuntimeError(f"PATCH {name} failed: {e.code} {body_txt}") from e + + +def main() -> int: + base_url = os.environ.get("PB_URL", "http://127.0.0.1:8090").rstrip("/") + token = admin_auth(base_url) + coll = get_collection(base_url, token, COLLECTION) + if not coll: + print(f"ERROR: collection {COLLECTION!r} not found on {base_url}") + return 1 + + existing_fields = coll.get("fields", []) + existing_field_names = {f["name"] for f in existing_fields} + new_fields = [ + NEW_FIELDS[name] for name in NEW_FIELDS + if name not in existing_field_names + ] + if new_fields: + merged_fields = existing_fields + new_fields + print(f"Adding {len(new_fields)} new field(s) to {COLLECTION}: " + f"{[f['name'] for f in new_fields]}") + patch_collection(base_url, token, COLLECTION, {"fields": merged_fields}) + coll = get_collection(base_url, token, COLLECTION) + existing_fields = coll.get("fields", []) + else: + print(f"No-op: all NEW_FIELDS already present on {COLLECTION}.") + + # Final sanity dump for the operator. + final = get_collection(base_url, token, COLLECTION) + final_field_names = sorted(f["name"] for f in final.get("fields", [])) + print(f"\nFinal field list on {COLLECTION}: {final_field_names}") + print("\nMigration complete.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyError as e: + print(f"ERROR: missing environment variable {e}. " + "Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.", file=sys.stderr) + sys.exit(2) diff --git a/src/app/api/fuel-logs/import/route.ts b/src/app/api/fuel-logs/import/route.ts index 5cd2db0..46e1480 100644 --- a/src/app/api/fuel-logs/import/route.ts +++ b/src/app/api/fuel-logs/import/route.ts @@ -1,59 +1,153 @@ import { NextResponse } from "next/server"; -import Papa from "papaparse"; +import * as XLSX from "xlsx"; import { pbAdmin } from "@/lib/pb"; +import { + resolveColumn, + parseDate, + toNumber, +} from "@/lib/fuel-import-parse"; /** * POST /api/fuel-logs/import * - * Accepts multipart/form-data with a single `file` field containing a CSV. - * Expected columns (header row required): + * Accepts multipart/form-data with a single `file` field containing an + * Excel (.xlsx) or CSV upload. The same route accepts both formats via + * SheetJS, so the user can drop either file in. * - * vehicle_description - matches VEHICLES.description (case-insensitive) - * date_logged - yyyy-mm-dd - * litres - number - * odo_reading - number (odometer / hour meter) + * Expected columns (header row required, case-insensitive, whitespace + * normalised). The "Aug 2026" pump sheet from The Hills uses the + * "primary" spelling; the others are legacy CSV aliases kept working + * so old imports don't break: * - * Optional: - * project_code - falls back to the vehicle's project_code if blank + * PRIMARY LEGACY ALIASES + * ──────────────────────────── ──────────────────────────────── + * Date Logged date, date_logged + * Vehicle QR vehicle_qr, qr, qr_code_id + * Vehicle vehicle_description, description + * Km's/ Hours odo_reading, odometer, hour_meter + * Litres Pumped litres, liters + * Current Site site, current_site + * Latest Consumption latest_consumption, consumption (optional) * - * The route: - * 1. Creates one DATA_IMPORT_BATCHES record (batch_type=fuel_logs) - * with the raw CSV attached for the audit trail. - * 2. Resolves each row's vehicle_description → VEHICLES.id. - * 3. Inserts a FUEL_LOGS row per valid input, linked to the batch. + * Matching strategy: + * - Vehicle: prefer QR (the clean identifier in the source data), + * fall back to description text for legacy CSV imports. + * - Site: match `sites.name` case-insensitive. One round-trip at + * the top of the route; cached in a Map for the duration. * - * Returns JSON: { batchId, imported, skipped, errors[] }. + * Pipeline: + * 1. Parse file (xlsx or csv) into rows. + * 2. Create one DATA_IMPORT_BATCHES record (batch_type=fuel_logs) + * with the original file attached for the audit trail. Status is + * `processed` from birth - fuel imports stay synchronous (no AI, + * fixed schema). + * 3. For each row: validate, resolve vehicle+site, insert one + * FUEL_LOGS row linked to the batch. + * + * Returns JSON: { batchId, totalRows, imported, skipped, errors[] }. + * + * Date parsing: pump sheets use DD/MM/YYYY (often with a leading space). + * We try that first, fall back to YYYY-MM-DD for legacy CSV, then to + * Excel date serials (numbers > 25569). Anything else is a row error. + * + * Pure helpers (resolveColumn, parseDate, toNumber) live in + * src/lib/fuel-import-parse.ts so they're unit-testable without the + * Next.js request lifecycle. */ + export async function POST(request: Request) { const form = await request.formData(); const file = form.get("file"); if (!(file instanceof File)) { return NextResponse.json({ error: "No file provided" }, { status: 400 }); } - const csvText = await file.text(); - const parsed = Papa.parse>(csvText, { - header: true, - skipEmptyLines: true, - transformHeader: (h) => h.trim(), - }); - - if (parsed.errors.length > 0 && parsed.data.length === 0) { + // SheetJS handles both .xlsx (binary) and .csv (text) from a single + // call site; we just feed it the right "type". + const filename = file.name.toLowerCase(); + let workbook: XLSX.WorkBook; + try { + if (filename.endsWith(".csv")) { + const text = await file.text(); + workbook = XLSX.read(text, { type: "string" }); + } else { + const buf = await file.arrayBuffer(); + workbook = XLSX.read(new Uint8Array(buf), { type: "array" }); + } + } catch (err) { return NextResponse.json( { - error: "CSV could not be parsed", - details: parsed.errors.slice(0, 5), + error: "File could not be parsed as Excel or CSV", + details: err instanceof Error ? err.message : String(err), + }, + { status: 400 }, + ); + } + + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + return NextResponse.json( + { error: "Workbook has no sheets" }, + { status: 400 }, + ); + } + + // raw:true keeps numbers as numbers (matters for odo/litres/ + // latest_consumption) and dates as Excel serials - we handle both in + // parseDate(). defval:"" so blank cells become "" not undefined. + const rowsRaw = XLSX.utils.sheet_to_json>( + workbook.Sheets[sheetName], + { raw: true, defval: "" }, + ); + + if (rowsRaw.length === 0) { + return NextResponse.json( + { error: "File contains no data rows" }, + { status: 400 }, + ); + } + + const headers = Object.keys(rowsRaw[0] ?? {}); + const dateCol = resolveColumn(headers, "date"); + const qrCol = resolveColumn(headers, "vehicle_qr"); + const descCol = resolveColumn(headers, "vehicle_desc"); + const odoCol = resolveColumn(headers, "odo"); + const litresCol = resolveColumn(headers, "litres"); + const siteCol = resolveColumn(headers, "site"); + const consumptionCol = resolveColumn(headers, "consumption"); + + // Required at minimum: a date, an odometer/hour reading, and a litre + // count. Vehicle can come from QR OR description (legacy). + if (!dateCol || !odoCol || !litresCol) { + return NextResponse.json( + { + error: "Missing required columns", + details: { + need: ["Date Logged", "Km's/ Hours", "Litres Pumped"], + found: headers, + }, + }, + { status: 400 }, + ); + } + if (!qrCol && !descCol) { + return NextResponse.json( + { + error: "Missing vehicle identifier column", + details: { + need: ["Vehicle QR", "Vehicle"], + found: headers, + }, }, { status: 400 }, ); } const pb = await pbAdmin(); - - // Create the batch with the original file attached. Fuel-logs stay - // synchronous (fixed-format CSV, no AI needed), so the batch is born - // `processed` — see Task 8 of the 2026-08-19 PO-import-queue plan. const now = new Date().toISOString(); + + // Audit-trail batch - original file attached so the raw sheet is + // recoverable later even if the import mapping changes again. const batch = await pb.collection("data_import_batches").create({ batch_type: "fuel_logs", date_uploaded: now, @@ -64,55 +158,114 @@ export async function POST(request: Request) { attempts: 0, }); - // Build description → vehicle lookup in one round-trip + // Vehicle lookup. QR is the clean identifier (source data has QR + // but the description column has typos and casing drift). Description + // remains as a fallback for legacy CSV imports that lack a QR column. const allVehicles = await pb.collection("vehicles").getFullList({ - fields: "id,description,project_code", + fields: "id,description,project_code,qr_code_id", }); + const vehicleByQr = new Map(); const vehicleByDesc = new Map(); for (const v of allVehicles) { - vehicleByDesc.set(v.description.trim().toLowerCase(), { - id: v.id, - projectCode: v.project_code, - }); + const qr = (v.qr_code_id ?? "").trim(); + if (qr) vehicleByQr.set(qr, { id: v.id, projectCode: v.project_code }); + const desc = (v.description ?? "").trim().toLowerCase(); + if (desc) vehicleByDesc.set(desc, { id: v.id, projectCode: v.project_code }); + } + + // Site lookup by `sites.name`. One round-trip; cached for the whole + // import. Used both to resolve the relation and to derive the + // canonical `project_code` for the row. + const allSites = await pb.collection("sites").getFullList({ + fields: "id,name,project_code", + }); + const siteByName = new Map(); + for (const s of allSites) { + const name = (s.name ?? "").trim().toLowerCase(); + if (name) siteByName.set(name, { id: s.id, projectCode: s.project_code }); } let imported = 0; + let qrMatched = 0; + let descMatched = 0; const errors: { row: number; reason: string }[] = []; - for (let i = 0; i < parsed.data.length; i++) { - const row = parsed.data[i]; - const desc = (row.vehicle_description ?? "").trim().toLowerCase(); - const date = (row.date_logged ?? "").trim(); - const litres = Number(row.litres); - const odo = Number(row.odo_reading); + for (let i = 0; i < rowsRaw.length; i++) { + const row = rowsRaw[i]; - if (!desc || !date || !Number.isFinite(litres) || !Number.isFinite(odo)) { + // --- Required fields ----------------------------------------------- + const isoDate = parseDate(row[dateCol]); + if (!isoDate) { errors.push({ row: i + 2, - reason: `Missing/invalid fields (need vehicle_description, date_logged, litres, odo_reading)`, + reason: `Invalid/missing date (got ${JSON.stringify(row[dateCol])})`, }); continue; } - const vehicle = vehicleByDesc.get(desc); + + const odo = toNumber(row[odoCol]); + const litres = toNumber(row[litresCol]); + if (odo === null || litres === null) { + errors.push({ + row: i + 2, + reason: "Missing/invalid odo_reading or litres (need both)", + }); + continue; + } + + // --- Vehicle: QR first, description fallback ----------------------- + const qrRaw = qrCol ? String(row[qrCol] ?? "").trim() : ""; + const descRaw = descCol ? String(row[descCol] ?? "").trim() : ""; + let vehicle: { id: string; projectCode: string } | undefined; + if (qrRaw && vehicleByQr.has(qrRaw)) { + vehicle = vehicleByQr.get(qrRaw); + qrMatched++; + } else if (descRaw && vehicleByDesc.has(descRaw.toLowerCase())) { + vehicle = vehicleByDesc.get(descRaw.toLowerCase()); + descMatched++; + } if (!vehicle) { errors.push({ row: i + 2, - reason: `Unknown vehicle "${desc}"`, + reason: `Unknown vehicle (qr=${qrRaw || "-"}, description=${descRaw || "-"})`, }); continue; } - const explicitProject = (row.project_code ?? "").trim(); - const projectCode = explicitProject || vehicle.projectCode; + // --- Site (optional in the row) ------------------------------------ + let siteId: string | undefined; + let siteProjectCode: string | undefined; + const siteRaw = siteCol ? String(row[siteCol] ?? "").trim() : ""; + if (siteRaw) { + const site = siteByName.get(siteRaw.toLowerCase()); + if (site) { + siteId = site.id; + siteProjectCode = site.projectCode; + } + } + + // project_code: prefer the resolved site's project_code, then fall + // back to the vehicle's project_code (matches pre-existing behaviour + // for legacy CSV imports that have no site column). + const projectCode = siteProjectCode ?? vehicle.projectCode; + + // latest_consumption is OPTIONAL - empty cells stay undefined rather + // than being silently set to 0. + const consumptionRaw = consumptionCol ? row[consumptionCol] : undefined; + const latestConsumption = toNumber(consumptionRaw); + const latestConsumptionOrUndef = + latestConsumption === null ? undefined : latestConsumption; try { await pb.collection("fuel_logs").create({ project_code: projectCode, + site: siteId, vehicle: vehicle.id, batch_reference: batch.id, - date_logged: new Date(date).toISOString(), + date_logged: isoDate, litres, odo_reading: odo, + latest_consumption: latestConsumptionOrUndef, }); imported++; } catch (err) { @@ -123,11 +276,21 @@ export async function POST(request: Request) { } } + if (process.env.NODE_ENV !== "production") { + console.log("[fuel-import] summary", { + totalRows: rowsRaw.length, + imported, + skipped: errors.length, + qrMatched, + descMatched, + }); + } + return NextResponse.json({ batchId: batch.id, - totalRows: parsed.data.length, + totalRows: rowsRaw.length, imported, skipped: errors.length, errors, }); -} \ No newline at end of file +} diff --git a/src/app/dashboard/fuel/fuel-view.tsx b/src/app/dashboard/fuel/fuel-view.tsx index 7e9fefe..7ed8cbe 100644 --- a/src/app/dashboard/fuel/fuel-view.tsx +++ b/src/app/dashboard/fuel/fuel-view.tsx @@ -66,6 +66,13 @@ export interface FuelRow { variancePct: number | null; status: FuelStatus; cost: number; + /** + * "Latest Consumption" as reported by the source pump sheet (typically + * litres/100km or litres/hour). Stored verbatim on the FUEL_LOGS row + * so we can audit it against the app's recomputed variancePct later. + * undefined when the source row had no value. + */ + latestConsumption?: number | null; } export interface VehicleSummary { @@ -376,6 +383,12 @@ export function FuelView({ Vehicle Odo / meter Litres + + Consumption (src) + Allowable limit Variance Cost @@ -384,7 +397,7 @@ export function FuelView({ {pageRows.length === 0 ? ( - + {rows.length === 0 ? "No fuel logs yet — upload a CSV to get started." : "No fuel logs match the current filters."} @@ -421,10 +434,24 @@ export function FuelView({ {formatNumber(r.litres, { maximumFractionDigits: 2 })} L + + {r.latestConsumption != null ? ( + + {formatNumber(r.latestConsumption, { + maximumFractionDigits: 2, + })} + + ) : ( + — + )} + {r.limitTarget !== null && r.limitUnit ? `${r.limitTarget} ${r.limitUnit}` - : "—"} + : "-"} @@ -561,4 +588,4 @@ function FleetStatusBadge({ {alert > 0 ? `${alert} alert` : `${warn} warn`} ); -} \ No newline at end of file +} diff --git a/src/app/dashboard/fuel/page.tsx b/src/app/dashboard/fuel/page.tsx index 494652d..409dc47 100644 --- a/src/app/dashboard/fuel/page.tsx +++ b/src/app/dashboard/fuel/page.tsx @@ -94,6 +94,10 @@ export default async function FuelPage() { variancePct: variance, status, cost: log.litres * RATE_PER_LITRE, + latestConsumption: + "latest_consumption" in log + ? ((log as { latest_consumption?: number }).latest_consumption ?? null) + : null, }; }); @@ -247,4 +251,4 @@ function computeVariance( } return { variance: null, status: "n/a", limitTarget: limit.target, limitUnit: limit.unit }; -} \ No newline at end of file +} diff --git a/src/components/import-fuel-logs-button.tsx b/src/components/import-fuel-logs-button.tsx index 42795d6..ba66c08 100644 --- a/src/components/import-fuel-logs-button.tsx +++ b/src/components/import-fuel-logs-button.tsx @@ -23,11 +23,20 @@ interface ImportResult { errors: { row: number; reason: string }[]; } +// Column expectations for the Aug 2026 pump-sheet shape (primary). +// Legacy CSV with vehicle_description still works — the server route +// falls back to description-matching if no Vehicle QR column exists. const SAMPLE_HEADERS = [ - "vehicle_description", "date_logged", + "vehicle_qr", + "vehicle", "litres", "odo_reading", + "current_site", +]; + +const SAMPLE_OPTIONAL = [ + "latest_consumption", ]; export function ImportFuelLogsButton() { @@ -103,8 +112,8 @@ export function ImportFuelLogsButton() { Import fuel logs - Upload a CSV — each row creates one fuel-log entry linked to the - source file for audit. + Upload an Excel (.xlsx) or CSV file - each row creates one fuel-log + entry linked to the source file for audit. @@ -125,8 +134,21 @@ export function ImportFuelLogsButton() { ))}

- Optional: project_code — - falls back to the vehicle's project. + Optional:{" "} + {SAMPLE_OPTIONAL.map((h, i) => ( + + {i > 0 ? ", " : ""} + {h} + + ))}{" "} + - latest_consumption is persisted verbatim (empty cells stay + blank). Vehicles are matched by QR first; the route falls + back to description-matching for legacy CSVs that lack a + Vehicle QR column. +

+

+ Dates: DD/MM/YYYY (e.g. 24/08/2026). + Legacy YYYY-MM-DD also accepted.

@@ -135,7 +157,7 @@ export function ImportFuelLogsButton() { @@ -144,7 +166,7 @@ export function ImportFuelLogsButton() {

{file.name}

- {(file.size / 1024).toFixed(1)} KB ·{" "} + {(file.size / 1024).toFixed(1)} KB -{" "} {format(new Date(file.lastModified), "yyyy-MM-dd HH:mm")}