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