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")}