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:
parent
a214012d99
commit
ad12a3c01c
2 changed files with 201 additions and 13 deletions
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue