feat(plant): inactive equipment and no-rate-needed override; brighter worker week strip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-30 01:15:52 +02:00
parent cfcfbc6056
commit 665bb215f1
10 changed files with 212 additions and 16 deletions

View file

@ -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)

View file

@ -8,7 +8,7 @@ import type { UnitType } from "@/lib/pb-types";
* PATCH /api/vehicles/[id] * PATCH /api/vehicles/[id]
* *
* Updates editable fields on a vehicle record. Currently supports * 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. * unknown fields are silently dropped.
*/ */
export async function PATCH( export async function PATCH(
@ -37,6 +37,8 @@ export async function PATCH(
const desc = body.description.trim(); const desc = body.description.trim();
if (desc.length > 0) payload.description = desc; 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") { if (body.unit_type === "fuel" || body.unit_type === "plant") {
payload.unit_type = body.unit_type as UnitType; payload.unit_type = body.unit_type as UnitType;
} }

View file

@ -16,6 +16,7 @@ import { plantFuelFillSeries } from "@/lib/metrics/fuel-fill-series";
import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage"; import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage";
import { plantBilledDays } from "@/lib/metrics/plant-billing"; import { plantBilledDays } from "@/lib/metrics/plant-billing";
import type { FuelLogRecord } from "@/lib/pb-types"; import type { FuelLogRecord } from "@/lib/pb-types";
import { VehicleStatusActions } from "@/components/vehicle-status-actions";
import { MergeVehicleButton } from "@/components/merge-vehicle-button"; import { MergeVehicleButton } from "@/components/merge-vehicle-button";
import { loadMergeCandidates } from "@/lib/merge-candidates-load"; import { loadMergeCandidates } from "@/lib/merge-candidates-load";
@ -113,6 +114,13 @@ export default async function PlantDetailPage({
right={ right={
// flex-wrap: two labelled buttons here would overflow a 375px screen. // flex-wrap: two labelled buttons here would overflow a 375px screen.
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{canManage && <VehicleStatusActions
id={vehicleId}
label={vehicle.description}
inactive={!!vehicle.inactive}
noRateNeeded={!!vehicle.no_rate_needed}
hasRate={costPerHour > 0}
/>}
{canManage && <MergeVehicleButton {canManage && <MergeVehicleButton
vehicleId={vehicleId} vehicleId={vehicleId}
vehicleLabel={vehicle.description} vehicleLabel={vehicle.description}

View file

@ -99,6 +99,8 @@ export default async function PlantPage({
fuelLimits: v.fuel_limits ?? "", fuelLimits: v.fuel_limits ?? "",
costPerHour: v.cost_per_hour ?? 0, costPerHour: v.cost_per_hour ?? 0,
reviewCount: agg?.reviewCount ?? 0, reviewCount: agg?.reviewCount ?? 0,
inactive: !!v.inactive,
noRateNeeded: !!v.no_rate_needed,
historical: !activeIds.has(v.site), historical: !activeIds.has(v.site),
canManage: scope.allowed.some(s=>s.id===v.site), canManage: scope.allowed.some(s=>s.id===v.site),
totalHours: agg?.totalHours ?? 0, 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; // average rate. "billable" is the subset with a cost_per_hour set;
// computed from allVehicles rather than rows so the count stays // computed from allVehicles rather than rows so the count stays
// meaningful now that rows includes unrated vehicles. // 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, (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 avgRate = billableVehicles.length ? billableVehicles.reduce((s,v)=>s+v.cost_per_hour,0)/billableVehicles.length : 0;
const kpis: PlantKpis = { 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), totalHours: rows.reduce((s, r) => s + r.totalHours, 0),
totalValue: rows.reduce((s, r) => s + r.totalValue, 0), totalValue: rows.reduce((s, r) => s + r.totalValue, 0),
avgRate, avgRate,

View file

@ -58,6 +58,8 @@ export interface PlantRow {
reviewCount?: number; reviewCount?: number;
historical?: boolean; historical?: boolean;
canManage?: boolean; canManage?: boolean;
inactive?: boolean;
noRateNeeded?: boolean;
totalValue: number; totalValue: number;
logCount: number; logCount: number;
overBudgetCount: number; overBudgetCount: number;
@ -142,6 +144,7 @@ export function PlantView({
const qs = params.toString(); const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname); router.replace(qs ? `${pathname}?${qs}` : pathname);
} }
const [showInactive, setShowInactive] = React.useState(false);
const [activeDialog, setActiveDialog] = React.useState<"add" | "import" | null>(null); const [activeDialog, setActiveDialog] = React.useState<"add" | "import" | null>(null);
const [attention, setAttention] = React.useState(initialAttention); const [attention, setAttention] = React.useState(initialAttention);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
@ -173,11 +176,12 @@ export function PlantView({
return rows.filter((r) => { return rows.filter((r) => {
if (removedIds.has(r.id)) return false; if (removedIds.has(r.id)) return false;
if (attention === "review" && !r.reviewCount) 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; if (q && !`${r.description} ${r.qrCodeId} ${r.projectCode}`.toLowerCase().includes(q)) return false;
return true; return true;
}); });
}, [rows, search, removedIds, attention]); }, [rows, search, removedIds, attention, showInactive]);
function handleSearch(value: string) { function handleSearch(value: string) {
setSearch(value); setSearch(value);
@ -396,6 +400,10 @@ export function PlantView({
<select aria-label="Equipment filter" value={attention} onChange={e=>{setAttention(e.target.value);setPage(1);}} className="h-10 rounded-md border border-input bg-card px-3 text-sm"> <select aria-label="Equipment filter" value={attention} onChange={e=>{setAttention(e.target.value);setPage(1);}} className="h-10 rounded-md border border-input bg-card px-3 text-sm">
<option value="all">All Equipment</option><option value="review">Needs Review</option><option value="rates">Missing Rates</option> <option value="all">All Equipment</option><option value="review">Needs Review</option><option value="rates">Missing Rates</option>
</select> </select>
<Button type="button" variant={showInactive ? "secondary" : "outline"} aria-pressed={showInactive}
onClick={() => { setShowInactive(v => !v); setPage(1); }}>
{showInactive ? "Hide inactive" : `Show inactive (${rows.filter(r => r.inactive).length})`}
</Button>
{filtersActive && ( {filtersActive && (
<Button <Button
variant="ghost" variant="ghost"
@ -523,6 +531,7 @@ export function PlantView({
{r.description} {r.description}
</Link> </Link>
)} )}
{r.inactive && <Badge variant="outline" className="text-[10px]">Inactive</Badge>}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -545,7 +554,7 @@ export function PlantView({
: <span className="text-muted-foreground">—</span>} : <span className="text-muted-foreground">—</span>}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{r.canManage !== false ? <VehicleRateButton id={r.id} label={r.description} rate={r.costPerHour ?? 0} /> : <span>{r.costPerHour ? formatCurrency(r.costPerHour) : "Rate missing"}</span>} {r.canManage !== false ? <VehicleRateButton id={r.id} label={r.description} rate={r.costPerHour ?? 0} /> : <span>{r.costPerHour ? formatCurrency(r.costPerHour) : r.noRateNeeded ? "No rate needed" : "Rate missing"}</span>}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{r.totalValue > 0 ? ( {r.totalValue > 0 ? (
@ -553,7 +562,7 @@ export function PlantView({
{formatCurrency(r.totalValue)} {formatCurrency(r.totalValue)}
</span> </span>
) : ( ) : (
<span className="text-xs text-muted-foreground">{r.costPerHour === 0 ? "Rate missing" : "—"}</span> <span className="text-xs text-muted-foreground">{r.costPerHour === 0 && !r.noRateNeeded ? "Rate missing" : "—"}</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
@ -807,6 +816,7 @@ function PlantMobileCard({
{r.projectCode || "—"} {r.projectCode || "—"}
</Badge> </Badge>
)} )}
{r.inactive && <Badge variant="outline" className="text-[10px]">Inactive</Badge>}
{r.qrCodeId && ( {r.qrCodeId && (
<span className="font-mono text-[10px]">QR {r.qrCodeId}</span> <span className="font-mono text-[10px]">QR {r.qrCodeId}</span>
)} )}

View file

@ -113,7 +113,7 @@ async function loadPlantSummary(scope: SiteScope) {
readingsForReview, 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)}`, 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, 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, vehicleCount: vehicles.length,
equipmentCount: equipment.length, equipmentCount: equipment.length,
dailyFuelLitres, dailyFuelLitres,

View file

@ -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 (
<>
<Button variant="outline" disabled={busy} aria-label={`${inactive ? "Reactivate" : "Mark inactive"} ${label}`}
title={inactive ? "Show this unit in the Plant table again" : "No longer in use: hidden from the Plant table unless inactive is shown"}
onClick={() => patch({ inactive: !inactive })}>
{inactive ? "Reactivate" : "Mark inactive"}
</Button>
{!hasRate && (
<Button variant="outline" disabled={busy} aria-label={`${noRateNeeded ? "Require a rate for" : "No rate needed for"} ${label}`}
title="This unit has no hire rate on purpose: leave it out of the missing-rate review"
onClick={() => patch({ no_rate_needed: !noRateNeeded })}>
{noRateNeeded ? "Require rate again" : "No rate needed"}
</Button>
)}
{error && <span role="alert" className="text-sm text-destructive">{error}</span>}
</>
);
}

View file

@ -2,12 +2,12 @@ import type { ActivityDay, ActivityStatus } from "@/lib/worker-week-activity";
import { durationLabel } from "@/lib/workforce-duration"; import { durationLabel } from "@/lib/workforce-duration";
const styles: Record<ActivityStatus, string> = { const styles: Record<ActivityStatus, string> = {
full: "bg-emerald-700/40 dark:bg-emerald-400/30", full: "bg-emerald-600/70 dark:bg-emerald-400/60",
overtime: "bg-amber-600/45 dark:bg-amber-300/30", overtime: "bg-amber-500/75 dark:bg-amber-300/60",
short: "bg-red-700/40 dark:bg-red-400/30", short: "bg-red-600/70 dark:bg-red-400/60",
leave: "bg-blue-700/35 dark:bg-blue-400/30", leave: "bg-blue-600/70 dark:bg-blue-400/60",
missing: "bg-red-700/40 dark:bg-red-400/30", missing: "bg-red-600/70 dark:bg-red-400/60",
off: "bg-muted-foreground/15", off: "bg-muted-foreground/25",
pending: "border border-muted-foreground/20", pending: "border border-muted-foreground/20",
}; };
const labels: Record<ActivityStatus, string> = { const labels: Record<ActivityStatus, string> = {

View file

@ -33,7 +33,7 @@ export async function loadDashboardAttention(
const filter = siteFilter(scope); const filter = siteFilter(scope);
const [vehicles, orders, notes, clockEvents, correctedIds] = await Promise.all([ const [vehicles, orders, notes, clockEvents, correctedIds] = await Promise.all([
modules.includes("plant") ? pb.collection("vehicles").getFullList({ 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({ modules.includes("financial") ? pb.collection("purchase_orders").getFullList({
fields: "id,site,purchase_order_number", ...filterOpt(filter), $autoCancel: false, fields: "id,site,purchase_order_number", ...filterOpt(filter), $autoCancel: false,
@ -53,7 +53,7 @@ export async function loadDashboardAttention(
]); ]);
return { return {
plant: vehicles.filter(needsVehicleType).length, 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, financial: missingPurchaseOrders(notes, orders).length,
onSite: unresolvedClockIns(clockEvents.filter((event) => isWorkerActive(event.expand?.employee)), new Date(), correctedIds).length, onSite: unresolvedClockIns(clockEvents.filter((event) => isWorkerActive(event.expand?.employee)), new Date(), correctedIds).length,
cashFloats, cashFloats,

View file

@ -124,6 +124,10 @@ export interface VehicleRecord extends RecordModel {
qr_code_id: string; qr_code_id: string;
cost_per_hour: number; cost_per_hour: number;
unit_type: UnitType; 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 * ISO timestamp of the user's most recent acknowledgement of this
* vehicle's "Attention required" status. null/absent when never * vehicle's "Attention required" status. null/absent when never