From 2204989a3c25c34874fbe3eb335b0e8b1b21866b Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 28 Aug 2026 12:26:35 +0200 Subject: [PATCH] docs(plan): plant hours weekly log implementation plan Bite-sized TDD task decomposition covering 13 tasks: 1. PocketBase schema (vehicles.unit_type + plant_hours_log) 2. Type updates (UnitType, PlantHoursLogRecord, BatchType) 3. Shared import-parse helpers + parseTime 4. Per-row validator + derived fields 5. Template generator + seed script 6. GET /api/plant-hours/template 7. Exception-report CSV builder + route 8. POST /api/plant-hours/import 9. DELETE /api/plant-hours/[id] 10. PATCH /api/vehicles/[id] accepts unit_type 11. ImportPlantHoursButton component 12. /dashboard/plant page extension 13. End-to-end smoke test Each task has files, failing tests, implementation code, run commands, and commit step. Follows the approved design spec. --- .../2026-08-28-plant-hours-weekly-log.md | 2889 +++++++++++++++++ 1 file changed, 2889 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-plant-hours-weekly-log.md diff --git a/docs/superpowers/plans/2026-08-28-plant-hours-weekly-log.md b/docs/superpowers/plans/2026-08-28-plant-hours-weekly-log.md new file mode 100644 index 0000000..5d7abb4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-plant-hours-weekly-log.md @@ -0,0 +1,2889 @@ +# Plant Hours Weekly Log Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add weekly plant-equipment hours import against the existing `/dashboard/plant` page, with a downloadable exception-report fix-and-retry loop. + +**Architecture:** New `plant_hours_log` PocketBase collection + one new field (`unit_type`) on `vehicles`. One import route (lenient — per-row errors, never whole-batch fail), one template-download route that emits an `.xlsx` workbook via SheetJS, one exception-report route that emits a CSV of failed rows, one DELETE route. UI extends the existing `/dashboard/plant` page (no new sidebar entry). Mismatches between row shape and `vehicle.unit_type` are surfaced in an "Attention Required" panel and excluded from totals. + +**Tech Stack:** Next.js 15 (App Router), PocketBase (`pbAdmin()`), SheetJS (`xlsx`), Radix UI, Vitest, Tailwind. + +--- + +## File structure + +### New files + +| File | Responsibility | +|------|----------------| +| `src/lib/import-parse.ts` | Shared parsing helpers (`resolveColumn`, `parseDate`, `toNumber`, `parseTime`) — extracted from `fuel-import-parse.ts` + a new `parseTime`. | +| `src/lib/plant-hours-schema.ts` | Per-row validator + derived-field helpers (`computePlantHoursFields`, `validatePlantHoursRow`). | +| `src/lib/plant-hours-template.ts` | Pure function `buildPlantHoursTemplateWorkbook(sampleVehicle?: VehicleRecord): Uint8Array`. | +| `src/lib/plant-hours-exception.ts` | Pure function `buildPlantHoursExceptionCsv(batch, errors): string` — shared between route + tests. | +| `src/app/api/plant-hours/import/route.ts` | `POST /api/plant-hours/import` — parse, validate, insert, return counts. | +| `src/app/api/plant-hours/template/route.ts` | `GET /api/plant-hours/template` — emits `.xlsx`. | +| `src/app/api/plant-hours/exception-report/[batchId]/route.ts` | `GET /api/plant-hours/exception-report/[batchId]` — emits CSV. | +| `src/app/api/plant-hours/[id]/route.ts` | `DELETE /api/plant-hours/[id]` — removes a row. | +| `src/components/import-plant-hours-button.tsx` | Dialog + drop zone + result panel + exception-report download. | +| `scripts/generate-plant-hours-template.ts` | One-shot script that writes `RAW_DOCS/Plant_Hours_Template_2.xlsx`. | +| `RAW_DOCS/Plant_Hours_Template_2.xlsx` | Seed template (matches `/api/plant-hours/template` output). | + +### Modified files + +| File | Change | +|------|--------| +| `scripts/pb-bootstrap-schema.py` | Add `vehicles.unit_type` field; add `plant_hours_log` collection; add `"plant_hours"` to `data_import_batches.batch_type` enum. | +| `src/lib/pb-types.ts` | Add `unit_type` to `VehicleRecord`; add `PlantHoursLogRecord`; add `"plant_hours"` to `BatchType`. | +| `src/lib/fuel-import-parse.ts` | Re-export the three shared helpers from `import-parse.ts` so existing fuel route + tests keep working. | +| `src/app/api/vehicles/[id]/route.ts` | Accept `unit_type` in the PATCH payload (validated as `"fuel" \| "plant"`). | +| `src/app/dashboard/plant/page.tsx` | Fetch `plant_hours_log` rows + vehicle list; pass to `PlantView`. | +| `src/app/dashboard/plant/plant-view.tsx` | Add topbar "Import plant hours" + "Download template" buttons; add Attention Required panel + Weekly log table. | + +### Test files + +| File | Tests for | +|------|-----------| +| `src/lib/import-parse.test.ts` | `parseTime` (new helper). | +| `src/lib/plant-hours-schema.test.ts` | Per-row validator + derived fields. | +| `src/lib/plant-hours-template.test.ts` | Template generator (column order, frozen row, sample row). | +| `src/lib/plant-hours-exception.test.ts` | Exception CSV builder. | +| `src/app/api/plant-hours/import/route.test.ts` | Import route — happy path + per-row error accumulation + duplicate detection + type-mismatch warning. | +| `src/app/api/plant-hours/[id]/route.test.ts` | DELETE route. | + +--- + +## Task 1: PocketBase schema migration + +**Files:** +- Modify: `scripts/pb-bootstrap-schema.py` + +- [ ] **Step 1: Add `unit_type` to the `vehicles` collection block** + +In `scripts/pb-bootstrap-schema.py`, find the `vehicles` collection definition (search for the comment that introduces it). Add `unit_type` to its `fields` list, positioned next to `cost_per_hour` for logical grouping: + +```python +{"name": "unit_type", "type": "select", "required": True, "options": ["fuel", "plant"], "default": "fuel"}, +``` + +- [ ] **Step 2: Add the `plant_hours_log` collection block** + +After the existing `fuel_logs` collection block, add a new block: + +```python +{ + "name": "plant_hours_log", + "fields": [ + {"name": "date_logged", "type": "date", "required": True}, + {"name": "vehicle_qr", "type": "text", "required": True}, + {"name": "vehicle", "type": "relation", "required": True, "options": {"collectionId": "", "cascadeDelete": False, "maxSelect": 1, "minSelect": 1}}, + {"name": "site", "type": "relation", "required": True, "options": {"collectionId": "", "cascadeDelete": False, "maxSelect": 1, "minSelect": 1}}, + {"name": "project_code", "type": "text", "required": True}, + {"name": "start_date", "type": "date", "required": True}, + {"name": "end_date", "type": "date", "required": True}, + {"name": "start_time", "type": "text", "required": False}, + {"name": "end_time", "type": "text", "required": False}, + {"name": "start_hours", "type": "number", "required": True}, + {"name": "end_hours", "type": "number", "required": True}, + {"name": "hours", "type": "number", "required": True}, + {"name": "notes", "type": "text", "required": False}, + {"name": "hour_meter_budget", "type": "number", "required": True}, + {"name": "cost_per_hour", "type": "number", "required": True}, + {"name": "value", "type": "number", "required": True}, + {"name": "over_budget", "type": "bool", "required": True}, + {"name": "hours_mismatch", "type": "bool", "required": True}, + {"name": "vehicle_unit_type_at_import", "type": "select", "required": True, "options": ["fuel", "plant"]}, + {"name": "batch_reference", "type": "relation", "required": False, "options": {"collectionId": "", "cascadeDelete": False, "maxSelect": 1, "minSelect": 0}}, + ], + "indexes": [ + "CREATE UNIQUE INDEX idx_plant_hours_log_qr_start ON plant_hours_log (vehicle_qr, start_date)", + ], +}, +``` + +(Note: the `` / `` / `` placeholders are filled in by the existing helper that resolves collection IDs from names — match the style of the `fuel_logs` block above.) + +- [ ] **Step 3: Add `"plant_hours"` to `data_import_batches.batch_type` enum** + +Find the `data_import_batches` collection definition. Update its `batch_type` field options to include `"plant_hours"`: + +```python +{"name": "batch_type", "type": "select", "required": True, "options": ["timesheets", "fuel_logs", "purchase_orders", "delivery_notes", "plant_hours"]}, +``` + +- [ ] **Step 4: Run the script against the live PocketBase (manual)** + +Run: + +```bash +python3 scripts/pb-bootstrap-schema.py +``` + +Expected: the script reports the new field, the new collection, and the new enum value were applied. Verify in the PocketBase admin UI at `https://pb.mega.infra.apphub.co.za` that `vehicles` now has a `unit_type` select, `plant_hours_log` exists, and `data_import_batches.batch_type` lists `plant_hours`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/pb-bootstrap-schema.py +git commit -m "feat(pb): vehicles.unit_type + plant_hours_log collection + +Adds the schema needed for the weekly plant-equipment hours log: +- vehicles gets a unit_type select (fuel|plant), defaults to fuel + for backward-compat with existing rows +- plant_hours_log collection with the 13 plant-hours fields plus + vehicle_unit_type_at_import snapshot and composite unique index + (vehicle_qr, start_date) +- data_import_batches.batch_type gains plant_hours" +``` + +--- + +## Task 2: Update `src/lib/pb-types.ts` + +**Files:** +- Modify: `src/lib/pb-types.ts:37-47` (VehicleRecord) +- Modify: `src/lib/pb-types.ts:64-68` (BatchType) +- Modify: `src/lib/pb-types.ts` (new PlantHoursLogRecord after FuelLogRecord) + +- [ ] **Step 1: Add `unit_type` to `VehicleRecord`** + +Replace: + +```typescript +export interface VehicleRecord extends RecordModel { + collectionName: "vehicles"; + project_code: string; + site: string; + description: string; + standard_work_hours: string | number; + fuel_limits: string; + qr_code_id: string; + cost_per_hour: number; + expand?: { site?: SiteRecord }; +} +``` + +with: + +```typescript +export type UnitType = "fuel" | "plant"; + +export interface VehicleRecord extends RecordModel { + collectionName: "vehicles"; + project_code: string; + site: string; + description: string; + standard_work_hours: string | number; + fuel_limits: string; + qr_code_id: string; + cost_per_hour: number; + unit_type: UnitType; + expand?: { site?: SiteRecord }; +} +``` + +- [ ] **Step 2: Add `"plant_hours"` to `BatchType`** + +Replace: + +```typescript +export type BatchType = + | "timesheets" + | "fuel_logs" + | "purchase_orders" + | "delivery_notes"; +``` + +with: + +```typescript +export type BatchType = + | "timesheets" + | "fuel_logs" + | "purchase_orders" + | "delivery_notes" + | "plant_hours"; +``` + +- [ ] **Step 3: Add `PlantHoursLogRecord` after `FuelLogRecord`** + +Find `export interface FuelLogRecord extends RecordModel {` and add a new interface immediately after the `FuelLogRecord` block closes: + +```typescript +export interface PlantHoursLogRecord extends RecordModel { + collectionName: "plant_hours_log"; + date_logged: string; + vehicle_qr: string; + vehicle: string; + site: string; + project_code: string; + start_date: string; + end_date: string; + start_time?: string; + end_time?: string; + start_hours: number; + end_hours: number; + hours: number; + notes?: string; + hour_meter_budget: number; + cost_per_hour: number; + value: number; + over_budget: boolean; + hours_mismatch: boolean; + vehicle_unit_type_at_import: UnitType; + batch_reference?: string; + expand?: { + vehicle?: VehicleRecord; + site?: SiteRecord; + batch_reference?: DataImportBatchRecord; + }; +} +``` + +- [ ] **Step 4: Verify tsc passes** + +Run: + +```bash +npx tsc --noEmit +``` + +Expected: no type errors. (Existing vehicle consumers don't read `unit_type`, so adding the required field will require them to handle it in their payload construction — see Task 11 for the `useInlineEdit` consumer.) + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/pb-types.ts +git commit -m "feat(types): VehicleRecord.unit_type + PlantHoursLogRecord + +Adds UnitType union (\"fuel\" | \"plant\"), the new field on VehicleRecord, +the new PlantHoursLogRecord, and \"plant_hours\" to BatchType." +``` + +--- + +## Task 3: Extract shared import-parse helpers + add `parseTime` + +**Files:** +- Create: `src/lib/import-parse.ts` +- Create: `src/lib/import-parse.test.ts` +- Modify: `src/lib/fuel-import-parse.ts` + +- [ ] **Step 1: Write the failing test for `parseTime`** + +Create `src/lib/import-parse.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { parseTime } from "./import-parse"; + +describe("parseTime", () => { + it("accepts HH:MM (24h)", () => { + expect(parseTime("06:00")).toBe("06:00"); + expect(parseTime("17:30")).toBe("17:30"); + expect(parseTime("23:59")).toBe("23:59"); + expect(parseTime("00:00")).toBe("00:00"); + }); + + it("trims whitespace", () => { + expect(parseTime(" 06:00 ")).toBe("06:00"); + }); + + it("normalises single-digit hour to two digits", () => { + expect(parseTime("6:00")).toBe("06:00"); + expect(parseTime("9:05")).toBe("09:05"); + }); + + it("returns null for empty / null / undefined", () => { + expect(parseTime("")).toBeNull(); + expect(parseTime(null)).toBeNull(); + expect(parseTime(undefined)).toBeNull(); + }); + + it("returns null for malformed times", () => { + expect(parseTime("25:00")).toBeNull(); // hour out of range + expect(parseTime("12:60")).toBeNull(); // minute out of range + expect(parseTime("12:5")).toBeNull(); // minute must be 2 digits + expect(parseTime("not a time")).toBeNull(); + expect(parseTime("12-00")).toBeNull(); // wrong separator + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/import-parse.test.ts` +Expected: FAIL — module `./import-parse` not found. + +- [ ] **Step 3: Create `src/lib/import-parse.ts` with shared helpers + `parseTime`** + +```typescript +/** + * Shared parsing helpers for the import routes (fuel-logs, plant-hours). + * Extracted from src/lib/fuel-import-parse.ts so that plant-hours can + * reuse the column-resolution + date-parsing logic and add a new + * parseTime helper without duplicating code. + * + * All four helpers are stateless and side-effect free. fuel-import-parse + * re-exports the three legacy helpers so existing consumers keep working. + */ + +/** Header aliases for the plant-hours import (13 logical columns). */ +export const PLANT_COL_ALIASES: Record = { + date_logged: ["date logged", "date_logged", "date"], + vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr_code_id"], + vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"], + start_date: ["start date", "start_date"], + end_date: ["end date", "end_date"], + start_time: ["start time", "start_time"], + end_time: ["end time", "end_time"], + start_hours: ["start hours", "start_hours"], + end_hours: ["end hours", "end_hours"], + hours: ["hours"], + notes: ["notes"], + hour_meter: ["hour meter", "hour_meter", "hour_meter_budget", "budget"], + cost_per_hour: ["cost / hr", "cost/hr", "cost_per_hour", "cost"], +}; + +/** Header aliases for the fuel-logs import (legacy, kept for the fuel route). */ +export const FUEL_COL_ALIASES: Record = { + date: ["date logged", "date_logged", "date"], + vehicle_qr: ["vehicle qr", "vehicle_qr", "qr", "qr code id", "qr_code_id"], + vehicle_desc: ["vehicle", "vehicle description", "vehicle_description", "description"], + odo: ["km's/ hours", "kms/ hours", "km/hours", "odo_reading", "odometer", "hour_meter", "hour meter"], + litres: ["litres pumped", "litres", "liters"], + site: ["current site", "site", "current_site"], + consumption: ["latest consumption", "latest_consumption", "consumption"], +}; + +/** Normalise a header for fuzzy matching. */ +export function normHeader(s: unknown): string { + return typeof s === "string" + ? s.replace(/\s+/g, " ").trim().toLowerCase() + : ""; +} + +/** + * Resolve the original header text for a logical column key. + * Returns the EXACT header string the spreadsheet used (so the row + * lookup uses the same key the source file provided), or null if no + * alias matched. + */ +export function resolveColumn( + headers: string[], + key: keyof typeof PLANT_COL_ALIASES | keyof typeof FUEL_COL_ALIASES, + aliasMap: Record = PLANT_COL_ALIASES as Record, +): string | null { + const target = aliasMap[key].map(normHeader); + for (const h of headers) { + if (target.includes(normHeader(h))) return h; + } + return null; +} + +/** + * Parse the date column into an ISO `YYYY-MM-DD` string. + * Primary: `DD/MM/YYYY`. Falls back to `YYYY-MM-DD` and Excel serials. + */ +export function parseDate(input: unknown): string | null { + if (input === null || input === undefined) return null; + if (typeof input === "number" && Number.isFinite(input) && input > 25569) { + const d = new Date(Math.round((input - 25569) * 86400 * 1000)); + if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); + } + const s = String(input).trim(); + if (!s) return null; + let m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(s); + if (m) { + const dd = Number(m[1]); + const mm = Number(m[2]); + const yyyy = Number(m[3]); + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { + const iso = new Date(Date.UTC(yyyy, mm - 1, dd)); + if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); + } + return null; + } + m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); + if (m) { + const iso = new Date( + Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])), + ); + if (!Number.isNaN(iso.getTime())) return iso.toISOString().slice(0, 10); + } + return null; +} + +/** Coerce a cell value to a finite number, or null if empty / non-numeric. */ +export function toNumber(input: unknown): number | null { + if (input === null || input === undefined) return null; + if (typeof input === "number") return Number.isFinite(input) ? input : null; + if (typeof input === "string") { + const trimmed = input.trim(); + if (!trimmed) return null; + const n = Number(trimmed); + return Number.isFinite(n) ? n : null; + } + return null; +} + +/** + * Parse a time cell into `HH:MM` (24h). Returns null on malformed input. + * Single-digit hours are normalised to two digits (`6:00` → `06:00`). + */ +export function parseTime(input: unknown): string | null { + if (input === null || input === undefined) return null; + const s = String(input).trim(); + if (!s) return null; + const m = /^(\d{1,2}):(\d{2})$/.exec(s); + if (!m) return null; + const hh = Number(m[1]); + const mm = Number(m[2]); + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return null; + return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/import-parse.test.ts` +Expected: 5 `describe` blocks pass. + +- [ ] **Step 5: Make `fuel-import-parse.ts` re-export the shared helpers** + +Replace `src/lib/fuel-import-parse.ts` content with a thin re-export shim so the existing fuel route + `fuel-import-parse.test.ts` keep working without change: + +```typescript +/** + * Legacy module — re-exports the three shared helpers from + * src/lib/import-parse.ts. The fuel-logs import route still imports + * `COL_ALIASES`, `normHeader`, `resolveColumn`, `parseDate`, `toNumber` + * from this path; new code should import from "./import-parse" directly. + */ +import { + normHeader, + resolveColumn, + parseDate, + toNumber, +} from "./import-parse"; +import { FUEL_COL_ALIASES } from "./import-parse"; + +export const COL_ALIASES = FUEL_COL_ALIASES; +export { normHeader, resolveColumn, parseDate, toNumber }; +``` + +- [ ] **Step 6: Run existing fuel-import-parse test to confirm nothing broke** + +Run: `npx vitest run src/lib/fuel-import-parse.test.ts` +Expected: PASS — all existing tests still green. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/import-parse.ts src/lib/import-parse.test.ts src/lib/fuel-import-parse.ts +git commit -m "refactor(imports): extract shared import-parse helpers + add parseTime + +Moves normHeader/resolveColumn/parseDate/toNumber out of fuel-import-parse +into a new src/lib/import-parse.ts so plant-hours can reuse them. Adds +parseTime (HH:MM, 24h, single-digit hour normalised). Adds the +PLANT_COL_ALIASES table for the plant-hours route. fuel-import-parse +becomes a thin re-export shim — the fuel-logs route and its existing +test file are unchanged." +``` + +--- + +## Task 4: Plant-hours schema validator + derived fields + +**Files:** +- Create: `src/lib/plant-hours-schema.ts` +- Create: `src/lib/plant-hours-schema.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/plant-hours-schema.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { + computePlantHoursFields, + validatePlantHoursRow, + type PlantHoursRowInput, +} from "./plant-hours-schema"; + +const baseVehicle = { + id: "veh-1", + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + unit_type: "plant" as const, + site: "site-1", + site_project_code: "P-001", +}; + +const validRow: PlantHoursRowInput = { + date_logged: "2026-08-28", + vehicle_qr: "QR-A", + vehicle_desc: "Generator X", + start_date: "2026-08-24", + end_date: "2026-08-28", + start_time: "06:00", + end_time: "17:00", + start_hours: 1200, + end_hours: 1231, + hours: 31, + notes: "", + hour_meter_budget: 40, + cost_per_hour: 185, +}; + +describe("computePlantHoursFields", () => { + it("computes hours from end_hours − start_hours", () => { + expect( + computePlantHoursFields({ ...validRow, hours: undefined }).hours, + ).toBe(31); + }); + it("computes value = hours * cost_per_hour rounded to 2dp", () => { + expect(computePlantHoursFields(validRow).value).toBe(5735); + // 31 * 185 = 5735 exactly + }); + it("flags over_budget when hours > hour_meter_budget", () => { + expect(computePlantHoursFields(validRow).over_budget).toBe(false); + expect( + computePlantHoursFields({ ...validRow, hour_meter_budget: 20 }) + .over_budget, + ).toBe(true); + }); + it("flags hours_mismatch when operator hours differ by > 0.05", () => { + expect(computePlantHoursFields(validRow).hours_mismatch).toBe(false); + expect( + computePlantHoursFields({ ...validRow, hours: 30 }).hours_mismatch, + ).toBe(true); + }); +}); + +describe("validatePlantHoursRow", () => { + it("returns no errors for a valid row + matching vehicle", () => { + const r = validatePlantHoursRow(validRow, baseVehicle, new Set()); + expect(r.errors).toEqual([]); + expect(r.warnings).toEqual([]); + }); + + it("rejects unknown QR", () => { + const r = validatePlantHoursRow( + { ...validRow, vehicle_qr: "QR-XYZ" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/unknown_vehicle_qr/); + }); + + it("rejects end_date < start_date", () => { + const r = validatePlantHoursRow( + { ...validRow, end_date: "2026-08-23" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/bad_date_range/); + }); + + it("rejects end_hours < start_hours", () => { + const r = validatePlantHoursRow( + { ...validRow, end_hours: 1199 }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/end_hours_lt_start_hours/); + }); + + it("rejects bad time format", () => { + const r = validatePlantHoursRow( + { ...validRow, start_time: "not a time" }, + baseVehicle, + new Set(), + ); + expect(r.errors[0]).toMatch(/bad_time_format/); + }); + + it("rejects when cell cost_per_hour blank AND vehicle has none", () => { + const r = validatePlantHoursRow( + { ...validRow, cost_per_hour: null }, + { ...baseVehicle, cost_per_hour: 0 }, + new Set(), + ); + expect(r.errors[0]).toMatch(/missing_cost_per_hour/); + }); + + it("falls back to vehicle.cost_per_hour when cell blank", () => { + const r = validatePlantHoursRow( + { ...validRow, cost_per_hour: null }, + baseVehicle, + new Set(), + ); + expect(r.errors).toEqual([]); + expect(r.computed.cost_per_hour).toBe(185); + }); + + it("rejects duplicate week", () => { + const r = validatePlantHoursRow( + validRow, + baseVehicle, + new Set(["QR-A|2026-08-24"]), + ); + expect(r.errors[0]).toMatch(/duplicate_week/); + }); + + it("warns (does not error) on plant row for fuel-typed vehicle", () => { + const r = validatePlantHoursRow( + validRow, + { ...baseVehicle, unit_type: "fuel" }, + new Set(), + ); + expect(r.errors).toEqual([]); + expect(r.warnings[0]).toMatch(/plant_row_for_fuel_vehicle/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/plant-hours-schema.test.ts` +Expected: FAIL — module `./plant-hours-schema` not found. + +- [ ] **Step 3: Create `src/lib/plant-hours-schema.ts`** + +```typescript +/** + * Plant-hours row validator + derived-field helpers. + * + * The import route calls validatePlantHoursRow() once per row. + * It returns errors (which block insertion) and warnings (which + * surface in the Attention Required panel but don't block). + * + * Pure — no I/O, no PocketBase calls. The route owns the vehicle + * cache and the duplicate-set; this file just consumes them. + */ +import type { UnitType } from "./pb-types"; + +export interface PlantHoursRowInput { + date_logged: string; + vehicle_qr: string; + vehicle_desc: string; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; + start_hours: number; + end_hours: number; + hours: number | null; + notes: string; + hour_meter_budget: number; + cost_per_hour: number | null; +} + +export interface PlantHoursVehicleLookup { + id: string; + qr_code_id: string; + description: string; + cost_per_hour: number; + unit_type: UnitType; + site: string; + site_project_code: string; +} + +export interface PlantHoursComputedFields { + hours: number; + cost_per_hour: number; + value: number; + over_budget: boolean; + hours_mismatch: boolean; + vehicle_unit_type_at_import: UnitType; +} + +export interface PlantHoursValidationResult { + errors: string[]; + warnings: string[]; + computed: PlantHoursComputedFields; +} + +const HOURS_MISMATCH_TOLERANCE = 0.05; + +/** Compute derived fields. Assumes the row passed basic type parsing. */ +export function computePlantHoursFields( + row: PlantHoursRowInput, + vehicleUnitType: UnitType = "plant", +): PlantHoursComputedFields { + const hours = row.end_hours - row.start_hours; + const operatorHours = row.hours ?? hours; + const costPerHour = row.cost_per_hour ?? 0; + const value = Math.round(hours * costPerHour * 100) / 100; + return { + hours, + cost_per_hour: costPerHour, + value, + over_budget: hours > row.hour_meter_budget, + hours_mismatch: + Math.abs(operatorHours - hours) > HOURS_MISMATCH_TOLERANCE, + vehicle_unit_type_at_import: vehicleUnitType, + }; +} + +/** + * Validate one parsed row against the vehicle lookup and the + * set of (vehicle_qr, start_date) keys already imported in this batch. + */ +export function validatePlantHoursRow( + row: PlantHoursRowInput, + vehicle: PlantHoursVehicleLookup | undefined, + duplicatesThisBatch: Set, +): PlantHoursValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!vehicle) { + errors.push(`unknown_vehicle_qr: ${row.vehicle_qr || "(empty)"}`); + return { + errors, + warnings, + computed: computePlantHoursFields(row), + }; + } + + if (row.end_date < row.start_date) { + errors.push("bad_date_range: end_date is before start_date"); + } + if (row.end_hours < row.start_hours) { + errors.push( + `end_hours_lt_start_hours: ${row.end_hours} < ${row.start_hours}`, + ); + } + if ( + row.start_time !== null && + !/^([01]\d|2[0-3]):[0-5]\d$/.test(row.start_time) + ) { + errors.push(`bad_time_format: start_time "${row.start_time}"`); + } + if ( + row.end_time !== null && + !/^([01]\d|2[0-3]):[0-5]\d$/.test(row.end_time) + ) { + errors.push(`bad_time_format: end_time "${row.end_time}"`); + } + + // Cost-per-hour resolution. + let resolvedCostPerHour: number; + if (row.cost_per_hour !== null && row.cost_per_hour > 0) { + resolvedCostPerHour = row.cost_per_hour; + } else if (vehicle.cost_per_hour > 0) { + resolvedCostPerHour = vehicle.cost_per_hour; + } else { + errors.push("missing_cost_per_hour: cell empty and vehicle has no rate"); + resolvedCostPerHour = 0; + } + + const dupKey = `${vehicle.qr_code_id}|${row.start_date}`; + if (duplicatesThisBatch.has(dupKey)) { + errors.push( + `duplicate_week: ${vehicle.qr_code_id} already logged for ${row.start_date}`, + ); + } + + // Type-mismatch warning (lenient — doesn't block insertion). + if (vehicle.unit_type === "fuel") { + warnings.push( + `plant_row_for_fuel_vehicle: ${vehicle.qr_code_id} is typed as fuel; row was imported as audit-only data`, + ); + } + + const rowForCompute: PlantHoursRowInput = { + ...row, + cost_per_hour: resolvedCostPerHour, + }; + const computed = computePlantHoursFields(rowForCompute, vehicle.unit_type); + + return { errors, warnings, computed }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/plant-hours-schema.test.ts` +Expected: 13 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/lib/plant-hours-schema.ts src/lib/plant-hours-schema.test.ts +git commit -m "feat(plant-hours): row validator + derived field helpers + +Pure validator covering all the error/warning cases from the spec: +unknown QR, bad date range, end_hours < start_hours, bad time format, +missing cost (cell + vehicle), duplicate week, and the type-mismatch +warning. computePlantHoursFields() derives hours/value/over_budget/ +hours_mismatch and the vehicle_unit_type_at_import snapshot." +``` + +--- + +## Task 5: Template generator (pure function + seed script) + +**Files:** +- Create: `src/lib/plant-hours-template.ts` +- Create: `src/lib/plant-hours-template.test.ts` +- Create: `scripts/generate-plant-hours-template.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/plant-hours-template.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import * as XLSX from "xlsx"; +import { buildPlantHoursTemplateWorkbook } from "./plant-hours-template"; + +function readWorkbook(bytes: Uint8Array): { + headers: string[]; + sampleRow: string[]; + frozen: boolean; +} { + const wb = XLSX.read(bytes, { type: "buffer" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const aoa = XLSX.utils.sheet_to_json(ws, { header: 1 }); + return { + headers: aoa[0] ?? [], + sampleRow: aoa[1] ?? [], + // XLSX.write doesn't expose panes; check the workbook XML for . + frozen: JSON.stringify(wb).includes("frozen"), + }; +} + +describe("buildPlantHoursTemplateWorkbook", () => { + it("emits the 13 expected headers in order", () => { + const bytes = buildPlantHoursTemplateWorkbook(); + const { headers } = readWorkbook(bytes); + expect(headers).toEqual([ + "Date Logged", + "Vehicle QR", + "Vehicle", + "Start Date", + "End Date", + "Start Time", + "End Time", + "Start Hours", + "End Hours", + "Hours", + "Notes", + "Hour Meter", + "Cost / HR", + ]); + }); + + it("emits a sample row using the supplied vehicle QR", () => { + const bytes = buildPlantHoursTemplateWorkbook({ + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + }); + const { sampleRow } = readWorkbook(bytes); + expect(sampleRow[1]).toBe("QR-A"); + expect(sampleRow[2]).toBe("Generator X"); + expect(sampleRow[12]).toBe(185); + }); + + it("uses a placeholder QR when no vehicle supplied", () => { + const { sampleRow } = readWorkbook(buildPlantHoursTemplateWorkbook()); + expect(sampleRow[1]).toBe("QR-A"); + }); + + it("freezes the header row", () => { + const bytes = buildPlantHoursTemplateWorkbook(); + expect(readWorkbook(bytes).frozen).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/plant-hours-template.test.ts` +Expected: FAIL — module `./plant-hours-template` not found. + +- [ ] **Step 3: Create `src/lib/plant-hours-template.ts`** + +```typescript +/** + * Pure workbook builder for Plant_Hours_Template_2.xlsx. The route + * (`GET /api/plant-hours/template`) and the seed script both call + * this — keep the layout canonical here. + */ +import * as XLSX from "xlsx"; + +export interface TemplateSampleVehicle { + qr_code_id: string; + description: string; + cost_per_hour: number; +} + +const HEADERS = [ + "Date Logged", + "Vehicle QR", + "Vehicle", + "Start Date", + "End Date", + "Start Time", + "End Time", + "Start Hours", + "End Hours", + "Hours", + "Notes", + "Hour Meter", + "Cost / HR", +]; + +/** + * Build the canonical Plant_Hours_Template_2.xlsx workbook as a + * Uint8Array ready for `new Response(bytes, …)`. + * + * - Header row is frozen. + * - Row 2 is a sample row using the supplied vehicle (or placeholder). + * - Date/time/number columns are typed so Excel picks the right input + * format. + */ +export function buildPlantHoursTemplateWorkbook( + sample: TemplateSampleVehicle = { + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + }, +): Uint8Array { + const today = new Date(); + // Next Monday as ISO YYYY-MM-DD + const nextMonday = new Date(today); + nextMonday.setUTCDate( + today.getUTCDate() + ((1 + 7 - today.getUTCDay()) % 7 || 7), + ); + const nextMondayIso = nextMonday.toISOString().slice(0, 10); + const nextFriday = new Date(nextMonday); + nextFriday.setUTCDate(nextMonday.getUTCDate() + 4); + const nextFridayIso = nextFriday.toISOString().slice(0, 10); + + const aoa: unknown[][] = [ + HEADERS, + [ + today.toISOString().slice(0, 10), // Date Logged + sample.qr_code_id, + sample.description, + nextMondayIso, + nextFridayIso, + "06:00", + "17:00", + 1200, + 1231, + 31, + "Sample row — replace with your values", + 40, + sample.cost_per_hour, + ], + ]; + + const ws = XLSX.utils.aoa_to_sheet(aoa); + ws["!cols"] = HEADERS.map((h) => ({ wch: h === "Notes" ? 30 : 14 })); + ws["!freeze"] = { xSplit: 0, ySplit: 1 }; + + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Plant Hours"); + return XLSX.write(wb, { bookType: "xlsx", type: "buffer" }) as Uint8Array; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/plant-hours-template.test.ts` +Expected: 4 tests pass. + +- [ ] **Step 5: Create the seed script** + +Create `scripts/generate-plant-hours-template.ts`: + +```typescript +/** + * One-shot script: writes the canonical Plant_Hours_Template_2.xlsx + * to RAW_DOCS/. Run when the template layout changes — keep the + * seed file in sync with src/lib/plant-hours-template.ts. + * + * npx tsx scripts/generate-plant-hours-template.ts + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { buildPlantHoursTemplateWorkbook } from "../src/lib/plant-hours-template"; + +const out = path.resolve(__dirname, "../RAW_DOCS/Plant_Hours_Template_2.xlsx"); +const bytes = buildPlantHoursTemplateWorkbook(); +fs.writeFileSync(out, bytes); +console.log(`Wrote ${out} (${bytes.byteLength} bytes)`); +``` + +- [ ] **Step 6: Run the seed script to populate `RAW_DOCS/`** + +Run: `npx tsx scripts/generate-plant-hours-template.ts` +Expected: prints `Wrote /…/RAW_DOCS/Plant_Hours_Template_2.xlsx (NNNN bytes)`. + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/plant-hours-template.ts src/lib/plant-hours-template.test.ts scripts/generate-plant-hours-template.ts RAW_DOCS/Plant_Hours_Template_2.xlsx +git commit -m "feat(plant-hours): template workbook generator + seed file + +buildPlantHoursTemplateWorkbook() emits the canonical 13-column xlsx +with a frozen header row, typed cells (date/time/number), a sample +row using the supplied vehicle, and a Notes column widened for +readability. Used by both /api/plant-hours/template and the seed +script that writes RAW_DOCS/Plant_Hours_Template_2.xlsx." +``` + +--- + +## Task 6: Template download route + +**Files:** +- Create: `src/app/api/plant-hours/template/route.ts` + +- [ ] **Step 1: Create the route** + +```typescript +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import { buildPlantHoursTemplateWorkbook } from "@/lib/plant-hours-template"; + +/** + * GET /api/plant-hours/template + * + * Emits the canonical Plant_Hours_Template_2.xlsx workbook. The + * sample row's vehicle QR / description / cost-per-hour are pulled + * from the first vehicle in PocketBase (plant or fuel — doesn't + * matter, the template just needs plausible real data). + */ +export async function GET() { + const start = Date.now(); + try { + const pb = await pbAdmin(); + const first = await pb + .collection("vehicles") + .getList(1, 1, { sort: "description" }) + .then((r) => r.items[0]) + .catch(() => null); + + const sample = first + ? { + qr_code_id: first.qr_code_id || "QR-A", + description: first.description || "Generator X", + cost_per_hour: first.cost_per_hour || 185, + } + : { qr_code_id: "QR-A", description: "Generator X", cost_per_hour: 185 }; + + const bytes = buildPlantHoursTemplateWorkbook(sample); + console.log( + `[plant-hours-template] size=${bytes.byteLength} duration_ms=${Date.now() - start}`, + ); + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": + 'attachment; filename="Plant_Hours_Template_2.xlsx"', + "Cache-Control": "no-store", + }, + }); + } catch (err) { + console.error(`[plant-hours-template] error:`, err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "template_failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 2: Manual smoke test against the running app** + +Start the dev server (`npm run dev`), then: + +```bash +curl -sS -o /tmp/ph.xlsx -w "%{http_code}\n" http://localhost:3000/api/plant-hours/template +file /tmp/ph.xlsx +``` + +Expected: `200` and `Microsoft Excel 2007+` (or similar) from `file`. Open in Excel and confirm 13 columns + frozen header + sample row. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/plant-hours/template/route.ts +git commit -m "feat(plant-hours): GET /api/plant-hours/template route + +Emits the canonical xlsx with a sample row derived from the first +vehicle in PocketBase. Falls back to a hardcoded sample if the +vehicles collection is empty." +``` + +--- + +## Task 7: Exception-report CSV builder + route + +**Files:** +- Create: `src/lib/plant-hours-exception.ts` +- Create: `src/lib/plant-hours-exception.test.ts` +- Create: `src/app/api/plant-hours/exception-report/[batchId]/route.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/lib/plant-hours-exception.test.ts`: + +```typescript +import { describe, it, expect } from "vitest"; +import { buildPlantHoursExceptionCsv } from "./plant-hours-exception"; + +describe("buildPlantHoursExceptionCsv", () => { + it("emits the 15 expected columns", () => { + const csv = buildPlantHoursExceptionCsv([ + { + row: 4, + originalRow: { + vehicle_qr: "QR-XYZ", + vehicle: "Generator X", + start_date: "2026-08-24", + end_date: "2026-08-28", + start_time: "06:00", + end_time: "17:00", + start_hours: 1200, + end_hours: 1231, + hours: 31, + notes: "", + hour_meter_budget: 40, + cost_per_hour: 185, + }, + reason: "unknown_vehicle_qr: QR-XYZ", + }, + ]); + const lines = csv.split("\n"); + expect(lines[0]).toBe( + "original_row,vehicle_qr,vehicle,start_date,end_date,start_time,end_time,start_hours,end_hours,hours,notes,hour_meter_budget,cost_per_hour,reason,operator_note", + ); + expect(lines[1]).toContain("QR-XYZ"); + expect(lines[1]).toContain("unknown_vehicle_qr"); + expect(lines[1]).toMatch(/,,"$/); // empty operator_note at end + }); + + it("quotes cells that contain commas / quotes / newlines", () => { + const csv = buildPlantHoursExceptionCsv([ + { + row: 1, + originalRow: { + vehicle_qr: "QR-A", + vehicle: 'Generator "X", big', + start_date: "2026-08-24", + end_date: "2026-08-28", + start_time: null, + end_time: null, + start_hours: 0, + end_hours: 0, + hours: 0, + notes: "line1\nline2", + hour_meter_budget: 0, + cost_per_hour: 0, + }, + reason: "bad_date_range", + }, + ]); + expect(csv).toContain('"Generator ""X"", big"'); + expect(csv).toContain('"line1\nline2"'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/lib/plant-hours-exception.test.ts` +Expected: FAIL — module `./plant-hours-exception` not found. + +- [ ] **Step 3: Create `src/lib/plant-hours-exception.ts`** + +```typescript +/** + * Pure CSV builder for the plant-hours exception report. Mirrors the + * 13 template columns + reason + operator_note so the operator can + * edit the CSV in place and re-upload it. + */ + +export interface ExceptionRow { + row: number; + originalRow: { + vehicle_qr: string; + vehicle: string; + start_date: string; + end_date: string; + start_time: string | null; + end_time: string | null; + start_hours: number; + end_hours: number; + hours: number | null; + notes: string; + hour_meter_budget: number; + cost_per_hour: number | null; + }; + reason: string; +} + +const HEADER = [ + "original_row", + "vehicle_qr", + "vehicle", + "start_date", + "end_date", + "start_time", + "end_time", + "start_hours", + "end_hours", + "hours", + "notes", + "hour_meter_budget", + "cost_per_hour", + "reason", + "operator_note", +]; + +/** RFC 4180 — quote cells containing commas, quotes, or newlines. */ +function csvCell(v: string | number | null | undefined): string { + if (v === null || v === undefined) return ""; + const s = String(v); + if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +export function buildPlantHoursExceptionCsv(rows: ExceptionRow[]): string { + const lines: string[] = [HEADER.join(",")]; + for (const r of rows) { + const o = r.originalRow; + lines.push( + [ + csvCell(r.row), + csvCell(o.vehicle_qr), + csvCell(o.vehicle), + csvCell(o.start_date), + csvCell(o.end_date), + csvCell(o.start_time), + csvCell(o.end_time), + csvCell(o.start_hours), + csvCell(o.end_hours), + csvCell(o.hours), + csvCell(o.notes), + csvCell(o.hour_meter_budget), + csvCell(o.cost_per_hour), + csvCell(r.reason), + "", // operator_note — empty by default + ].join(","), + ); + } + return lines.join("\n") + "\n"; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/lib/plant-hours-exception.test.ts` +Expected: 2 tests pass. + +- [ ] **Step 5: Create the exception-report route** + +Create `src/app/api/plant-hours/exception-report/[batchId]/route.ts`: + +```typescript +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import { buildPlantHoursExceptionCsv, type ExceptionRow } from "@/lib/plant-hours-exception"; + +/** + * GET /api/plant-hours/exception-report/[batchId] + * + * Reconstructs the exception CSV from the named batch. We don't + * persist the per-row original data — we re-fetch the source file + * from data_import_batches and re-parse the same rows that failed + * in the original import. The matching is by `original_row`. + */ +export async function GET( + _request: Request, + { params }: { params: Promise<{ batchId: string }> }, +) { + const { batchId } = await params; + const start = Date.now(); + + try { + const pb = await pbAdmin(); + const batch = await pb.collection("data_import_batches").getOne(batchId); + const fileToken = batch.original_file; + + // Fetch the source file as a buffer so SheetJS can re-parse it. + const fileUrl = pb.files.getURL(batch, fileToken); + const res = await fetch(fileUrl); + if (!res.ok) { + return NextResponse.json( + { error: "source_file_unavailable" }, + { status: 500 }, + ); + } + const buffer = new Uint8Array(await res.arrayBuffer()); + + // Look up all plant_hours_log rows from this batch that + // successfully imported — their absence (vs. source rows) tells + // us which rows were rejected. We then surface every row in the + // source as an exception (operators can ignore ones that + // imported fine; we don't have per-row provenance, only the + // batch-level file). + // + // Simpler v1: surface every source row that does NOT have a + // matching plant_hours_log row in this batch. + const imported = await pb.collection("plant_hours_log").getFullList({ + filter: `batch_reference = "${batchId}"`, + perPage: 5000, + }); + + // Build the exception list by re-parsing the source file and + // matching against imported rows by (vehicle_qr, start_date). + const XLSX = await import("xlsx"); + const wb = XLSX.read(buffer, { type: "buffer" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const aoa = XLSX.utils.sheet_to_json(ws, { + header: 1, + raw: true, + }); + const headers = (aoa[0] as string[]) ?? []; + const dataRows = aoa.slice(1) as unknown[][]; + + const { resolveColumn } = await import("@/lib/import-parse"); + const qrCol = resolveColumn(headers, "vehicle_qr"); + const startCol = resolveColumn(headers, "start_date"); + + const importedKeys = new Set( + imported.map((r) => `${r.vehicle_qr}|${r.start_date}`), + ); + + const exceptionRows: ExceptionRow[] = []; + dataRows.forEach((row, idx) => { + if (!row || row.length === 0) return; + const qr = qrCol ? String(row[headers.indexOf(qrCol)] ?? "").trim() : ""; + const start = startCol + ? String(row[headers.indexOf(startCol)] ?? "").trim() + : ""; + const key = `${qr}|${start}`; + if (importedKeys.has(key)) return; // imported successfully + + // We don't have the row's original `reason` persisted — + // emit a generic "not_imported" reason; the operator can + // cross-reference against the import result they saw. + exceptionRows.push({ + row: idx + 2, // 1-based, header is row 1 + originalRow: { + vehicle_qr: qr, + vehicle: "", + start_date: start, + end_date: "", + start_time: null, + end_time: null, + start_hours: 0, + end_hours: 0, + hours: null, + notes: "", + hour_meter_budget: 0, + cost_per_hour: null, + }, + reason: "not_imported: see import result panel", + }); + }); + + const csv = buildPlantHoursExceptionCsv(exceptionRows); + console.log( + `[plant-hours-exception] batchId=${batchId} rows=${exceptionRows.length} duration_ms=${Date.now() - start}`, + ); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="plant-hours-exceptions-${batchId}.csv"`, + "Cache-Control": "no-store", + }, + }); + } catch (err) { + console.error(`[plant-hours-exception] error:`, err); + return NextResponse.json( + { error: err instanceof Error ? err.message : "exception_failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/lib/plant-hours-exception.ts src/lib/plant-hours-exception.test.ts src/app/api/plant-hours/exception-report/ +git commit -m "feat(plant-hours): exception-report CSV builder + route + +buildPlantHoursExceptionCsv() emits a 15-column CSV with the 13 +template columns + reason + operator_note, RFC-4180-quoted. The +GET /api/plant-hours/exception-report/[batchId] route re-parses the +source file from data_import_batches.original_file, diffs against +the imported plant_hours_log rows for that batch, and emits the CSV +of rows that did NOT import." +``` + +--- + +## Task 8: Import route (POST /api/plant-hours/import) + +**Files:** +- Create: `src/app/api/plant-hours/import/route.ts` +- Create: `src/app/api/plant-hours/import/route.test.ts` + +- [ ] **Step 1: Write the failing route test** + +Create `src/app/api/plant-hours/import/route.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "./route"; + +// Mock PocketBase so the route never touches the live DB. +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name === "vehicles") { + return { + getFullList: vi.fn(async () => [ + { + id: "veh-1", + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + unit_type: "plant", + site: "site-1", + expand: { site: { id: "site-1", project_code: "P-001" } }, + }, + ]), + }; + } + if (name === "data_import_batches") { + return { + create: vi.fn(async () => ({ id: "batch-1" })), + }; + } + if (name === "plant_hours_log") { + return { + getList: vi.fn(async () => ({ items: [] })), + create: vi.fn(async (payload) => ({ id: `row-${Math.random()}`, ...payload })), + }; + } + throw new Error(`unexpected collection: ${name}`); + }, + })), +})); + +function csvFile(content: string): File { + return new File([content], "plant.csv", { type: "text/csv" }); +} + +async function importWith(form: FormData): Promise { + return POST({ formData: async () => form } as unknown as Request); +} + +describe("POST /api/plant-hours/import", () => { + beforeEach(() => vi.clearAllMocks()); + + it("rejects non-multipart / missing file", async () => { + const res = await POST({ formData: async () => new FormData() } as unknown as Request); + expect(res.status).toBe(400); + }); + + it("imports one valid row and returns counts", async () => { + const form = new FormData(); + form.append( + "file", + csvFile( + [ + "Date Logged,Vehicle QR,Vehicle,Start Date,End Date,Start Time,End Time,Start Hours,End Hours,Hours,Notes,Hour Meter,Cost / HR", + "2026-08-28,QR-A,Generator X,2026-08-24,2026-08-28,06:00,17:00,1200,1231,31,,40,185", + ].join("\n"), + ), + ); + const res = await importWith(form); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.totalRows).toBe(1); + expect(json.imported).toBe(1); + expect(json.errors).toEqual([]); + }); + + it("returns per-row error for unknown QR but still processes other rows", async () => { + const form = new FormData(); + form.append( + "file", + csvFile( + [ + "Date Logged,Vehicle QR,Vehicle,Start Date,End Date,Start Hours,End Hours,Hours,Hour Meter,Cost / HR", + "2026-08-28,QR-A,Generator X,2026-08-24,2026-08-28,1200,1231,31,40,185", + "2026-08-28,QR-XYZ,Mystery,2026-08-24,2026-08-28,0,0,0,40,0", + ].join("\n"), + ), + ); + const res = await importWith(form); + const json = await res.json(); + expect(json.imported).toBe(1); + expect(json.errors.length).toBe(1); + expect(json.errors[0].reason).toMatch(/unknown_vehicle_qr/); + }); + + it("warns but still imports when row is plant-shaped but vehicle is fuel-typed", async () => { + // Override the mock for this test. + const { pbAdmin } = await import("@/lib/pb"); + (pbAdmin as unknown as ReturnType).mockImplementationOnce( + async () => ({ + collection: (name: string) => { + if (name === "vehicles") { + return { + getFullList: vi.fn(async () => [ + { + id: "veh-1", + qr_code_id: "QR-A", + description: "Generator X", + cost_per_hour: 185, + unit_type: "fuel", + site: "site-1", + expand: { site: { id: "site-1", project_code: "P-001" } }, + }, + ]), + }; + } + if (name === "data_import_batches") { + return { create: vi.fn(async () => ({ id: "batch-1" })) }; + } + if (name === "plant_hours_log") { + return { + getList: vi.fn(async () => ({ items: [] })), + create: vi.fn(async (p) => ({ id: "row-1", ...p })), + }; + } + throw new Error("nope"); + }, + }), + ); + const form = new FormData(); + form.append( + "file", + csvFile( + [ + "Date Logged,Vehicle QR,Vehicle,Start Date,End Date,Start Hours,End Hours,Hours,Hour Meter,Cost / HR", + "2026-08-28,QR-A,Generator X,2026-08-24,2026-08-28,1200,1231,31,40,185", + ].join("\n"), + ), + ); + const res = await importWith(form); + const json = await res.json(); + expect(json.imported).toBe(1); + expect(json.warnings[0]).toMatch(/plant_row_for_fuel_vehicle/); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/plant-hours/import/route.test.ts` +Expected: FAIL — module `./route` not found. + +- [ ] **Step 3: Create `src/app/api/plant-hours/import/route.ts`** + +```typescript +import { NextResponse } from "next/server"; +import * as XLSX from "xlsx"; +import { pbAdmin } from "@/lib/pb"; +import { + normHeader, + resolveColumn, + parseDate, + parseTime, + toNumber, + PLANT_COL_ALIASES, +} from "@/lib/import-parse"; +import { + validatePlantHoursRow, + type PlantHoursRowInput, + type PlantHoursVehicleLookup, +} from "@/lib/plant-hours-schema"; +import type { UnitType } from "@/lib/pb-types"; + +const MAX_BYTES = 10 * 1024 * 1024; // 10 MB +const VEHICLES_CACHE_PAGE = 5000; + +interface ImportError { + row: number; + reason: string; +} +interface ImportWarning { + row: number; + reason: string; +} + +function formatPbError(err: unknown): string { + const e = err as { message?: string; data?: Record }; + const head = e?.message ?? "Insert failed"; + if (e?.data && typeof e.data === "object") { + const parts = Object.entries(e.data) + .map(([f, i]) => `${f}: ${i?.message ?? "invalid"}`) + .join("; "); + if (parts) return `${head} (${parts})`; + } + return head; +} + +function buildVehicleLookup( + rows: Array<{ + id: string; + qr_code_id: string; + description: string; + cost_per_hour: number; + unit_type: UnitType; + site: string; + expand?: { site?: { id: string; project_code: string } }; + }>, +): Map { + const map = new Map(); + for (const v of rows) { + map.set(v.qr_code_id, { + id: v.id, + qr_code_id: v.qr_code_id, + description: v.description, + cost_per_hour: v.cost_per_hour, + unit_type: v.unit_type, + site: v.site, + site_project_code: v.expand?.site?.project_code ?? "", + }); + } + return map; +} + +/** + * POST /api/plant-hours/import + * + * Accepts a single .xlsx or .csv file under the "file" form field. + * Lenient — per-row errors don't abort the rest of the batch. Returns + * { batchId, totalRows, imported, skipped, errors[], warnings[] }. + */ +export async function POST(request: Request) { + const start = Date.now(); + let form: FormData; + try { + form = await request.formData(); + } catch { + return NextResponse.json( + { error: "multipart/form-data required" }, + { status: 400 }, + ); + } + + const file = form.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "missing_file" }, { status: 400 }); + } + if (file.size === 0) { + return NextResponse.json({ error: "empty_file" }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "file_too_large" }, { status: 400 }); + } + + // Parse file via SheetJS. + const buffer = new Uint8Array(await file.arrayBuffer()); + let aoa: unknown[][]; + try { + const wb = XLSX.read(buffer, { type: "buffer" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + aoa = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true }); + } catch { + return NextResponse.json({ error: "parse_failed" }, { status: 400 }); + } + const headerRow = (aoa[0] as string[]) ?? []; + const dataRows = aoa.slice(1) as unknown[][]; + + // Resolve all column keys up-front (so we can fail loudly on a bad template). + const cols: Partial> = {}; + for (const key of Object.keys(PLANT_COL_ALIASES) as Array< + keyof typeof PLANT_COL_ALIASES + >) { + cols[key] = resolveColumn(headerRow, key); + } + const missing: string[] = []; + for (const required of [ + "vehicle_qr", + "vehicle_desc", + "start_date", + "end_date", + "start_hours", + "end_hours", + "hours", + "hour_meter", + "cost_per_hour", + ] as const) { + if (!cols[required]) missing.push(required); + } + if (missing.length) { + return NextResponse.json( + { error: "missing_columns", details: missing }, + { status: 400 }, + ); + } + + const colIndex = (key: keyof typeof PLANT_COL_ALIASES): number => + headerRow.indexOf(cols[key] as string); + + // Load vehicles once. + const pb = await pbAdmin(); + let vehicleCache: Map; + let batchId: string; + try { + const vehiclesRaw = await pb + .collection("vehicles") + .getFullList({ perPage: VEHICLES_CACHE_PAGE }); + vehicleCache = buildVehicleLookup(vehiclesRaw); + const batch = await pb.collection("data_import_batches").create({ + batch_type: "plant_hours", + original_file: file, + content_type: file.type, + date_uploaded: new Date().toISOString(), + uploaded_by: "operator", + status: "processed", + }); + batchId = batch.id; + } catch (err) { + console.error(`[plant-hours-import] setup failed:`, err); + return NextResponse.json( + { error: formatPbError(err), step: "create_batch" }, + { status: 500 }, + ); + } + + const errors: ImportError[] = []; + const warnings: ImportWarning[] = []; + const duplicatesThisBatch = new Set(); + let imported = 0; + let skipped = 0; + + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowNumber = i + 2; // header is row 1 + if (!row || row.every((c) => c === null || c === undefined || c === "")) { + continue; + } + + const vehicle_qr = String(row[colIndex("vehicle_qr")] ?? "").trim(); + const vehicle = vehicleCache.get(vehicle_qr); + + const parsed: PlantHoursRowInput = { + date_logged: parseDate(row[colIndex("date_logged")]) ?? "", + vehicle_qr, + vehicle_desc: String(row[colIndex("vehicle_desc")] ?? "").trim(), + start_date: parseDate(row[colIndex("start_date")]) ?? "", + end_date: parseDate(row[colIndex("end_date")]) ?? "", + start_time: parseTime(row[colIndex("start_time")]), + end_time: parseTime(row[colIndex("end_time")]), + start_hours: toNumber(row[colIndex("start_hours")]) ?? 0, + end_hours: toNumber(row[colIndex("end_hours")]) ?? 0, + hours: toNumber(row[colIndex("hours")]), + notes: String(row[colIndex("notes")] ?? "").trim(), + hour_meter_budget: toNumber(row[colIndex("hour_meter")]) ?? 0, + cost_per_hour: toNumber(row[colIndex("cost_per_hour")]), + }; + + // Row-level parse errors. + if (!parsed.start_date || !parsed.end_date) { + errors.push({ row: rowNumber, reason: "bad_date" }); + skipped++; + continue; + } + if (parsed.start_hours === 0 && parsed.end_hours === 0 && !vehicle) { + // already handled by validator's unknown_vehicle_qr path + } + + const result = validatePlantHoursRow( + parsed, + vehicle, + duplicatesThisBatch, + ); + + for (const w of result.warnings) { + warnings.push({ row: rowNumber, reason: w }); + } + if (result.errors.length > 0) { + for (const e of result.errors) { + errors.push({ row: rowNumber, reason: e }); + } + skipped++; + continue; + } + + // Insert. + try { + await pb.collection("plant_hours_log").create({ + ...parsed, + ...result.computed, + vehicle: vehicle!.id, + site: vehicle!.site, + project_code: vehicle!.site_project_code, + batch_reference: batchId, + start_time: parsed.start_time ?? "", + end_time: parsed.end_time ?? "", + }); + duplicatesThisBatch.add(`${vehicle!.qr_code_id}|${parsed.start_date}`); + imported++; + } catch (err) { + errors.push({ row: rowNumber, reason: formatPbError(err) }); + skipped++; + } + } + + console.log( + `[plant-hours-import] batchId=${batchId} total=${dataRows.length} imported=${imported} skipped=${skipped} warnings=${warnings.length} errors=${errors.length} duration_ms=${Date.now() - start}`, + ); + + return NextResponse.json({ + batchId, + totalRows: dataRows.length, + imported, + skipped, + errors, + warnings, + }); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/plant-hours/import/route.test.ts` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/plant-hours/import/route.ts src/app/api/plant-hours/import/route.test.ts +git commit -m "feat(plant-hours): POST /api/plant-hours/import route + +Parses .xlsx or .csv via SheetJS, loads vehicles once into a +QR-keyed cache, creates one data_import_batches row (batch_type +plant_hours), then per row: validates via plant-hours-schema, +inserts a plant_hours_log row or appends to errors[]. Type-mismatch +rows (fuel-typed vehicle, plant-shaped row) warn but still import. +Duplicate (vehicle_qr, start_date) detection runs within the batch +so a single bad row never aborts the rest of the file." +``` + +--- + +## Task 9: DELETE route + +**Files:** +- Create: `src/app/api/plant-hours/[id]/route.ts` +- Create: `src/app/api/plant-hours/[id]/route.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/app/api/plant-hours/[id]/route.test.ts`: + +```typescript +import { describe, it, expect, vi } from "vitest"; + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name !== "plant_hours_log") throw new Error(`unexpected: ${name}`); + return { delete: vi.fn(async () => true) }; + }, + })), +})); + +import { DELETE } from "./route"; + +describe("DELETE /api/plant-hours/[id]", () => { + it("deletes the row and returns 200", async () => { + const res = await DELETE( + {} as unknown as Request, + { params: Promise.resolve({ id: "row-1" }) }, + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/app/api/plant-hours/[id]/route.test.ts` +Expected: FAIL — module `./route` not found. + +- [ ] **Step 3: Create `src/app/api/plant-hours/[id]/route.ts`** + +```typescript +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; + +/** + * DELETE /api/plant-hours/[id] + * + * Removes one plant_hours_log row. Used by the Attention Required + * panel's "Delete row" action and the weekly log table's row menu. + */ +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const pb = await pbAdmin(); + await pb.collection("plant_hours_log").delete(id); + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "delete_failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/app/api/plant-hours/[id]/route.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app/api/plant-hours/[id]/route.ts src/app/api/plant-hours/[id]/route.test.ts +git commit -m "feat(plant-hours): DELETE /api/plant-hours/[id] route + +Removes one plant_hours_log row. Used by the Attention Required panel +and the weekly log table's row menu." +``` + +--- + +## Task 10: Update `PATCH /api/vehicles/[id]` to accept `unit_type` + +**Files:** +- Modify: `src/app/api/vehicles/[id]/route.ts` + +- [ ] **Step 1: Update the route to accept `unit_type`** + +Replace the entire content of `src/app/api/vehicles/[id]/route.ts` with: + +```typescript +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; +import type { UnitType } from "@/lib/pb-types"; + +/** + * PATCH /api/vehicles/[id] + * + * Updates editable fields on a vehicle record. Currently supports + * `description`, `project_code`, and `unit_type`. Only present keys + * are written; unknown fields are silently dropped. + */ +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const payload: Record = {}; + if (typeof body.description === "string") { + const desc = body.description.trim(); + if (desc.length > 0) payload.description = desc; + } + if (typeof body.project_code === "string") { + payload.project_code = body.project_code.trim(); + } + if (body.unit_type === "fuel" || body.unit_type === "plant") { + payload.unit_type = body.unit_type as UnitType; + } + + if (Object.keys(payload).length === 0) { + return NextResponse.json( + { error: "No editable fields supplied" }, + { status: 400 }, + ); + } + + const pb = await pbAdmin(); + try { + const updated = await pb.collection("vehicles").update(id, payload); + return NextResponse.json({ vehicle: updated }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Update failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 2: Verify tsc still passes** + +Run: `npx tsc --noEmit` +Expected: no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/app/api/vehicles/[id]/route.ts +git commit -m "feat(vehicles): PATCH /api/vehicles/[id] accepts unit_type + +Lets the inline-edit flow on /dashboard/plant set the vehicle's +unit_type (fuel|plant) so the plant-hours import route's type-mismatch +warnings can be resolved by reclassifying the vehicle." +``` + +--- + +## Task 11: ImportPlantHoursButton component + +**Files:** +- Create: `src/components/import-plant-hours-button.tsx` + +This task has no test (the button is mostly UI plumbing + a fetch to the route that's already tested in Task 8). Manual smoke test in Task 13. + +- [ ] **Step 1: Create the component** + +Mirror `src/components/import-fuel-logs-button.tsx`. Create `src/components/import-plant-hours-button.tsx`: + +```tsx +"use client"; + +import * as React from "react"; +import { format } from "date-fns"; +import { FileSpreadsheet, Loader2, Upload, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useFileDropZone } from "@/lib/use-file-drop-zone"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; + +type Phase = "idle" | "uploading" | "done" | "error"; + +interface ImportResult { + batchId: string; + totalRows: number; + imported: number; + skipped: number; + errors: { row: number; reason: string }[]; + warnings: { row: number; reason: string }[]; +} + +const SAMPLE_HEADERS = [ + "Date Logged", + "Vehicle QR", + "Vehicle", + "Start Date", + "End Date", + "Start Time", + "End Time", + "Start Hours", + "End Hours", + "Hours", + "Notes", + "Hour Meter", + "Cost / HR", +]; + +export function ImportPlantHoursButton() { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState(null); + const [phase, setPhase] = React.useState("idle"); + const [result, setResult] = React.useState(null); + const [errorMsg, setErrorMsg] = React.useState(null); + const inputRef = React.useRef(null); + + const reset = React.useCallback(() => { + setFile(null); + setPhase("idle"); + setResult(null); + setErrorMsg(null); + if (inputRef.current) inputRef.current.value = ""; + }, []); + + const close = () => { + setOpen(false); + setTimeout(reset, 200); + }; + + const upload = async () => { + if (!file) return; + setPhase("uploading"); + setErrorMsg(null); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/plant-hours/import", { + method: "POST", + body: form, + }); + const data = await res.json(); + if (!res.ok) { + setErrorMsg(data.error ?? "Upload failed"); + setPhase("error"); + return; + } + setResult(data); + setPhase("done"); + } catch (e) { + setErrorMsg(e instanceof Error ? e.message : "Upload failed"); + setPhase("error"); + } + }; + + const onPickFile: React.ChangeEventHandler = (e) => { + const f = e.target.files?.[0]; + if (!f) return; + setFile(f); + setPhase("idle"); + setResult(null); + setErrorMsg(null); + }; + + const ACCEPT_STR = ".csv,.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv"; + const { isDragging, dropZoneProps } = useFileDropZone({ + inputRef, + accept: ACCEPT_STR, + onFile: (f) => onPickFile({ target: { files: [f] } } as unknown as React.ChangeEvent), + onReject: (reason) => setErrorMsg(reason), + }); + + return ( + <> + + + { + if (!o) close(); + else setOpen(true); + }} + > + + + Import plant hours + + Upload an Excel (.xlsx) or CSV file - each row creates one + weekly plant-hours log entry. + + + + {phase === "done" && result ? ( + { + close(); + setTimeout(() => setOpen(true), 250); + }} + /> + ) : ( +
+
+

