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:
parent
cfcfbc6056
commit
665bb215f1
10 changed files with 212 additions and 16 deletions
114
scripts/migrations/2026-10-30-vehicles-inactive-no-rate.py
Normal file
114
scripts/migrations/2026-10-30-vehicles-inactive-no-rate.py
Normal 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)
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
54
src/components/vehicle-status-actions.tsx
Normal file
54
src/components/vehicle-status-actions.tsx
Normal 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>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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> = {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue