diff --git a/src/app/api/dashboard-attention/route.test.ts b/src/app/api/dashboard-attention/route.test.ts index 55d8613..368f700 100644 --- a/src/app/api/dashboard-attention/route.test.ts +++ b/src/app/api/dashboard-attention/route.test.ts @@ -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" })); }); diff --git a/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx b/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx index d43b126..0db717a 100644 --- a/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx +++ b/src/app/dashboard/plant/equipment/[vehicleId]/page.tsx @@ -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(); - 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; diff --git a/src/app/dashboard/plant/page.tsx b/src/app/dashboard/plant/page.tsx index 0d1b24f..b4879b9 100644 --- a/src/app/dashboard/plant/page.tsx +++ b/src/app/dashboard/plant/page.tsx @@ -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({ - {(summary.estimatedFuel || summary.missingRates > 0 || summary.readingsForReview > 0) && ( -
- {summary.estimatedFuel &&

Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. Add Fuel Prices

} - {summary.missingRates > 0 &&

{summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. Add Rates

} - {summary.readingsForReview > 0 &&

{summary.readingsForReview} reading intervals need review and are excluded from hire cost. Review Readings

} -
- )} - + Fuel spend is estimated at the default diesel price because no dated fuel prices are configured. Add Fuel Prices, + summary.missingRates > 0 && {summary.missingRates} equipment items have no hire rate. Their meter hours are recorded, but their hire cost is excluded until a rate is set. Add Rates, + summary.readingsForReview > 0 && {summary.readingsForReview} reading intervals need review and are excluded from hire cost. Review Readings, + ]} /> + + + - ); diff --git a/src/app/dashboard/plant/plant-billing-charts.tsx b/src/app/dashboard/plant/plant-billing-charts.tsx new file mode 100644 index 0000000..d81ddaf --- /dev/null +++ b/src/app/dashboard/plant/plant-billing-charts.tsx @@ -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 ( +
+ {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 ( + + + {chart.title} +

{period} · All equipment

+
+ {[[chart.recorded, recordedTotal, RECORDED], [chart.billed, billedTotal, BILLED]].map(([series, total, color]) => ( +
+
+ + {(series as { label: string }).label} +
+
{chart.format(total as number)}
+
+ ))} +
+
+ +
+ {months.length === 0 ? ( +
No equipment history yet.
+ ) : ( + + + + format(parseISO(`${value}-01`), "MMM yy")} + stroke="var(--muted-foreground)" tickLine={false} axisLine={false} fontSize={11} minTickGap={16} /> + + format(parseISO(`${String(value)}-01`), "MMMM yyyy")} + formatter={(value: number, name: string) => [chart.format(value), name]} /> + + + + + + )} +
+
+ + + {months.map((m) => )} +
{chart.title} by month
Month{chart.recorded.label}{chart.billed.label}
{m.month}{chart.format(m[chart.recorded.key])}{chart.format(m[chart.billed.key])}
+

+ Bill hours are 9 h every weekday while a machine is on hire. Current month is still in progress. +

+
+
+ ); + })} +
+ ); +} diff --git a/src/app/dashboard/plant/unclassified-vehicles.tsx b/src/app/dashboard/plant/unclassified-vehicles.tsx index dcc1090..e697f90 100644 --- a/src/app/dashboard/plant/unclassified-vehicles.tsx +++ b/src/app/dashboard/plant/unclassified-vehicles.tsx @@ -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([]); const [selected, setSelected] = React.useState(null); @@ -33,6 +37,8 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic const [error, setError] = React.useState(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 (
- 0 && "border-red-500 dark:border-red-400")}> +
- 0 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} aria-hidden /> + Attention Needed

@@ -74,10 +80,15 @@ export function UnclassifiedVehicles({ vehicles }: { vehicles: UnclassifiedVehic

- {remaining.length === 0 ? "All clear" : `${remaining.length} unclassified`} + {!needsAttention ? "All clear" : remaining.length > 0 ? `${remaining.length} unclassified` : `${issues.length} to review`}
+ {issues.length > 0 && ( +
    + {issues.map((notice, index) =>
  • {notice}
  • )} +
+ )} {remaining.length === 0 ? (
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 6521250..405c8e7 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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`; diff --git a/src/lib/dashboard-attention.ts b/src/lib/dashboard-attention.ts index c9b5904..1b0cee3 100644 --- a/src/lib/dashboard-attention.ts +++ b/src/lib/dashboard-attention.ts @@ -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 { // 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, diff --git a/src/lib/metrics/plant-billing.test.ts b/src/lib/metrics/plant-billing.test.ts new file mode 100644 index 0000000..d34e347 --- /dev/null +++ b/src/lib/metrics/plant-billing.test.ts @@ -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 }, + ]); + }); +}); diff --git a/src/lib/metrics/plant-billing.ts b/src/lib/metrics/plant-billing.ts new file mode 100644 index 0000000..c221b0b --- /dev/null +++ b/src/lib/metrics/plant-billing.ts @@ -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 & Partial>)[], periods: PlantPeriod[], costPerHour: number, today: string): BilledDay[] { + const billedByDate = new Map(); + 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(); + 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)); +}