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:
parent
e7c8c90e32
commit
2c2d493e12
9 changed files with 275 additions and 66 deletions
|
|
@ -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" }));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
97
src/app/dashboard/plant/plant-billing-charts.tsx
Normal file
97
src/app/dashboard/plant/plant-billing-charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
src/lib/metrics/plant-billing.test.ts
Normal file
33
src/lib/metrics/plant-billing.test.ts
Normal 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
81
src/lib/metrics/plant-billing.ts
Normal file
81
src/lib/metrics/plant-billing.ts
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue