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.
This commit is contained in:
DEls 2026-08-28 13:40:30 +02:00
parent a214012d99
commit ad12a3c01c
2 changed files with 201 additions and 13 deletions

View file

@ -1,18 +1,20 @@
import { pbAdmin } from "@/lib/pb";
import { PlantView, type PlantRow } from "./plant-view";
import { PlantView, type PlantRow, type PlantLogRow } from "./plant-view";
export const dynamic = "force-dynamic";
export default async function PlantPage() {
const pb = await pbAdmin();
// Plant now lives in the VEHICLES collection — we filter to rated items
// (cost_per_hour > 0) so the financial view shows the heavy machinery.
const all = await pb.collection("vehicles").getFullList({
sort: "description",
});
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[] = all
const rows: PlantRow[] = allVehicles
.filter((v) => (v.cost_per_hour ?? 0) > 0)
.map((v) => ({
id: v.id,
@ -21,13 +23,35 @@ export default async function PlantPage() {
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 (
<PlantView
rows={rows}
logs={logs}
projectCodes={projectCodes}
title="Plant & Equipment"
subtitle="Heavy machinery with hourly hire rates — financial view."

View file

@ -25,6 +25,7 @@ import {
import { Pagination } from "@/components/ui/pagination";
import { Topbar } from "@/components/layout/sidebar";
import { EditModeBanner, EditModeToggle } from "@/components/edit-mode-bar";
import { ImportPlantHoursButton } from "@/components/import-plant-hours-button";
import { useInlineEdit } from "@/lib/use-inline-edit";
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
@ -37,6 +38,26 @@ export interface PlantRow {
projectCode: string;
hourMeter: number;
costPerHour: number;
unitType: "fuel" | "plant";
}
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;
}
function amberTile(
@ -72,11 +93,13 @@ function amberTile(
export function PlantView({
rows,
logs,
projectCodes,
title,
subtitle,
}: {
rows: PlantRow[];
logs: PlantLogRow[];
projectCodes: string[];
title: string;
subtitle?: string;
@ -163,12 +186,21 @@ export function PlantView({
title={title}
subtitle={subtitle}
right={
<EditModeToggle
editing={edit.editing}
onToggle={handleToggle}
pendingCount={edit.pendingCount}
saving={edit.saving}
/>
<div className="flex items-center gap-2">
<a
href="/api/plant-hours/template"
className="inline-flex h-9 items-center gap-1 rounded-md border border-border bg-card px-3 text-sm hover:bg-muted"
>
Download template
</a>
<ImportPlantHoursButton />
<EditModeToggle
editing={edit.editing}
onToggle={handleToggle}
pendingCount={edit.pendingCount}
saving={edit.saving}
/>
</div>
}
/>
@ -367,6 +399,138 @@ export function PlantView({
)}
</CardContent>
</Card>
{/* Attention Required: type-mismatched rows */}
{(() => {
const mismatches = logs.filter((l) => l.vehicleUnitType !== "plant");
if (mismatches.length === 0) return null;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<span aria-hidden className="inline-block h-2 w-2 rounded-full bg-[#F2B165]" />
Attention required
</CardTitle>
<Badge variant="outline">{mismatches.length} row(s)</Badge>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Vehicle QR</TableHead>
<TableHead>Description</TableHead>
<TableHead>Logged as</TableHead>
<TableHead>Vehicle typed as</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mismatches.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-mono text-xs">{m.vehicleQr}</TableCell>
<TableCell>{m.vehicleDescription}</TableCell>
<TableCell>
<Badge variant="outline">plant</Badge>
</TableCell>
<TableCell>
<Badge>{m.vehicleUnitType}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={async () => {
if (!confirm("Delete this log row?")) return;
await fetch(`/api/plant-hours/${m.id}`, { method: "DELETE" });
router.refresh();
}}
>
Delete row
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
})()}
{/* Weekly plant hours log */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Weekly plant hours log</CardTitle>
<Badge variant="outline">{logs.length} entries</Badge>
</CardHeader>
<CardContent className="p-0">
{logs.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">
No plant hours logged yet. Download the template, fill in your
weekly hours, and import.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Week</TableHead>
<TableHead>Vehicle</TableHead>
<TableHead className="text-right">Hours</TableHead>
<TableHead className="text-right">Budget</TableHead>
<TableHead className="text-right">Cost/hr</TableHead>
<TableHead className="text-right">Value</TableHead>
<TableHead>Status</TableHead>
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((l) => (
<TableRow key={l.id}>
<TableCell>
<div className="font-mono text-xs">
{l.startDate} → {l.endDate}
</div>
{(l.startTime || l.endTime) && (
<div className="text-xs text-muted-foreground">
{l.startTime} → {l.endTime}
</div>
)}
</TableCell>
<TableCell>
<div className="font-medium">{l.vehicleDescription}</div>
<div className="font-mono text-xs text-muted-foreground">
{l.vehicleQr}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{l.hours.toFixed(1)}
</TableCell>
<TableCell className="text-right tabular-nums">
{l.hourMeterBudget}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(l.costPerHour)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(l.value)}
</TableCell>
<TableCell>
{l.overBudget ? (
<Badge className="bg-[#F2B165]/20 text-[#E08A3C]">Over budget</Badge>
) : (
<Badge variant="outline">Under</Badge>
)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-xs text-muted-foreground">
{l.notes || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</>
);