feat(plant): actual vs bill charts, attention card under KPIs, sidebar mark for missing rates

- Plant overview: monthly Actual vs Bill hours and Total vs Bill value across
  all equipment, using the same rules as the equipment detail tiles
  (billed-day logic moved to lib/metrics/plant-billing).
- Attention Needed moves under the KPI cards and now holds the missing hire
  rate, readings-to-review and fuel price notices.
- Sidebar Plant mark also counts equipment with no hire rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-10-28 22:43:56 +02:00
parent e7c8c90e32
commit 2c2d493e12
9 changed files with 275 additions and 66 deletions

View file

@ -39,7 +39,7 @@ afterEach(() => vi.useRealTimers());
it("counts the same actionable items as the dashboards, grouping missing POs rather than deliveries", async () => {
const response = await GET(request("?site=site1"));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ plant: 1, financial: 1, onSite: 0, cashFloats: 0 });
expect(await response.json()).toEqual({ plant: 1, plantNoRate: 1, financial: 1, onSite: 0, cashFloats: 0 });
expect(response.headers.get("cache-control")).toContain("no-store");
expect(getScope).toHaveBeenCalledWith({ site: "site1" });
expect(getFullList).toHaveBeenCalledWith("vehicles", expect.objectContaining({ filter: 'site="site1"' }));
@ -55,17 +55,17 @@ it("loads only workforce alerts for a workforce office account", async () => {
});
const response = await GET(request());
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await response.json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("clears the attention counts after classification and matching PO upload", async () => {
getFullList.mockImplementation(async (name: string) => {
if (name === "vehicles") return [{ unit_type: "fuel" }, { unit_type: "plant" }];
if (name === "vehicles") return [{ unit_type: "fuel" }, { unit_type: "plant", cost_per_hour: 450 }];
if (name === "dn_batch_notes") return [waitingNote("one")];
if (name === "purchase_orders") return [{ site: site.id, purchase_order_number: "MEGCPO9373", processing_status: "processing" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("counts the cash float cards under their limit for an admin only", async () => {
@ -92,7 +92,7 @@ it("preserves restricted all-sites access in every query", async () => {
it("returns all clear without reading records when no sites are accessible", async () => {
getScope.mockResolvedValue({ allowed: [], active: [], mode: "all", unrestricted: false });
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(getFullList).not.toHaveBeenCalled();
});
@ -123,13 +123,13 @@ it("flags old missed clock-outs while excluding today's shifts, completed shifts
if (name === "clock_missed_out_corrections") return [{ clock_in_event: "corrected" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 1, cashFloats: 0 });
getFullList.mockImplementation(async (name: string) => {
if (name === "clock_events") return events;
if (name === "clock_missed_out_corrections") return [{ clock_in_event: "corrected" }, { clock_in_event: "missed" }];
return [];
});
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 0, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats: 0 });
});
it("surfaces correction read failures instead of wrongly flagging corrected shifts", async () => {
@ -145,6 +145,6 @@ it("surfaces correction read failures instead of wrongly flagging corrected shif
{ id: "missed", employee: "a", direction: "in", scanned_at: "2026-10-03T05:00:00Z", expand: { employee: { employment_status: "inactive" } } },
{ id: "active", employee: "b", direction: "in", scanned_at: "2026-10-03T05:00:00Z", expand: { employee: { employment_status: "active" } } },
] : []);
expect(await (await GET(request())).json()).toEqual({ plant: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(await (await GET(request())).json()).toEqual({ plant: 0, plantNoRate: 0, financial: 0, onSite: 1, cashFloats: 0 });
expect(getFullList).toHaveBeenCalledWith("clock_events", expect.objectContaining({ expand: "employee" }));
});

View file

@ -14,6 +14,7 @@ import {
} from "./plant-detail-view";
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 { MergeVehicleButton } from "@/components/merge-vehicle-button";
import { loadMergeCandidates } from "@/lib/merge-candidates-load";
@ -90,46 +91,7 @@ export default async function PlantDetailPage({
projectCode:logSiteById[point.site] ?? vehicleProjectCode });
}
// Billed days: the site is billed a flat 9 h per weekday, however many hours
// the machine ran. First find the days with recorded work (weekly sheets cover
// every day of their period, meter intervals count their closing day); they
// fix when the hire starts/ends and which rate applies.
const BILLED_HOURS_PER_DAY = 9;
const billedByDate = new Map<string, number>();
const periodById = new Map(periods.map(p => [p.id, p]));
for (const p of points) {
if (p.rejected || p.hours <= 0) continue;
const period = p.source === "weekly" ? periodById.get(p.id) : undefined;
if (period?.start_date && period.end_date) {
for (let t = Date.parse(period.start_date.slice(0, 10)); t <= Date.parse(period.end_date.slice(0, 10)); t += 86400000) {
const d = new Date(t).toISOString().slice(0, 10);
if (d <= today && !billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
} else {
const d = p.date.slice(0, 10);
if (!billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
}
// The machine is on hire for the whole month: from its first recorded day to
// the end of its last active month (never beyond today), every weekday is
// billed 9 h whether or not it worked. Rate: that day's recorded rate, else
// the last known one, else the current hire rate.
const billedDays: { date: string; hours: number; value: number }[] = [];
const workedDates = [...billedByDate.keys()].sort();
if (workedDates.length > 0) {
const lastWorked = workedDates[workedDates.length - 1];
const monthEnd = new Date(Date.UTC(Number(lastWorked.slice(0, 4)), Number(lastWorked.slice(5, 7)), 0)).toISOString().slice(0, 10);
const end = Date.parse(monthEnd < today ? monthEnd : today);
let rate = 0;
for (let t = Date.parse(workedDates[0]); t <= end; t += 86400000) {
const date = new Date(t).toISOString().slice(0, 10);
rate = billedByDate.get(date) ?? rate;
const dow = new Date(t).getUTCDay();
if (dow === 0 || dow === 6) continue;
const dayRate = rate > 0 ? rate : costPerHour;
billedDays.push({ date, hours: BILLED_HOURS_PER_DAY, value: Math.round(BILLED_HOURS_PER_DAY * dayRate * 100) / 100 });
}
}
const billedDays = plantBilledDays(points, periods, costPerHour, today);
// Latest hour-meter reading = last odo in the series (we sorted asc).
const latestHourMeter = points.length > 0 ? points[points.length - 1].odoReading : 0;

View file

@ -1,3 +1,4 @@
import { Fragment } from "react";
import Link from "next/link";
import {
Droplets,
@ -17,6 +18,9 @@ import { plantUsage, type PlantPeriod } from "@/lib/metrics/plant-usage";
import { UnclassifiedVehicles } from "./unclassified-vehicles";
import { MonthlyFuelCharts } from "./monthly-fuel-charts";
import { monthlyPlantFuel } from "@/lib/metrics/monthly-plant-fuel";
import { plantFuelFillSeries } from "@/lib/metrics/fuel-fill-series";
import { monthlyPlantBilling, plantBilledDays } from "@/lib/metrics/plant-billing";
import { PlantBillingCharts } from "./plant-billing-charts";
import type { FuelLogRecord, VehicleRecord } from "@/lib/pb-types";
export const dynamic = "force-dynamic";
@ -82,8 +86,20 @@ async function loadPlantSummary(scope: SiteScope) {
fuelSpendThisMonth += log.litres * resolveFuelPrice(log.date_logged, fuelPrices);
}
}
const billingMachines = [];
for (const v of equipmentWithHistory.values()) {
const usage = plantUsage(allowedFuelLogs.filter(l=>l.vehicle===v.id), periods.filter(p=>p.vehicle===v.id), v.cost_per_hour ?? 0, today);
// Same figures as the equipment detail tiles, limited to the selected sites.
const siteLogs = allFuelLogs.filter(l=>l.vehicle===v.id);
const sitePeriods = periods.filter(p=>p.vehicle===v.id && activeIds.has(p.site ?? ""));
const sitePoints = plantUsage(siteLogs, sitePeriods, v.cost_per_hour ?? 0, today).filter(p=>p.date.slice(0,10)<=today);
billingMachines.push({
fillHours: plantFuelFillSeries(siteLogs)
.filter(p=>p.fillHours>0 && (p.log.date_logged ?? "").slice(0,10)<=today)
.map(p=>({date:(p.log.date_logged ?? "").slice(0,10),hours:p.fillHours})),
points: sitePoints,
billedDays: plantBilledDays(sitePoints, sitePeriods, v.cost_per_hour ?? 0, today),
});
for (const point of usage) {
const date = point.date.slice(0,10);
if (activeIds.has(point.site) && date >= monthStartIso && date <= today) {
@ -108,6 +124,7 @@ async function loadPlantSummary(scope: SiteScope) {
combinedSpend: fuelSpendThisMonth + equipmentValueThisMonth,
unclassified,
monthlyFuel,
monthlyBilling: monthlyPlantBilling(billingMachines),
};
}
@ -144,16 +161,15 @@ export default async function PlantLanding({
<KpiTile label="Equipment" value={formatNumber(summary.equipmentCount)} sub="Currently assigned" icon={Wrench} />
</section>
{(summary.estimatedFuel || summary.missingRates > 0 || summary.readingsForReview > 0) && (
<div role="status" className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{summary.estimatedFuel && <p>Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. <Link className="font-medium text-foreground underline" href="/dashboard/plant/fuel-prices">Add Fuel Prices</Link></p>}
{summary.missingRates > 0 && <p>{summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?rates=1">Add Rates</Link></p>}
{summary.readingsForReview > 0 && <p>{summary.readingsForReview} reading intervals need review and are excluded from hire cost. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?review=1">Review Readings</Link></p>}
</div>
)}
<MonthlyFuelCharts {...summary.monthlyFuel} />
<UnclassifiedVehicles vehicles={summary.unclassified} notices={[
summary.estimatedFuel && <Fragment key="fuel-prices">Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. <Link className="font-medium text-foreground underline" href="/dashboard/plant/fuel-prices">Add Fuel Prices</Link></Fragment>,
summary.missingRates > 0 && <Fragment key="rates">{summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?rates=1">Add Rates</Link></Fragment>,
summary.readingsForReview > 0 && <Fragment key="readings">{summary.readingsForReview} reading intervals need review and are excluded from hire cost. <Link className="font-medium text-foreground underline" href="/dashboard/plant/equipment?review=1">Review Readings</Link></Fragment>,
]} />
<MonthlyFuelCharts {...summary.monthlyFuel} />
<PlantBillingCharts months={summary.monthlyBilling} />
<UnclassifiedVehicles vehicles={summary.unclassified} />
</div>
</>
);

View file

@ -0,0 +1,97 @@
"use client";
import { format, parseISO } from "date-fns";
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatNumber } from "@/lib/utils";
import type { MonthlyPlantBillingPoint } from "@/lib/metrics/plant-billing";
// Recorded figures in the equipment green, billed figures in the sky blue the
// equipment detail tiles use for billing.
const RECORDED = "#007160";
const BILLED = "#0284c7";
const CHARTS = [
{
title: "Actual Hours vs Bill Hours",
unit: "Hours",
recorded: { key: "actualHours", label: "Actual hours" },
billed: { key: "billHours", label: "Bill hours" },
format: (n: number) => `${formatNumber(n, { maximumFractionDigits: 1 })} h`,
axis: (n: number) => formatNumber(n, { notation: "compact", maximumFractionDigits: 1 }),
},
{
title: "Total Value vs Bill Value",
unit: "Rand",
recorded: { key: "totalValue", label: "Total value" },
billed: { key: "billValue", label: "Bill value" },
format: (n: number) => formatCurrency(n),
axis: (n: number) => `R ${formatNumber(n, { notation: "compact", maximumFractionDigits: 1 })}`,
},
] as const;
export function PlantBillingCharts({ months }: { months: MonthlyPlantBillingPoint[] }) {
const period = months.length > 0
? `${format(parseISO(`${months[0].month}-01`), "MMM yyyy")} to ${format(parseISO(`${months[months.length - 1].month}-01`), "MMM yyyy")}`
: "Recorded equipment history";
return (
<section aria-label="Equipment actual vs billed" className="grid min-w-0 grid-cols-1 gap-4 lg:grid-cols-2">
{CHARTS.map((chart) => {
const recordedTotal = months.reduce((sum, m) => sum + m[chart.recorded.key], 0);
const billedTotal = months.reduce((sum, m) => sum + m[chart.billed.key], 0);
return (
<Card key={chart.title} className="min-w-0 rounded-lg shadow-none">
<CardHeader className="pb-3">
<CardTitle>{chart.title}</CardTitle>
<p className="mt-2 text-xs text-muted-foreground">{period} · All equipment</p>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
{[[chart.recorded, recordedTotal, RECORDED], [chart.billed, billedTotal, BILLED]].map(([series, total, color]) => (
<div key={(series as { key: string }).key}>
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
<span aria-hidden className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color as string }} />
{(series as { label: string }).label}
</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{chart.format(total as number)}</dd>
</div>
))}
</dl>
</CardHeader>
<CardContent>
<div className="h-64 w-full min-w-0">
{months.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">No equipment history yet.</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={months} accessibilityLayer barGap={2} margin={{ top: 12, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="month" tickFormatter={(value: string) => format(parseISO(`${value}-01`), "MMM yy")}
stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} />
<YAxis tickFormatter={chart.axis} stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} width={56} />
<Tooltip cursor={{ fill: "var(--muted)", opacity: 0.6 }}
contentStyle={{ backgroundColor: "var(--popover)", color: "var(--popover-foreground)", border: "1px solid var(--border)", borderRadius: 8, fontSize: 12 }}
itemStyle={{ color: "var(--popover-foreground)" }}
labelFormatter={(value) => format(parseISO(`${String(value)}-01`), "MMMM yyyy")}
formatter={(value: number, name: string) => [chart.format(value), name]} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey={chart.recorded.key} name={chart.recorded.label} fill={RECORDED} radius={[4, 4, 0, 0]} maxBarSize={32} isAnimationActive={false} />
<Bar dataKey={chart.billed.key} name={chart.billed.label} fill={BILLED} radius={[4, 4, 0, 0]} maxBarSize={32} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
)}
</div>
<div className="sr-only"><table>
<caption>{chart.title} by month</caption>
<thead><tr><th scope="col">Month</th><th scope="col">{chart.recorded.label}</th><th scope="col">{chart.billed.label}</th></tr></thead>
<tbody>{months.map((m) => <tr key={m.month}><th scope="row">{m.month}</th><td>{chart.format(m[chart.recorded.key])}</td><td>{chart.format(m[chart.billed.key])}</td></tr>)}</tbody>
</table></div>
<p className="mt-3 text-xs text-muted-foreground">
Bill hours are 9 h every weekday while a machine is on hire. Current month is still in progress.
</p>
</CardContent>
</Card>
);
})}
</section>
);
}

View file

@ -23,7 +23,11 @@ const OPTIONS = [
{ value: "plant", title: "Equipment", unit: "L/hour", description: "Track operating hours and fuel consumption.", icon: Wrench },
] as const;
export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehicle[] }) {
export function UnclassifiedVehicles({ vehicles, notices = [] }: {
vehicles: UnclassifiedVehicle[];
/** Other plant issues (missing hire rates, readings to review) shown above the list. */
notices?: React.ReactNode[];
}) {
const router = useRouter();
const [assignedIds, setAssignedIds] = React.useState<string[]>([]);
const [selected, setSelected] = React.useState<UnclassifiedVehicle | null>(null);
@ -33,6 +37,8 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
const [error, setError] = React.useState<string | null>(null);
const [message, setMessage] = React.useState("");
const remaining = vehicles.filter((vehicle) => !assignedIds.includes(vehicle.id));
const issues = notices.filter(Boolean);
const needsAttention = remaining.length > 0 || issues.length > 0;
function chooseVehicle(vehicle: UnclassifiedVehicle) {
setSelected(vehicle);
@ -62,11 +68,11 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
return (
<section aria-label="Attention needed">
<Card className={cn(remaining.length > 0 && "border-red-500 dark:border-red-400")}>
<Card className={cn(needsAttention && "border-red-500 dark:border-red-400")}>
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<AlertTriangle className={cn("h-4 w-4 shrink-0", remaining.length > 0 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} aria-hidden />
<AlertTriangle className={cn("h-4 w-4 shrink-0", needsAttention ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} aria-hidden />
<CardTitle>Attention Needed</CardTitle>
</div>
<p className="mt-2 text-sm text-muted-foreground">
@ -74,10 +80,15 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic
</p>
</div>
<Badge variant="outline" className="shrink-0">
{remaining.length === 0 ? "All clear" : `${remaining.length} unclassified`}
{!needsAttention ? "All clear" : remaining.length > 0 ? `${remaining.length} unclassified` : `${issues.length} to review`}
</Badge>
</CardHeader>
<CardContent>
{issues.length > 0 && (
<ul role="status" className="mb-4 space-y-2 rounded-md border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{issues.map((notice, index) => <li key={index}>{notice}</li>)}
</ul>
)}
{remaining.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" aria-hidden />

View file

@ -239,14 +239,19 @@ export function Sidebar({ user }: { user: AppUser }) {
}
const groupActive = groupIsActive(entry, pathname);
const plantUntyped = attention?.plant ?? 0;
const plantNoRate = attention?.plantNoRate ?? 0;
const attentionCount = entry.href === "/dashboard/plant"
? attention?.plant ?? 0
? plantUntyped + plantNoRate
: entry.href === "/dashboard/financial" ? attention?.financial ?? 0
: entry.href === "/dashboard/workforce" ? attention?.onSite ?? 0 : 0;
// Management is always expanded, so a low cash float card is marked
// on its Cash Floats child only, not on the group as well.
const attentionDescription = entry.href === "/dashboard/plant"
? `${attentionCount} ${attentionCount === 1 ? "vehicle needs" : "vehicles need"} a type assigned`
? [
plantUntyped > 0 && `${plantUntyped} ${plantUntyped === 1 ? "vehicle needs" : "vehicles need"} a type assigned`,
plantNoRate > 0 && `${plantNoRate} equipment ${plantNoRate === 1 ? "item has" : "items have"} no hire rate`,
].filter(Boolean).join(" · ")
: entry.href === "/dashboard/financial"
? `${attentionCount} missing purchase ${attentionCount === 1 ? "order" : "orders"}`
: `${attentionCount} missed ${attentionCount === 1 ? "clock-out needs" : "clock-outs need"} correcting`;

View file

@ -1,7 +1,7 @@
import type PocketBase from "pocketbase";
import { missingPurchaseOrders } from "@/lib/dn-pipeline/missing-purchase-orders";
import { filterOpt, siteFilter, type SiteScope } from "@/lib/site-scope";
import { needsVehicleType } from "@/lib/vehicle-buckets";
import { isPlant, needsVehicleType } from "@/lib/vehicle-buckets";
import { unresolvedClockIns } from "@/lib/clock-events";
import { loadClockCorrectionIds } from "@/lib/clock-corrections";
import { isWorkerActive } from "@/lib/worker-status";
@ -10,7 +10,10 @@ import { DASHBOARD_MODULES, type DashboardModule } from "@/lib/auth/access";
import { lowCardCount } from "@/lib/cash-float-server";
export interface DashboardAttention {
/** Vehicles still waiting for a type (Vehicles or Equipment). */
plant: number;
/** Equipment with no hire rate: its hours are recorded but not costed. */
plantNoRate?: number;
financial: number;
onSite: number;
/** Cash float cards that are under their limit. Admin only. */
@ -26,11 +29,11 @@ export async function loadDashboardAttention(
): Promise<DashboardAttention> {
// The cards are not site-scoped, so this is counted on its own.
const cashFloats = options.management ? await lowCardCount(pb) : 0;
if (scope.active.length === 0) return { plant: 0, financial: 0, onSite: 0, cashFloats };
if (scope.active.length === 0) return { plant: 0, plantNoRate: 0, financial: 0, onSite: 0, cashFloats };
const filter = siteFilter(scope);
const [vehicles, orders, notes, clockEvents, correctedIds] = await Promise.all([
modules.includes("plant") ? pb.collection("vehicles").getFullList({
fields: "id,unit_type", ...filterOpt(filter), $autoCancel: false,
fields: "id,unit_type,cost_per_hour", ...filterOpt(filter), $autoCancel: false,
}) : [],
modules.includes("financial") ? pb.collection("purchase_orders").getFullList({
fields: "id,site,purchase_order_number", ...filterOpt(filter), $autoCancel: false,
@ -50,6 +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,
financial: missingPurchaseOrders(notes, orders).length,
onSite: unresolvedClockIns(clockEvents.filter((event) => isWorkerActive(event.expand?.employee)), new Date(), correctedIds).length,
cashFloats,

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { monthlyPlantBilling, plantBilledDays } from "./plant-billing";
import type { PlantUsage } from "./plant-usage";
const point = (date: string, hours: number, costPerHour = 100): PlantUsage => ({
id: date, date, site: "s", odoReading: 0, hours, value: hours * costPerHour, costPerHour, litres: 0, rejected: false, source: "meter",
});
describe("plantBilledDays", () => {
it("bills 9 h every weekday from the first worked day to month end, capped at today", () => {
// Wed 1 Oct 2025 worked; today Tue 7 Oct → Wed, Thu, Fri, Mon, Tue.
const days = plantBilledDays([point("2025-10-01", 3)], [], 100, "2025-10-07");
expect(days.map(d => d.date)).toEqual(["2025-10-01", "2025-10-02", "2025-10-03", "2025-10-06", "2025-10-07"]);
expect(days.every(d => d.hours === 9 && d.value === 900)).toBe(true);
});
it("ignores rejected and zero-hour readings", () => {
expect(plantBilledDays([{ ...point("2025-10-01", 3), rejected: true }, point("2025-10-02", 0)], [], 100, "2025-10-07")).toEqual([]);
});
});
describe("monthlyPlantBilling", () => {
it("sums machines per month", () => {
const rows = monthlyPlantBilling([
{ fillHours: [{ date: "2025-10-03", hours: 20 }], points: [{ date: "2025-10-03", value: 2000 }], billedDays: [{ date: "2025-10-03", hours: 9, value: 900 }] },
{ fillHours: [{ date: "2025-11-04", hours: 5 }], points: [], billedDays: [{ date: "2025-10-06", hours: 9, value: 450 }] },
]);
expect(rows).toEqual([
{ month: "2025-10", actualHours: 20, billHours: 18, totalValue: 2000, billValue: 1350 },
{ month: "2025-11", actualHours: 5, billHours: 0, totalValue: 0, billValue: 0 },
]);
});
});

View file

@ -0,0 +1,81 @@
import type { PlantPeriod, PlantUsage } from "./plant-usage";
/** The site is billed a flat 9 h per weekday, however many hours the machine ran. */
export const BILLED_HOURS_PER_DAY = 9;
export interface BilledDay { date: string; hours: number; value: number }
/** Billed weekdays for one machine. Days with recorded work (weekly sheets cover
* every day of their period, meter intervals count their closing day) fix when
* the hire starts and which rate applies. The machine is then on hire from its
* first recorded day to the end of its last active month (never beyond today):
* every weekday is billed 9 h whether or not it worked. Rate: that day's
* recorded rate, else the last known one, else the current hire rate. */
export function plantBilledDays(points: (Pick<PlantUsage, "id" | "date" | "hours" | "costPerHour"> & Partial<Pick<PlantUsage, "rejected" | "source">>)[], periods: PlantPeriod[], costPerHour: number, today: string): BilledDay[] {
const billedByDate = new Map<string, number>();
const periodById = new Map(periods.map(p => [p.id, p]));
for (const p of points) {
if (p.rejected || p.hours <= 0) continue;
const period = p.source === "weekly" ? periodById.get(p.id) : undefined;
if (period?.start_date && period.end_date) {
for (let t = Date.parse(period.start_date.slice(0, 10)); t <= Date.parse(period.end_date.slice(0, 10)); t += 86400000) {
const d = new Date(t).toISOString().slice(0, 10);
if (d <= today && !billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
} else {
const d = p.date.slice(0, 10);
if (!billedByDate.has(d)) billedByDate.set(d, p.costPerHour);
}
}
const billedDays: BilledDay[] = [];
const workedDates = [...billedByDate.keys()].sort();
if (workedDates.length === 0) return billedDays;
const lastWorked = workedDates[workedDates.length - 1];
const monthEnd = new Date(Date.UTC(Number(lastWorked.slice(0, 4)), Number(lastWorked.slice(5, 7)), 0)).toISOString().slice(0, 10);
const end = Date.parse(monthEnd < today ? monthEnd : today);
let rate = 0;
for (let t = Date.parse(workedDates[0]); t <= end; t += 86400000) {
const date = new Date(t).toISOString().slice(0, 10);
rate = billedByDate.get(date) ?? rate;
const dow = new Date(t).getUTCDay();
if (dow === 0 || dow === 6) continue;
const dayRate = rate > 0 ? rate : costPerHour;
billedDays.push({ date, hours: BILLED_HOURS_PER_DAY, value: Math.round(BILLED_HOURS_PER_DAY * dayRate * 100) / 100 });
}
return billedDays;
}
export interface MonthlyPlantBillingPoint {
month: string;
actualHours: number;
billHours: number;
totalValue: number;
billValue: number;
}
/** Monthly totals across machines, matching the equipment detail tiles:
* actual hours = meter hours between refuels, total value = recorded hire value,
* bill hours/value = flat billed weekdays. */
export function monthlyPlantBilling(machines: {
fillHours: { date: string; hours: number }[];
points: { date: string; value: number }[];
billedDays: BilledDay[];
}[]): MonthlyPlantBillingPoint[] {
const byMonth = new Map<string, MonthlyPlantBillingPoint>();
const at = (date: string) => {
const month = date.slice(0, 7);
let row = byMonth.get(month);
if (!row) byMonth.set(month, row = { month, actualHours: 0, billHours: 0, totalValue: 0, billValue: 0 });
return row;
};
for (const m of machines) {
for (const f of m.fillHours) at(f.date).actualHours += f.hours;
for (const p of m.points) if (p.value) at(p.date).totalValue += p.value;
for (const b of m.billedDays) {
const row = at(b.date);
row.billHours += b.hours;
row.billValue += b.value;
}
}
return [...byMonth.values()].sort((a, b) => a.month.localeCompare(b.month));
}