diff --git a/scripts/migrations/2026-10-30-vehicles-inactive-no-rate.py b/scripts/migrations/2026-10-30-vehicles-inactive-no-rate.py new file mode 100644 index 0000000..a78d80e --- /dev/null +++ b/scripts/migrations/2026-10-30-vehicles-inactive-no-rate.py @@ -0,0 +1,114 @@ +#!/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-10-30-vehicles-inactive-no-rate.py + +Adds two optional booleans to VEHICLES: + inactive - unit no longer in use; hidden from the Plant table by default. + no_rate_needed - unit deliberately has no hire rate; skips "missing rate" reviews. + +Idempotent: re-running is safe; existing fields are skipped. +""" +import json +import os +import sys +import urllib.error +import urllib.request + + +COLLECTION = "vehicles" + +NEW_FIELDS = { + "inactive": {"name": "inactive", "type": "bool", "required": False}, + "no_rate_needed": {"name": "no_rate_needed", "type": "bool", "required": False}, +} + + +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 = 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}. " + f"Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD.", file=sys.stderr) + sys.exit(2) diff --git a/src/app/api/vehicles/[id]/route.ts b/src/app/api/vehicles/[id]/route.ts index 4e1340d..ccfac0e 100644 --- a/src/app/api/vehicles/[id]/route.ts +++ b/src/app/api/vehicles/[id]/route.ts @@ -8,7 +8,7 @@ import type { UnitType } from "@/lib/pb-types"; * PATCH /api/vehicles/[id] * * Updates editable fields on a vehicle record. Currently supports - * `description`, `unit_type` and `cost_per_hour`. Only present keys are written; + * `description`, `unit_type`, `cost_per_hour`, `inactive` and `no_rate_needed`. Only present keys are written; * unknown fields are silently dropped. */ export async function PATCH( @@ -37,6 +37,8 @@ export async function PATCH( const desc = body.description.trim(); if (desc.length > 0) payload.description = desc; } + if (typeof body.inactive === "boolean") payload.inactive = body.inactive; + if (typeof body.no_rate_needed === "boolean") payload.no_rate_needed = body.no_rate_needed; if (body.unit_type === "fuel" || body.unit_type === "plant") { payload.unit_type = body.unit_type as UnitType; } diff --git a/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx b/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx index 0db717a..97dd9ad 100644 --- a/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx +++ b/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx @@ -16,6 +16,7 @@ import { plantFuelFillSeries } from "@/lib/metrics/fuel-fill-series"; import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage"; import { plantBilledDays } from "@/lib/metrics/plant-billing"; import type { FuelLogRecord } from "@/lib/pb-types"; +import { VehicleStatusActions } from "@/components/vehicle-status-actions"; import { MergeVehicleButton } from "@/components/merge-vehicle-button"; import { loadMergeCandidates } from "@/lib/merge-candidates-load"; @@ -113,6 +114,13 @@ export default async function PlantDetailPage({ right={ // flex-wrap: two labelled buttons here would overflow a 375px screen.
+ {canManage && 0} + />} {canManage && s.id===v.site), totalHours: agg?.totalHours ?? 0, @@ -126,12 +128,14 @@ export default async function PlantPage({ // average rate. "billable" is the subset with a cost_per_hour set; // computed from allVehicles rather than rows so the count stays // meaningful now that rows includes unrated vehicles. - const billableVehicles = allVehicles.filter( + const activeVehicles = allVehicles.filter((v) => !v.inactive); + const billableVehicles = activeVehicles.filter( (v) => (v.cost_per_hour ?? 0) > 0, ); const avgRate = billableVehicles.length ? billableVehicles.reduce((s,v)=>s+v.cost_per_hour,0)/billableVehicles.length : 0; const kpis: PlantKpis = { - total: rows.length, + // Rate-free units are not "missing" a rate, so they leave both counts. + total: activeVehicles.filter((v) => (v.cost_per_hour ?? 0) > 0 || !v.no_rate_needed).length, totalHours: rows.reduce((s, r) => s + r.totalHours, 0), totalValue: rows.reduce((s, r) => s + r.totalValue, 0), avgRate, diff --git a/src/app/dashboard/plant/equipment/plant-view.tsx b/src/app/dashboard/plant/equipment/plant-view.tsx index 3d661cc..b5985cb 100644 --- a/src/app/dashboard/plant/equipment/plant-view.tsx +++ b/src/app/dashboard/plant/equipment/plant-view.tsx @@ -58,6 +58,8 @@ export interface PlantRow { reviewCount?: number; historical?: boolean; canManage?: boolean; + inactive?: boolean; + noRateNeeded?: boolean; totalValue: number; logCount: number; overBudgetCount: number; @@ -142,6 +144,7 @@ export function PlantView({ const qs = params.toString(); router.replace(qs ? `${pathname}?${qs}` : pathname); } + const [showInactive, setShowInactive] = React.useState(false); const [activeDialog, setActiveDialog] = React.useState<"add" | "import" | null>(null); const [attention, setAttention] = React.useState(initialAttention); const [search, setSearch] = React.useState(""); @@ -173,11 +176,12 @@ export function PlantView({ return rows.filter((r) => { if (removedIds.has(r.id)) return false; if (attention === "review" && !r.reviewCount) return false; - if (attention === "rates" && (r.costPerHour ?? 0) > 0) return false; + if (!showInactive && r.inactive) return false; + if (attention === "rates" && ((r.costPerHour ?? 0) > 0 || r.noRateNeeded)) return false; if (q && !`${r.description} ${r.qrCodeId} ${r.projectCode}`.toLowerCase().includes(q)) return false; return true; }); - }, [rows, search, removedIds, attention]); + }, [rows, search, removedIds, attention, showInactive]); function handleSearch(value: string) { setSearch(value); @@ -396,6 +400,10 @@ export function PlantView({ + {filtersActive && (
@@ -545,7 +554,7 @@ export function PlantView({ : —} - {r.canManage !== false ? : {r.costPerHour ? formatCurrency(r.costPerHour) : "Rate missing"}} + {r.canManage !== false ? : {r.costPerHour ? formatCurrency(r.costPerHour) : r.noRateNeeded ? "No rate needed" : "Rate missing"}} {r.totalValue > 0 ? ( @@ -553,7 +562,7 @@ export function PlantView({ {formatCurrency(r.totalValue)} ) : ( - {r.costPerHour === 0 ? "Rate missing" : "—"} + {r.costPerHour === 0 && !r.noRateNeeded ? "Rate missing" : "—"} )} @@ -807,6 +816,7 @@ function PlantMobileCard({ {r.projectCode || "—"} )} + {r.inactive && Inactive} {r.qrCodeId && ( QR {r.qrCodeId} )} diff --git a/src/app/dashboard/plant/page.tsx b/src/app/dashboard/plant/page.tsx index b4879b9..9860769 100644 --- a/src/app/dashboard/plant/page.tsx +++ b/src/app/dashboard/plant/page.tsx @@ -113,7 +113,7 @@ async function loadPlantSummary(scope: SiteScope) { readingsForReview, reportingPeriod: `${new Intl.DateTimeFormat("en-ZA",{day:"numeric",month:"long",year:"numeric",timeZone:"Africa/Johannesburg"}).format(new Date(monthStartIso+"T12:00:00Z"))} to ${new Intl.DateTimeFormat("en-ZA",{day:"numeric",month:"long",year:"numeric",timeZone:"Africa/Johannesburg"}).format(now)}`, estimatedFuel: fuelPrices.length === 0, - missingRates: [...equipmentWithHistory.values()].filter(v=>!(v.cost_per_hour > 0)).length, + missingRates: [...equipmentWithHistory.values()].filter(v=>!v.inactive && !v.no_rate_needed && !(v.cost_per_hour > 0)).length, vehicleCount: vehicles.length, equipmentCount: equipment.length, dailyFuelLitres, diff --git a/src/components/vehicle-status-actions.tsx b/src/components/vehicle-status-actions.tsx new file mode 100644 index 0000000..992a9cf --- /dev/null +++ b/src/components/vehicle-status-actions.tsx @@ -0,0 +1,54 @@ +"use client"; +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +/** Detail-page actions: take a unit out of use, and waive its hire rate. */ +export function VehicleStatusActions({ id, label, inactive, noRateNeeded, hasRate }: { + id: string; + label: string; + inactive: boolean; + noRateNeeded: boolean; + hasRate: boolean; +}) { + const router = useRouter(); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(""); + + async function patch(body: { inactive?: boolean; no_rate_needed?: boolean }) { + setBusy(true); + setError(""); + try { + const res = await fetch(`/api/vehicles/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})) as { error?: string }; + if (!res.ok) throw new Error(data.error || "Could not save"); + router.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not save"); + } finally { + setBusy(false); + } + } + + return ( + <> + + {!hasRate && ( + + )} + {error && {error}} + + ); +} diff --git a/src/components/worker-week-strip.tsx b/src/components/worker-week-strip.tsx index 4228dd5..6f97ea2 100644 --- a/src/components/worker-week-strip.tsx +++ b/src/components/worker-week-strip.tsx @@ -2,12 +2,12 @@ import type { ActivityDay, ActivityStatus } from "@/lib/worker-week-activity"; import { durationLabel } from "@/lib/workforce-duration"; const styles: Record = { - full: "bg-emerald-700/40 dark:bg-emerald-400/30", - overtime: "bg-amber-600/45 dark:bg-amber-300/30", - short: "bg-red-700/40 dark:bg-red-400/30", - leave: "bg-blue-700/35 dark:bg-blue-400/30", - missing: "bg-red-700/40 dark:bg-red-400/30", - off: "bg-muted-foreground/15", + full: "bg-emerald-600/70 dark:bg-emerald-400/60", + overtime: "bg-amber-500/75 dark:bg-amber-300/60", + short: "bg-red-600/70 dark:bg-red-400/60", + leave: "bg-blue-600/70 dark:bg-blue-400/60", + missing: "bg-red-600/70 dark:bg-red-400/60", + off: "bg-muted-foreground/25", pending: "border border-muted-foreground/20", }; const labels: Record = { diff --git a/src/lib/dashboard-attention.ts b/src/lib/dashboard-attention.ts index 1b0cee3..4cdbbcd 100644 --- a/src/lib/dashboard-attention.ts +++ b/src/lib/dashboard-attention.ts @@ -33,7 +33,7 @@ export async function loadDashboardAttention( const filter = siteFilter(scope); const [vehicles, orders, notes, clockEvents, correctedIds] = await Promise.all([ modules.includes("plant") ? pb.collection("vehicles").getFullList({ - fields: "id,unit_type,cost_per_hour", ...filterOpt(filter), $autoCancel: false, + fields: "id,unit_type,cost_per_hour,inactive,no_rate_needed", ...filterOpt(filter), $autoCancel: false, }) : [], modules.includes("financial") ? pb.collection("purchase_orders").getFullList({ fields: "id,site,purchase_order_number", ...filterOpt(filter), $autoCancel: false, @@ -53,7 +53,7 @@ export async function loadDashboardAttention( ]); return { plant: vehicles.filter(needsVehicleType).length, - plantNoRate: vehicles.filter((v) => isPlant(v) && !(Number(v.cost_per_hour) > 0)).length, + plantNoRate: vehicles.filter((v) => isPlant(v) && !v.inactive && !v.no_rate_needed && !(Number(v.cost_per_hour) > 0)).length, financial: missingPurchaseOrders(notes, orders).length, onSite: unresolvedClockIns(clockEvents.filter((event) => isWorkerActive(event.expand?.employee)), new Date(), correctedIds).length, cashFloats, diff --git a/src/lib/pb-types.ts b/src/lib/pb-types.ts index 439ab57..6b68dfb 100644 --- a/src/lib/pb-types.ts +++ b/src/lib/pb-types.ts @@ -124,6 +124,10 @@ export interface VehicleRecord extends RecordModel { qr_code_id: string; cost_per_hour: number; unit_type: UnitType; + /** Unit is no longer in use; hidden from the Plant table unless inactive is shown. */ + inactive?: boolean; + /** Unit deliberately has no hire rate; it is left out of "missing rate" reviews. */ + no_rate_needed?: boolean; /** * ISO timestamp of the user's most recent acknowledgement of this * vehicle's "Attention required" status. null/absent when never