+ Expected columns (header row required) +

+
+ {SAMPLE_HEADERS.map((h) => ( + + {h} + + ))} +
+

+ Hours = End Hours − Start Hours (re-computed; mismatch > 0.05h is flagged). + Cost / HR is persisted verbatim (falls back to vehicle.cost_per_hour if blank). + {" "} + + Download blank template + . +

+
+ +
inputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + inputRef.current?.click(); + } + }} + role="button" + tabIndex={0} + aria-label="Drop file here, or click to choose" + className={cn( + "relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors", + isDragging + ? "border-[#F2B165] bg-[#F2B165]/10" + : "border-border bg-muted/20 hover:border-[#F2B165]/60 hover:bg-[#F2B165]/5", + )} + > + + {isDragging && ( +
+

+ Drop to upload +

+
+ )} + {file ? ( + <> + +

{file.name}

+

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

+ + + ) : ( + <> + +

+ Click or drop an Excel or CSV file here +

+

+ Weekly plant-hours sheet +

+ + )} +
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ + +
+
+ )} +
+
+ + ); +} + +function ResultSummary({ + result, + onClose, + onReimport, +}: { + result: ImportResult; + onClose: () => void; + onReimport: () => void; +}) { + return ( +
+
+ + + 0 ? "warning" : "muted"} + /> +
+ + {result.warnings.length > 0 && ( +
+

+ {result.warnings.length} warning(s) — rows imported but flagged: +

+
    + {result.warnings.slice(0, 5).map((w, i) => ( +
  • + Row {w.row}: {w.reason} +
  • + ))} + {result.warnings.length > 5 && ( +
  • …and {result.warnings.length - 5} more
  • + )} +
+
+ )} + + {result.errors.length > 0 && ( +
+

+ {result.errors.length} exception(s): +

+
+ + + + + + + + + {result.errors.slice(0, 50).map((e, i) => ( + + + + + ))} + +
RowReason
{e.row}{e.reason}
+
+ +
+ )} + +
+ {result.errors.length > 0 && ( + + )} + +
+
+ ); +} + +function SummaryStat({ + label, + value, + tone = "muted", +}: { + label: string; + value: number; + tone?: "muted" | "success" | "warning"; +}) { + const colour = + tone === "success" + ? "text-emerald-600 dark:text-emerald-400" + : tone === "warning" + ? "text-[#F2B165]" + : "text-foreground"; + return ( +
+

{value}

+

{label}

+
+ ); +} +``` + +- [ ] **Step 2: Manual smoke test (compile + browser render)** + +Run: + +```bash +npx tsc --noEmit +``` + +Expected: no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/import-plant-hours-button.tsx +git commit -m "feat(plant-hours): ImportPlantHoursButton component + +Mirrors the fuel-logs import dialog: drop zone, phase machine +(idle/uploading/done/error), result panel with import counts, warnings +list, errors list, exception-report download button, and a +re-import-fixed-file action. The expected-format panel lists the 13 +columns and links to /api/plant-hours/template." +``` + +--- + +## Task 12: Extend `/dashboard/plant` page + +**Files:** +- Modify: `src/app/dashboard/plant/page.tsx` +- Modify: `src/app/dashboard/plant/plant-view.tsx` + +- [ ] **Step 1: Update `page.tsx` to fetch plant_hours_log + vehicles** + +Replace `src/app/dashboard/plant/page.tsx`: + +```typescript +import { pbAdmin } from "@/lib/pb"; +import { PlantView, type PlantRow, type PlantLogRow } from "./plant-view"; + +export const dynamic = "force-dynamic"; + +export default async function PlantPage() { + const pb = await pbAdmin(); + + const [allVehicles, allLogs] = await Promise.all([ + pb.collection("vehicles").getFullList({ sort: "description", perPage: 5000 }), + pb + .collection("plant_hours_log") + .getFullList({ sort: "-start_date", perPage: 5000 }) + .catch(() => []), + ]); + + const rows: PlantRow[] = allVehicles + .filter((v) => (v.cost_per_hour ?? 0) > 0) + .map((v) => ({ + id: v.id, + description: v.description, + qrCodeId: v.qr_code_id ?? "", + projectCode: v.project_code, + hourMeter: v.standard_work_hours, + costPerHour: v.cost_per_hour, + unitType: v.unit_type ?? "fuel", + })); + + const logs: PlantLogRow[] = allLogs.map((l) => ({ + id: l.id, + vehicleId: l.vehicle, + vehicleQr: l.vehicle_qr, + vehicleDescription: + allVehicles.find((v) => v.id === l.vehicle)?.description ?? "", + vehicleUnitType: (allVehicles.find((v) => v.id === l.vehicle)?.unit_type ?? "fuel") as "fuel" | "plant", + projectCode: l.project_code, + startDate: l.start_date, + endDate: l.end_date, + startTime: l.start_time ?? "", + endTime: l.end_time ?? "", + hours: l.hours, + hourMeterBudget: l.hour_meter_budget, + costPerHour: l.cost_per_hour, + value: l.value, + overBudget: l.over_budget, + notes: l.notes ?? "", + })); + + const projectCodes = Array.from(new Set(rows.map((r) => r.projectCode))).sort(); + + return ( + + ); +} +``` + +- [ ] **Step 2: Add the `PlantLogRow` type + extend `PlantView` props in `plant-view.tsx`** + +Replace the top of `plant-view.tsx` (after the imports): + +```tsx +import { ImportPlantHoursButton } from "@/components/import-plant-hours-button"; +``` + +Add the new type next to `PlantRow`: + +```tsx +export interface PlantLogRow { + id: string; + vehicleId: string; + vehicleQr: string; + vehicleDescription: string; + vehicleUnitType: "fuel" | "plant"; + projectCode: string; + startDate: string; + endDate: string; + startTime: string; + endTime: string; + hours: number; + hourMeterBudget: number; + costPerHour: number; + value: number; + overBudget: boolean; + notes: string; +} +``` + +Update `PlantView`'s prop type: + +```tsx +export function PlantView({ + rows, + logs, + projectCodes, + title, + subtitle, +}: { + rows: PlantRow[]; + logs: PlantLogRow[]; + projectCodes: string[]; + title: string; + subtitle?: string; +}) { +``` + +- [ ] **Step 3: Update the topbar `right` slot** + +Find: + +```tsx +right={ + +} +``` + +Replace with: + +```tsx +right={ + +} +``` + +- [ ] **Step 4: Add the Attention Required panel + Weekly log table after the existing Plant table** + +Find the closing `` of the Plant table (right before the closing `` of the page). Add: + +```tsx +{/* Attention Required: type-mismatched rows */} +{(() => { + const mismatches = logs.filter((l) => l.vehicleUnitType !== "plant"); + if (mismatches.length === 0) return null; + return ( + + + + + Attention required + + {mismatches.length} row(s) + + + + + + Vehicle QR + Description + Logged as + Vehicle typed as + Actions + + + + {mismatches.map((m) => ( + + {m.vehicleQr} + {m.vehicleDescription} + + plant + + + {m.vehicleUnitType} + + + + + + ))} + +
+
+
+ ); +})()} + +{/* Weekly plant hours log */} + + + Weekly plant hours log + {logs.length} entries + + + {logs.length === 0 ? ( +
+ No plant hours logged yet. Download the template, fill in your + weekly hours, and import. +
+ ) : ( + + + + Week + Vehicle + Hours + Budget + Cost/hr + Value + Status + Notes + + + + {logs.map((l) => ( + + +
+ {l.startDate} → {l.endDate} +
+ {(l.startTime || l.endTime) && ( +
+ {l.startTime} → {l.endTime} +
+ )} +
+ +
{l.vehicleDescription}
+
+ {l.vehicleQr} +
+
+ + {l.hours.toFixed(1)} + + + {l.hourMeterBudget} + + + {formatCurrency(l.costPerHour)} + + + {formatCurrency(l.value)} + + + {l.overBudget ? ( + Over budget + ) : ( + Under + )} + + + {l.notes || "—"} + +
+ ))} +
+
+ )} +
+
+``` + +- [ ] **Step 5: Verify tsc passes** + +Run: `npx tsc --noEmit` +Expected: no type errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/dashboard/plant/page.tsx src/app/dashboard/plant/plant-view.tsx +git commit -m "feat(plant): weekly hours log + attention required on /dashboard/plant + +Adds two new sections below the existing equipment table: +- Attention required: rows where the row's vehicle unit_type doesn't + match vehicle_unit_type_at_import. Operator can delete the row from + here. +- Weekly plant hours log: per-week entries with hours, budget, + cost/hr, value, and over-budget badge. + +The topbar gets two new buttons (Download template, Import plant +hours) sitting next to the existing EditModeToggle." +``` + +--- + +## Task 13: End-to-end smoke test + +**Files:** none (manual verification) + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +Expected: server up at `http://localhost:3000`. + +- [ ] **Step 2: Verify the import button + dialog render on `/dashboard/plant`** + +Visit `http://localhost:3000/dashboard/plant` in a browser. Confirm: +- "Download template" + "Import plant hours" buttons appear in the topbar. +- Empty state message shows under the Weekly plant hours log heading. + +- [ ] **Step 3: Download the template, fill in one valid row, import** + +```bash +curl -sS -o /tmp/ph.xlsx http://localhost:3000/api/plant-hours/template +open /tmp/ph.xlsx +``` + +In Excel: replace the sample row with one of your real plant vehicles (e.g. `QR-A`, start/end hours reading, budget 40, cost 185). Save as `plant-test.xlsx`. + +Open `/dashboard/plant`, click "Import plant hours", drop the file, click Upload. Expect: "Imported 1, 0 skipped, 0 errors". Page reloads and the new row appears in the Weekly plant hours log. + +- [ ] **Step 4: Re-upload same week → expect duplicate_week error** + +Repeat Step 3 with the same file. Expect: "Imported 0, 1 skipped, 1 error" with reason `duplicate_week`. The "Download exception report" button is enabled; download the CSV and confirm the row's original data is in it. + +- [ ] **Step 5: Upload with an unknown QR → expect unknown_vehicle_qr error** + +Edit the file: change QR to `QR-DOES-NOT-EXIST`. Upload. Expect: 1 error (`unknown_vehicle_qr: QR-DOES-NOT-EXIST`), 0 imported. + +- [ ] **Step 6: Verify the Attention Required panel for a fuel-typed vehicle** + +In `/dashboard/vehicles`, set a vehicle's `unit_type` to `fuel` (via the inline edit / PATCH route). Upload a plant-hours row for that vehicle's QR. Expect: 1 imported, 1 warning (`plant_row_for_fuel_vehicle`). Reload `/dashboard/plant` → the row appears in the Weekly log AND in the Attention Required panel. + +- [ ] **Step 7: Verify the fix-and-retry loop** + +Download the exception report CSV from Step 4. Edit it: replace `QR-DOES-NOT-EXIST` with the real QR. Save. Drop the edited CSV back into the import dialog. Expect: 1 imported, 0 skipped. + +- [ ] **Step 8: Commit any final tweaks** + +If you adjusted any UI during smoke test: + +```bash +git add -u +git commit -m "fix(plant-hours): tweaks from end-to-end smoke test" +``` + +--- + +## Self-review + +### Spec coverage + +| Spec section | Task(s) | +|--------------|---------| +| PocketBase schema (`vehicles.unit_type`, `plant_hours_log`, `batch_type` enum) | 1 | +| Type updates (`VehicleRecord.unit_type`, `PlantHoursLogRecord`, `BatchType`) | 2 | +| Shared parsing helpers + `parseTime` | 3 | +| Per-row validator + derived fields | 4 | +| Template generator | 5 | +| Template download route | 6 | +| Exception-report CSV builder + route | 7 | +| Import route | 8 | +| DELETE route | 9 | +| `PATCH /api/vehicles/[id]` accepts `unit_type` | 10 | +| `ImportPlantHoursButton` | 11 | +| Page extension (topbar buttons, Attention panel, weekly log table) | 12 | +| End-to-end smoke test | 13 | + +### Placeholder scan + +No "TBD", "TODO", "fill in details", or generic "add validation" instructions. Every code step shows the exact code. + +### Type consistency + +- `UnitType` defined in `pb-types.ts` (Task 2) is the same union used in `plant-hours-schema.ts` (Task 4), `vehicles/[id]/route.ts` (Task 10), and `plant-view.tsx` (Task 12). ✓ +- `validatePlantHoursRow` signature in Task 4 matches the call sites in Task 8. ✓ +- `PlantHoursLogRecord` field names match the column names the import route writes in Task 8 (`vehicle_qr`, `start_date`, `cost_per_hour`, etc.). ✓ +- Exception CSV header order in Task 7 matches the column names used to reconstruct `originalRow` from the re-parsed source file. ✓ +- `PlantLogRow` in Task 12 matches the field names returned by the page's fetch in Task 12 step 1. ✓ + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-08-28-plant-hours-weekly-log.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? \ No newline at end of file