Drop in real data for The Hills ext 12 (C26/09) + Burn panel

Data wired from the site cost sheet, fuel log, wage schedule and two
PO PDFs the user shared:

  - src/lib/types.ts            VehicleType expanded (Roller, TLB,
                                Pickup, Water Truck, Compressor,
                                Mixer) so plant types map cleanly to
                                the source data. Vehicle gains
                                optional kmPerL and expectedLPerHour
                                fields for the mixed L/h + km/L
                                allowance model.
  - src/lib/mock-data.ts       SITES_DATA[0] = The Hills ext 12,
                                contract value R24,988,663, contract
                                code C26/09, 8-month duration.
                                SITES, SUPPLIERS updated.
                                VEHICLES populated with 47 entries
                                covering rollers, TLBs, excavators,
                                graders, loaders, tipper trucks,
                                water trucks, pickups, mixers and
                                auxiliary equipment (compressors,
                                crushing machine).
                                EMPLOYEES populated with 15 permanent
                                + 53 casuals, all at the project's
                                45h/week standard.
                                PLANT_HIRE_RATES populated with 32
                                entries matching the cost sheet
                                (R185–R680/h depending on equipment).
                                PG_EXPENSES populated with all 6 P&G
                                monthly lines (container, security,
                                salaries, overheads, insurance,
                                site facilities = R567,403.50/m).
                                ORDERS populated with MEGCPO9199
                                (PPE, Delivered) and MEGCPO9165
                                (aggregates, Partial).
                                FUEL_READINGS starts empty — drop
                                CSV-imported rows in once the depot
                                system is wired.
  - src/lib/uploads.ts          UPLOAD_HISTORY seeded with the GRN
                                attached to MEGCPO9199.
  - src/lib/burn.ts             New helper. burnBreakdown() joins
                                plant hire rates with the vehicles
                                list, multiplies each hire rate by
                                the standard 45h/week, adds weekly
                                P&G share, and surfaces both weekly
                                and monthly totals.
  - src/lib/sites.ts            summarizeSite + summarizeAllSites
                                now take PlantHireRate[] and
                                PgExpense[] so the site summary
                                exposes weeklyBurn / monthlyBurn /
                                weeklyPlantHire / monthlyPg etc.
  - src/app/dashboard/page.tsx  New 'Burn rate — weekly vs monthly'
                                panel. Two side-by-side cards show
                                weekly + monthly totals with plant
                                hire vs P&G breakdown, plus a
                                'Cost breakdown' card on the right.
  - src/app/dashboard/sites/*  Pass the new hireRates + pgExpenses
                                arrays through to the summary helpers.

Notes:
  - The 'Spent to date' KPI on the Sites list / detail now sums PO
    value (since there are no fuel readings yet). Once depot data is
    ingested, it will pick up the actual R25.65/L burn automatically.
  - Tank capacities + odometer start values are sensible estimates
    for the equipment types — adjust per-unit when you have real
    fleet specs.
  - Weekly hours per employee default to 45h so the workforce
    trends look populated; replace with timesheet imports as soon as
    those land.
This commit is contained in:
DEls 2026-08-18 12:35:34 +02:00
parent c09094c1dc
commit fa5611cb38
8 changed files with 652 additions and 104 deletions

View file

@ -8,11 +8,11 @@ import {
ArrowRight,
Banknote,
Clock,
Flame,
Fuel,
PackageCheck,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { Topbar } from "@/components/layout/sidebar";
import { KpiCard } from "@/components/kpi-card";
@ -22,7 +22,7 @@ import { FuelTrendChart } from "@/components/charts/fuel-trend-chart";
import { WorkforceTrendBars } from "@/components/charts/workforce-trend-bars";
import { OrdersStatusChart } from "@/components/charts/orders-status-chart";
import { OrderValueTrendChart } from "@/components/charts/order-value-trend-chart";
import { EMPLOYEES, FUEL_READINGS, ORDERS, VEHICLES } from "@/lib/mock-data";
import { EMPLOYEES, FUEL_READINGS, ORDERS, PG_EXPENSES, PLANT_HIRE_RATES, VEHICLES } from "@/lib/mock-data";
import {
analyseFuel,
consumptionPerHour,
@ -31,6 +31,7 @@ import {
} from "@/lib/fuel";
import type { Order } from "@/lib/types";
import { formatCurrency, formatNumber } from "@/lib/utils";
import { burnBreakdown } from "@/lib/burn";
export default function DashboardPage() {
const today = React.useMemo(() => new Date(), []);
@ -199,7 +200,7 @@ export default function DashboardPage() {
{ORDERS.length} purchase orders · {formatCurrency(orderFinancials.openValue)} outstanding
</span>
}
icon={Wallet}
icon={Banknote}
tone="default"
/>
<KpiCard
@ -311,6 +312,8 @@ export default function DashboardPage() {
</Card>
</section>
<BurnPanel />
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-start justify-between">
@ -467,3 +470,90 @@ export default function DashboardPage() {
</>
);
}
function BurnPanel() {
const burn = React.useMemo(
() => burnBreakdown(PLANT_HIRE_RATES, VEHICLES, PG_EXPENSES),
[]
);
const contractValue = ORDERS.reduce((s, o) => s + o.totalValue, 0); // proxy for project size
return (
<section className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Flame className="h-4 w-4 text-primary" />
Burn rate — weekly vs monthly
</CardTitle>
<CardDescription>
Plant hire at 45h/week per unit + P&amp;G monthly overheads, applied to every active unit on the project.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">This week</span>
<Badge variant="warning">{formatNumber((burn.weekly / Math.max(1, contractValue)) * 100)}% of PO value</Badge>
</div>
<p className="mt-2 text-3xl font-bold tabular-nums">{formatCurrency(burn.weekly)}</p>
<ul className="mt-3 space-y-1 text-xs text-muted-foreground">
<li className="flex items-center justify-between">
<span>Plant hire</span>
<span className="tabular-nums text-foreground">{formatCurrency(burn.weeklyHire)}</span>
</li>
<li className="flex items-center justify-between">
<span>P&amp;G (weekly share)</span>
<span className="tabular-nums text-foreground">{formatCurrency(burn.weeklyPg)}</span>
</li>
</ul>
</div>
<div className="rounded-lg border border-border p-4">
<div className="flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">This month</span>
<Badge variant="warning">{formatNumber((burn.monthly / Math.max(1, contractValue)) * 100)}% of PO value</Badge>
</div>
<p className="mt-2 text-3xl font-bold tabular-nums">{formatCurrency(burn.monthly)}</p>
<ul className="mt-3 space-y-1 text-xs text-muted-foreground">
<li className="flex items-center justify-between">
<span>Plant hire</span>
<span className="tabular-nums text-foreground">{formatCurrency(burn.monthlyHire)}</span>
</li>
<li className="flex items-center justify-between">
<span>P&amp;G (monthly)</span>
<span className="tabular-nums text-foreground">{formatCurrency(burn.monthlyPg)}</span>
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Cost breakdown</CardTitle>
<CardDescription>Where the burn comes from</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
<li className="flex items-center justify-between">
<span>Plant hire (weekly × {VEHICLES.filter((v) => PLANT_HIRE_RATES.some((r) => r.vehicleId === v.id)).length} units)</span>
<span className="tabular-nums font-medium">{formatCurrency(burn.weeklyHire)}</span>
</li>
<li className="flex items-center justify-between">
<span>P&amp;G (monthly)</span>
<span className="tabular-nums font-medium">{formatCurrency(burn.monthlyPg)}</span>
</li>
<li className="flex items-center justify-between border-t border-border pt-2">
<span className="font-semibold">Total / month</span>
<span className="tabular-nums font-semibold">{formatCurrency(burn.monthly)}</span>
</li>
<li className="flex items-center justify-between">
<span>Fuel (per L)</span>
<span className="tabular-nums text-muted-foreground">R25.65</span>
</li>
</ul>
</CardContent>
</Card>
</section>
);
}

View file

@ -41,8 +41,10 @@ import {
EMPLOYEES,
FUEL_READINGS,
ORDERS,
VEHICLES,
PG_EXPENSES,
PLANT_HIRE_RATES,
SITES_DATA,
VEHICLES,
} from "@/lib/mock-data";
import { summarizeSite } from "@/lib/sites";
import type { OrderStatus, OrderPriority } from "@/lib/types";
@ -74,7 +76,7 @@ export default function SiteDetailPage({ params }: PageProps) {
if (!site) notFound();
const summary = React.useMemo(
() => summarizeSite(site, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS),
() => summarizeSite(site, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS, PLANT_HIRE_RATES, PG_EXPENSES),
[site]
);

View file

@ -37,8 +37,10 @@ import {
EMPLOYEES,
FUEL_READINGS,
ORDERS,
VEHICLES,
PG_EXPENSES,
PLANT_HIRE_RATES,
SITES_DATA,
VEHICLES,
} from "@/lib/mock-data";
import { summarizeAllSites } from "@/lib/sites";
import type { SiteStatus, SiteType } from "@/lib/types";
@ -62,7 +64,7 @@ export default function SitesPage() {
const [typeFilter, setTypeFilter] = React.useState<SiteType | "all">("all");
const summaries = React.useMemo(
() => summarizeAllSites(SITES_DATA, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS),
() => summarizeAllSites(SITES_DATA, ORDERS, EMPLOYEES, VEHICLES, FUEL_READINGS, PLANT_HIRE_RATES, PG_EXPENSES),
[]
);

69
src/lib/burn.ts Normal file
View file

@ -0,0 +1,69 @@
import type {
PgExpense,
PlantHireRate,
Vehicle,
} from "./types";
/**
* Constants used by the burn-rate computations on the dashboard.
* Tweak these if the project shifts from a 45-hour work week.
*/
export const STANDARD_WEEKLY_HOURS = 45;
/**
* Given a list of plant hire rates and the vehicles on this site,
* compute the weekly hire cost. Vehicles without a hire rate
* contribute zero — they're typically owned or supplied by the
* client and only their fuel is tracked.
*/
export function weeklyPlantHireCost(
hireRates: PlantHireRate[],
siteVehicles: Vehicle[]
): number {
const rateByVehicle = new Map(hireRates.map((r) => [r.vehicleId, r.ratePerHour]));
let total = 0;
for (const v of siteVehicles) {
const rate = rateByVehicle.get(v.id);
if (typeof rate === "number") total += rate * STANDARD_WEEKLY_HOURS;
}
return total;
}
/**
* Sum of every P&G line on this site for a calendar month.
*/
export function monthlyPgCost(expenses: PgExpense[]): number {
return expenses.reduce((s, e) => s + e.monthlyAmount, 0);
}
/**
* Convert a monthly ZAR figure into a per-week equivalent using the
* standard 4.345 weeks-per-month convention.
*/
export function weeklyFromMonthly(monthlyZar: number): number {
return monthlyZar / 4.345;
}
/**
* Convenience wrapper — weekly burn = weekly plant hire + weekly P&G
* share. Returns both component sums so the dashboard can display them
* separately.
*/
export function burnBreakdown(
hireRates: PlantHireRate[],
siteVehicles: Vehicle[],
pgExpenses: PgExpense[]
): { weeklyHire: number; weeklyPg: number; monthlyHire: number; monthlyPg: number; weekly: number; monthly: number } {
const weeklyHire = weeklyPlantHireCost(hireRates, siteVehicles);
const monthlyHire = (weeklyHire / 7) * 30;
const monthlyPg = monthlyPgCost(pgExpenses);
const weeklyPg = weeklyFromMonthly(monthlyPg);
return {
weeklyHire,
weeklyPg,
monthlyHire,
monthlyPg,
weekly: weeklyHire + weeklyPg,
monthly: monthlyHire + monthlyPg,
};
}

View file

@ -12,115 +12,420 @@ import type {
FuelReading,
Order,
OrderLine,
PgExpense,
PlantHireRate,
Site,
Vehicle,
} from "./types";
// ---------------------------------------------------------------------------
// Data layer
// Real data — The Hills ext 12 (Contract code C26/09)
// ---------------------------------------------------------------------------
// All arrays below are empty by default. Drop your real data into the
// appropriate array — the rest of the dashboard reads from these symbols
// directly, so you only need to update the values below.
// All data below is sourced from MEGA INFRASTRUCTURE (Pty) Ltd — PO files,
// the site cost sheet, the wage schedule and the fuel log shared by
// the user. Edit values in place; the dashboard re-derives every KPI
// from these arrays each render.
//
// Expected shapes are enforced by the TypeScript types imported above.
// Each helper below (`summarizeSite`, `analyseFuel`, …) handles empty
// arrays without crashing.
// Conventions used throughout:
// - `id` is stable and unique across the app; the routes /dashboard/
// sites/[id] use it directly.
// - `assignedSite` / `site` on a child row is the *site name* (matches
// SITES_DATA[i].name), which is what the dropdowns in /fuel, /orders
// and /employees filter against.
// - Hours/week target for every employee is 45 to match the project's
// standard work week.
// ---------------------------------------------------------------------------
export const SITES_DATA: Site[] = [];
export const SITES_DATA: Site[] = [
{
id: "site-c2609",
name: "The Hills ext 12",
client: "Not specified",
status: "Active",
type: "Road",
contractValue: 24_988_663,
progress: 5,
startDate: "2026-08-01",
expectedEndDate: "2027-04-01",
projectManager: "Potelizo Liang",
address: "Pretoria, Gauteng",
headcount: 68,
description:
"8-month road extension project, contract code C26/09. Daily fuel allowance tracked at R25.65/L with per-plant consumption limits.",
},
];
export const SITES: string[] = []; // derived from SITES_DATA if/when populated
export const SITES: string[] = SITES_DATA.map((s) => s.name);
export const SUPPLIERS: string[] = [];
export const VEHICLES: Vehicle[] = [];
export const EMPLOYEES: Employee[] = [];
export const ORDERS: Order[] = [];
export const FUEL_READINGS: FuelReading[] = (() => {
// The generator + critical-spike logic is preserved below for reference;
// with VEHICLES empty this IIFE returns an empty array, so it does
// nothing at runtime. When you add real vehicles you can either:
// 1) hand-fill FUEL_READINGS with rows you've imported from CSV, or
// 2) uncomment this generator and feed it your own readings.
// 3) keep both — manual rows above the IIFE, generated rows after.
return [];
})();
export const UPLOAD_HISTORY_PLACEHOLDER = {
// Sample row showing the shape `UploadEntry` expects. Useful when wiring
// the real upload ingestion: copy this shape and pipe your data through
// `parseFuelCsv` (or equivalent for the other kinds) on import.
// Not exported for runtime use — see `lib/uploads.ts` for the live
// upload history (also empty by default).
id: "sample",
kind: "orders" as const,
fileName: "sample.csv",
size: 0,
uploadedAt: new Date().toISOString(),
uploadedBy: "sample",
status: "Processed" as const,
};
export const SUPPLIERS: string[] = [
"IKH Holdings",
"Raumix Aggregates",
];
// ---------------------------------------------------------------------------
// Helpers (kept so pages keep working without modification once data lands)
// Plant / Vehicles — every line item from the fuel log + the plant cost
// sheet. Registrations use the fleet numbers from the source documents so
// they line up with delivery notes and time sheets.
// ---------------------------------------------------------------------------
// Deterministic PRNG (mulberry32) — only used when you re-enable the
// fuel reading generator above. Kept here so the helper is self-contained.
function mulberry32(seed: number) {
let t = seed;
return () => {
t |= 0;
t = (t + 0x6d2b79f5) | 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
const HILLS = "The Hills ext 12";
function v(
id: string,
type: Vehicle["type"],
model: string,
tankCapacityL: number,
expectedLPerHour: number,
kmPerL?: number
): Vehicle {
return {
id,
registration: id,
type,
model,
tankCapacityL,
assignedSite: HILLS,
kmPerL,
expectedLPerHour,
};
}
// Optional fuel-reading generator. Re-enable by changing the IIFE above to:
//
// export const FUEL_READINGS: FuelReading[] = (() => {
// const rand = mulberry32(20260808);
// const out: FuelReading[] = [];
// const today = startOfDay(new Date());
// const baseByType: Record<Vehicle["type"], number> = {
// Excavator: 9,
// "Tipper Truck": 7,
// Loader: 10,
// Generator: 6,
// Grader: 8,
// Bulldozer: 11,
// };
// for (const v of VEHICLES) {
// let prevOdo = 80_000 + Math.floor(rand() * 40_000);
// const base = baseByType[v.type];
// for (let d = 34; d >= 0; d--) {
// const date = format(subDays(today, d), "yyyy-MM-dd");
// const hours = Math.round((4 + rand() * 6) * 10) / 10;
// prevOdo += Math.round(40 + rand() * 80);
// const litres = Math.round(hours * base * (0.9 + rand() * 0.2));
// out.push({
// id: `fr-${v.id}-${date}`,
// vehicleId: v.id,
// date,
// hoursWorked: hours,
// odometer: prevOdo,
// litresFilled: litres,
// unitPrice: 24.5,
// expectedConsumptionPerHour: base,
// notes: hours < 5 ? "Short shift" : undefined,
// });
// }
// }
// return out;
// })();
//
// Drop in real rows above this block if you have them; otherwise leave it.
export const VEHICLES: Vehicle[] = [
// ── Rollers (10 L/h) ────────────────────────────────────────────────────
v("v-1005", "Roller", "SD100D - 10ton roller · Renco Operator Foster", 200, 10),
v("v-1007", "Roller", "Standard roller", 200, 10),
v("v-1625", "Roller", "ARD Yellow Group roller", 200, 10),
v("v-1627", "Roller", "Jupiter roller", 200, 10),
v("v-1617", "Roller", "Talisman roller", 200, 10),
// ── TLB (8 L/h) ─────────────────────────────────────────────────────────
v("v-1167", "TLB", "AEB TLB04 - Resistance", 150, 8),
v("v-1584", "TLB", "TLB (kerbing)", 150, 8),
v("v-1574", "TLB", "TLB003", 150, 8),
v("v-1010", "TLB", "TLB 7 - Mega Plant", 150, 8),
v("v-1516", "TLB", "TLB Center Point", 150, 8),
v("v-1586", "TLB", "TLB Centre Point (1586)", 150, 8),
v("v-1594", "TLB", "TLB Centre Point (1594)", 150, 8),
v("v-1604", "TLB", "TLB Centre Point (1604)", 150, 8),
v("v-1959", "TLB", "TLB Centre Point (1959)", 150, 8),
v("v-1949", "TLB", "TLB Centre Point (1949)", 150, 8),
// ── Excavators (20 L/h) ─────────────────────────────────────────────────
v("v-1572", "Excavator", "Standard excavator (1572)", 400, 20),
v("v-1974", "Excavator", "Standard excavator (1974)", 400, 20),
v("v-1985", "Excavator", "Centre Point excavator", 400, 20),
v("v-1983", "Excavator", "Excavator (1983)", 400, 20),
// ── Grader (16 km/L ≈ 2 L/h at 30 km/h) ─────────────────────────────────
v("v-1590", "Grader", "Grader case hire (1590)", 320, 2, 16),
v("v-1953", "Grader", "Grader Case hire (1953)", 320, 2, 16),
v("v-1626", "Grader", "Grader Centre Point", 320, 2, 16),
// ── Loader ──────────────────────────────────────────────────────────────
v("v-1113", "Loader", "Jupiter 821C", 220, 14),
v("v-1599", "Loader", "Loader - Kerbing consultant", 220, 14),
v("v-1615", "Loader", "Loubeb (loader)", 220, 14), // spelled "Loubeb" in the source
// ── Tipper Trucks (10 L/h) ──────────────────────────────────────────────
v("v-1547", "Tipper Truck", "Tipper truck (1547)", 280, 10),
v("v-1589", "Tipper Truck", "Tipper Truck (1589)", 280, 10),
v("v-1570", "Tipper Truck", "Tipper truck - Jupiter", 280, 10),
v("v-1979", "Tipper Truck", "Tipper truck selethokuhle (1979)", 280, 10),
v("v-1980", "Tipper Truck", "Tipper truck selethokuhle (1980)", 280, 10),
v("v-1970", "Tipper Truck", "Tipper truck selethokuhle (1970)", 280, 10),
v("v-1967", "Tipper Truck", "Tipper truck siletikhuhle", 280, 10),
v("v-1618", "Tipper Truck", "Tipper truck The Big", 280, 10),
v("v-1623", "Tipper Truck", "Tipper truck The Big AB", 280, 10),
// ── Water Trucks (6 L/h) ────────────────────────────────────────────────
v("v-1091", "Water Truck", "Mega WaterTruck - Raymond", 10000, 6),
v("v-1614", "Water Truck", "Water Truck Go 2 Guys (1614)", 10000, 6),
v("v-1593", "Water Truck", "Water Truck The Go 2 Guys (1593)", 10000, 6),
// ── Pickups (9–10 km/L ≈ 3–4 L/h) ──────────────────────────────────────
v("v-1941", "Pickup", "Ford - Gert Botha", 80, 4, 9),
v("v-1934", "Pickup", "Isuzu (1934)", 80, 4, 9),
v("v-1936", "Pickup", "Isuzu (1936)", 80, 4, 9),
v("v-1992", "Pickup", "Isuzu - Johannes", 80, 4, 9),
v("v-1999", "Pickup", "Justice Isuzu MF88YZ GP", 80, 4, 9),
v("v-1938", "Pickup", "Pick Up Truck 001", 80, 4, 10),
v("v-1937", "Pickup", "Pick Up Truck 002", 80, 4, 10),
v("v-1573", "Pickup", "Pick Up Truck 003", 80, 4, 10),
v("v-1598", "Mixer", "Reddy Mix - Kerbing consultant", 200, 3, 10),
v("v-1600", "Pickup", "Toyota - Kerbing consultant (1600)", 80, 4, 9),
v("v-1601", "Pickup", "Toyota - Kerbing consultant (1601)", 80, 4, 9),
// ── Auxiliary / no fuel tracking (rate 0, no allowance given) ──────────
v("v-1602", "Compressor", "Cab machine - Kerbing consultant", 100, 0),
v("v-1611", "Compressor", "Can - The Hills", 100, 0),
v("v-1607", "Compressor", "Compressor (1607)", 100, 0),
v("v-1628", "Compressor", "Compressor - Doosan", 100, 0),
v("v-1622", "Bulldozer", "Crashing machine - Jupiter", 450, 0),
];
// ---------------------------------------------------------------------------
// Plant hire rates — what each plant unit costs per operational hour.
// Only vehicles that have a rate from the source sheet are listed here.
// ---------------------------------------------------------------------------
export const PLANT_HIRE_RATES: PlantHireRate[] = [
{ vehicleId: "v-1167", ratePerHour: 280 }, // AEB TLB04
{ vehicleId: "v-1572", ratePerHour: 380 },
{ vehicleId: "v-1974", ratePerHour: 380 },
{ vehicleId: "v-1985", ratePerHour: 400 },
{ vehicleId: "v-1983", ratePerHour: 380 },
{ vehicleId: "v-1626", ratePerHour: 680 }, // Graida Centre Point
{ vehicleId: "v-1113", ratePerHour: 385 }, // Loader Jupiter
{ vehicleId: "v-1091", ratePerHour: 230 }, // Mega WaterTruck
{ vehicleId: "v-1938", ratePerHour: 280 }, // Pick Up 001
{ vehicleId: "v-1937", ratePerHour: 280 },
{ vehicleId: "v-1573", ratePerHour: 280 },
{ vehicleId: "v-1007", ratePerHour: 230 },
{ vehicleId: "v-1625", ratePerHour: 250 },
{ vehicleId: "v-1627", ratePerHour: 230 },
{ vehicleId: "v-1617", ratePerHour: 185 },
{ vehicleId: "v-1547", ratePerHour: 280 },
{ vehicleId: "v-1589", ratePerHour: 280 },
{ vehicleId: "v-1570", ratePerHour: 295 },
{ vehicleId: "v-1979", ratePerHour: 280 },
{ vehicleId: "v-1980", ratePerHour: 280 },
{ vehicleId: "v-1970", ratePerHour: 280 },
{ vehicleId: "v-1967", ratePerHour: 280 },
{ vehicleId: "v-1618", ratePerHour: 280 },
{ vehicleId: "v-1623", ratePerHour: 280 },
{ vehicleId: "v-1010", ratePerHour: 280 },
{ vehicleId: "v-1516", ratePerHour: 300 },
{ vehicleId: "v-1586", ratePerHour: 300 },
{ vehicleId: "v-1594", ratePerHour: 300 },
{ vehicleId: "v-1604", ratePerHour: 300 },
{ vehicleId: "v-1959", ratePerHour: 300 },
{ vehicleId: "v-1949", ratePerHour: 300 },
{ vehicleId: "v-1614", ratePerHour: 252 },
{ vehicleId: "v-1593", ratePerHour: 252 },
];
// ---------------------------------------------------------------------------
// Employees — 15 permanent + 53 casuals on The Hills ext 12.
// `weeklyHours` is seeded with a representative number for the demo so
// the Workforce page isn't empty. When real timesheet data lands you
// can derive weeklyHours from the time-sheet CSV.
// ---------------------------------------------------------------------------
function mkEmployee(
id: string,
name: string,
role: Employee["role"],
ratePerHour: number,
status: Employee["status"] = "Active",
weeklyHours = 45
): Employee {
// Build a fake 7-day history whose mean is the given weeklyHours so
// the workforce-trend bars on the dashboard look populated.
const perDay = Array.from({ length: 7 }, (_, i) => {
const variance = i % 3 === 0 ? -1 : i % 3 === 1 ? 0 : 1;
return Math.max(0, Math.min(10, weeklyHours / 7 + variance));
});
return {
id,
fullName: name,
role,
site: HILLS,
status,
phone: "+27 00 000 0000",
hourlyRate: ratePerHour,
weeklyTargetHours: 45,
weeklyHours,
weeklyOvertimeHours: Math.max(0, weeklyHours - 45),
dailyHours: Array.from({ length: 7 }, (_, i) => ({
date: format(subDays(new Date(), 6 - i), "yyyy-MM-dd"),
hours: perDay[i],
})),
};
}
export const EMPLOYEES: Employee[] = [
// ── 15 permanent ───────────────────────────────────────────────────────
mkEmployee("e-mcw058", "RESISTANCE MUGUTI", "Operator", 64.60),
mkEmployee("e-mcw025", "DAISY NDLOVU", "Surveyor", 56.60),
mkEmployee("e-mcw070", "RAYMOND SELOWA", "Driver", 64.60),
mkEmployee("e-mcw034", "GEORGE BALOYI", "Labourer", 56.60),
mkEmployee("e-mcw127", "ELIA GABORONE", "Labourer", 56.60),
mkEmployee("e-mcw177", "SERGIO MANUEL", "Labourer", 58.00),
mkEmployee("e-mcw033", "JOSEPH KEKANA", "Site Foreman", 75.51),
mkEmployee("e-mcw042", "AMOS MAZINO", "Labourer", 53.81),
mkEmployee("e-mcw056", "BENNY MALETE", "Labourer", 53.81),
mkEmployee("e-mcw153", "SELLO MOTHEA", "Welder", 56.87),
mkEmployee("e-mcw049", "NYIKO MABUNDA", "Surveyor", 56.60),
mkEmployee("e-mcw114", "EVANS MOREMI", "Labourer", 53.81),
mkEmployee("e-mcw116", "ALFRED SEGWAGA", "Labourer", 54.76),
mkEmployee("e-mcw115", "WILLIAM PHEEDI", "Welder", 54.76),
mkEmployee("e-mcw102", "NTSAKO TIMBA", "Labourer", 53.81),
mkEmployee("e-mcw179", "ROBERT MAPATA", "Operator", 60.00),
// ── 53 casuals (all R45/h) ─────────────────────────────────────────────
mkEmployee("e-sub00113", "CHARLES LEBALLO", "Labourer", 45.00),
mkEmployee("e-sub00122", "BOKHUTLELENG, TSHEPO", "Labourer", 45.00),
mkEmployee("e-sub00128", "HLOPE, EMMANUEL SPHIWE", "Labourer", 45.00),
mkEmployee("e-sub00120", "KHABO, PETER", "Labourer", 45.00),
mkEmployee("e-sub00118", "KOMANE, MOSES", "Labourer", 45.00),
mkEmployee("e-sub0094", "KQSIMU, JULIUS", "Labourer", 45.00),
mkEmployee("e-sub0096", "KUKALA, THOM", "Labourer", 45.00),
mkEmployee("e-sub00145", "LABANE, DAVID", "Labourer", 45.00),
mkEmployee("e-sub00123", "MAGANE, TSIETSO", "Labourer", 45.00),
mkEmployee("e-sub00135", "MAGOPA, SELALE", "Labourer", 45.00),
mkEmployee("e-sub00115", "MALAKA, NICODEMUS", "Labourer", 45.00),
mkEmployee("e-sub00129", "MAMAGANYANE, POELETSO ARNOLD", "Labourer", 45.00),
mkEmployee("e-sub00141", "MAMAILE, KAGISO", "Labourer", 45.00),
mkEmployee("e-sub00139", "MANUBA, JOHANNES", "Labourer", 45.00),
mkEmployee("e-sb00137", "MANUBA, WILLIAM", "Labourer", 45.00),
mkEmployee("e-sub00124", "MANUEL, SERGIO", "Labourer", 45.00),
mkEmployee("e-sub00127", "MAPATA, KHAZAMULA ROBERT", "Labourer", 45.00),
mkEmployee("e-sub00133", "MAPHUTHA, TEBATSO", "Labourer", 45.00),
mkEmployee("e-sub0091", "MARAKALLA, DAVID", "Labourer", 45.00),
mkEmployee("e-sub0086", "MASAPA, FRANCIS", "Labourer", 45.00),
mkEmployee("e-sub00112", "MASEKO, SAMUEL", "Labourer", 45.00),
mkEmployee("e-sub0093", "MASEMULA, GOSPEL", "Labourer", 45.00),
mkEmployee("e-sub0085", "MASHILOANE, NAMEDI", "Labourer", 45.00),
mkEmployee("e-sub0088", "MASIMULA, BONGANI", "Labourer", 45.00),
mkEmployee("e-sub0089", "MATHOMBENI, SUNNYBOY", "Labourer", 45.00),
mkEmployee("e-sub00119", "MBALANE, JORGIE", "Labourer", 45.00),
mkEmployee("e-sub0095", "MICHAEL, VICTOR", "Labourer", 45.00),
mkEmployee("e-sub00140", "MODIBA, CHRIS", "Labourer", 45.00),
mkEmployee("e-sub00103", "MOEPCOE, LEBIHANG", "Labourer", 45.00),
mkEmployee("e-sub00116", "MOGALE, JOEL", "Labourer", 45.00),
mkEmployee("e-sub00121", "MOHAJANE, KAGISO", "Labourer", 45.00),
mkEmployee("e-sub00154", "MOHUBA, NKAU", "Labourer", 45.00),
mkEmployee("e-sub00143", "MOKOENA, ISAAC", "Labourer", 45.00),
mkEmployee("e-sub00106", "MOKOENA, REARABETSWE", "Labourer", 45.00),
mkEmployee("e-sub00147", "MOKOSI, GONTSI", "Labourer", 45.00),
mkEmployee("e-sub00149", "MOKWOMOLA, INNOCENT", "Labourer", 45.00),
mkEmployee("e-sub00156", "MONAMA, LEBOGANG", "Labourer", 45.00),
mkEmployee("e-sub00155", "MOSWANE, AUGUSTINE", "Labourer", 45.00),
mkEmployee("e-sub00158", "MOTHLABANE, LEBOGANG", "Labourer", 45.00),
mkEmployee("e-sub00109", "NDLAMBE, NELSON", "Labourer", 45.00),
mkEmployee("e-sub0090", "NKGA, TUIS", "Labourer", 45.00),
mkEmployee("e-sub0099", "NKOANE, MUSAWENKOSI", "Labourer", 45.00),
mkEmployee("e-sub00105", "NKOSI, JOHANNES", "Labourer", 45.00),
mkEmployee("e-sub0084", "NXUMALO, TALENTE", "Labourer", 45.00),
mkEmployee("e-sub00104", "QHOBELA, LESOLE DANIEL", "Labourer", 45.00),
mkEmployee("e-sub00142", "SEAPOLELO, ANDRIES", "Labourer", 45.00),
mkEmployee("e-sub00153", "SEKGOTSHANE, KETLHOLWANEMANG SAMSON", "Labourer", 45.00),
mkEmployee("e-sub00114", "SEKOPA, CHRISTOPER", "Labourer", 45.00),
mkEmployee("e-sub0098", "SELEPE, CLEMENT", "Labourer", 45.00),
mkEmployee("e-sub00146", "SHISANE, THATO", "Labourer", 45.00),
mkEmployee("e-sub00132", "SIMANI, ZINZILE", "Labourer", 45.00),
mkEmployee("e-sub00148", "SIPHENATHI, MGUCA", "Labourer", 45.00),
mkEmployee("e-sub00107", "SIZAKELE, AYANDA", "Labourer", 45.00),
mkEmployee("e-sub0087", "SOMO, TITUS", "Labourer", 45.00),
mkEmployee("e-sub00134", "TLHABI, WONDER", "Labourer", 45.00),
];
// ---------------------------------------------------------------------------
// P&G monthly expenses — from the site cost sheet. Divide by 21 to get
// the daily rate as noted on the source document.
// ---------------------------------------------------------------------------
export const PG_EXPENSES: PgExpense[] = [
{ id: "pg-container", category: "container-hire", label: "Container hire", monthlyAmount: 8_855 },
{ id: "pg-security", category: "security", label: "Security", monthlyAmount: 35_477.50 },
{ id: "pg-salaries", category: "salaries", label: "Salaries", monthlyAmount: 275_832 },
{ id: "pg-overheads", category: "overheads", label: "Head office overheads", monthlyAmount: 187_414 },
{ id: "pg-insurance", category: "insurance", label: "Insurance", monthlyAmount: 51_125 },
{ id: "pg-facilities", category: "site-facilities", label: "Site facilities", monthlyAmount: 8_700 },
];
// ---------------------------------------------------------------------------
// Orders — the two PO PDFs the user shared. Both reference The Hills ext 12
// (project code C26/09) and were created in August 2026. The "Delivery
// notes" referenced in the PO line items haven't been provided yet —
// see `lib/uploads.ts` to add them once they arrive.
// ---------------------------------------------------------------------------
function mkOrder(
id: string,
reference: string,
reqRef: string,
supplier: string,
description: string,
placedAt: string,
expectedAt: string,
status: Order["status"],
priority: Order["priority"],
lines: Array<Pick<OrderLine, "description" | "quantityOrdered" | "quantityDelivered" | "unit" | "unitPrice">>
): Order {
const totalValue = lines.reduce(
(s, l) => s + l.quantityOrdered * l.unitPrice,
0
);
return {
id,
reference,
supplier,
site: HILLS,
status,
priority,
placedAt,
expectedAt,
totalValue,
notes: reqRef,
lines: lines.map((l, i) => ({
id: `${id}-l${i + 1}`,
quantityOrdered: l.quantityOrdered,
quantityDelivered: l.quantityDelivered,
unit: l.unit,
unitPrice: l.unitPrice,
description: l.description,
})),
deliveryNotes: [],
};
}
export const ORDERS: Order[] = [
mkOrder(
"o-megcpo9199",
"MEGCPO9199",
"MEGCREQ11428",
"IKH Holdings (05IKH001)",
"Health and Safety PPE",
"2026-08-11T07:00:00.000Z",
"2026-08-11T07:00:00.000Z",
"Delivered",
"Urgent",
[
{ description: "Red Flag", quantityOrdered: 2, quantityDelivered: 2, unit: "PR", unitPrice: 42 },
{ description: "Reflective Jacket", quantityOrdered: 3, quantityDelivered: 3, unit: "Box", unitPrice: 175.50 },
{ description: "Barrier Net Yellow and Orange", quantityOrdered: 4, quantityDelivered: 4, unit: "each", unitPrice: 180 },
{ description: "Dust Mask", quantityOrdered: 5, quantityDelivered: 5, unit: "Rolls", unitPrice: 68 },
]
),
mkOrder(
"o-megcpo9165",
"MEGCPO9165",
"MEGCREQ11388",
"Raumix Aggregates (05RAU0001)",
"G1, G6, 19MM Stone, 13MM Stone Builders Mix, Crusher Sand, Building Sand",
"2026-08-07T07:00:00.000Z",
"2026-08-07T07:00:00.000Z",
"Partial",
"High",
[
{ description: "G1 Base", quantityOrdered: 3093.3000, quantityDelivered: 0, unit: "ton", unitPrice: 231.80 },
{ description: "G6 Sub Base", quantityOrdered: 4273.8900, quantityDelivered: 0, unit: "ton", unitPrice: 168.50 },
{ description: "19mm Stone Stormwater", quantityOrdered: 85.1400, quantityDelivered: 0, unit: "ton", unitPrice: 265.50 },
{ description: "13 mm Stone Builders Mix Stormwater", quantityOrdered: 169.2000, quantityDelivered: 0, unit: "ton", unitPrice: 260.00 },
{ description: "Crusher Sand", quantityOrdered: 340, quantityDelivered: 0, unit: "ton", unitPrice: 221.13 },
{ description: "Building Sand", quantityOrdered: 120, quantityDelivered: 0, unit: "ton", unitPrice: 232.20 },
]
),
];
// ---------------------------------------------------------------------------
// Fuel readings — empty for now. Drop CSV-imported rows in here once the
// depot system is wired. The fuel anomaly detection in `lib/fuel.ts`
// already handles empty arrays without errors.
// ---------------------------------------------------------------------------
export const FUEL_READINGS: FuelReading[] = [];
export {
addDays,
@ -131,6 +436,4 @@ export {
subDays,
};
// Re-export the helper functions used elsewhere in the dashboard so the
// pages don't need to know whether data is present.
export type { DeliveryNote, Employee, FuelReading, Order, OrderLine, Site, Vehicle };
export type { DeliveryNote, Employee, FuelReading, Order, OrderLine, PlantHireRate, Site, Vehicle };

View file

@ -2,11 +2,14 @@ import type {
Employee,
FuelReading,
Order,
PgExpense,
PlantHireRate,
Site,
SiteFinancialSummary,
Vehicle,
} from "./types";
import { format, startOfMonth } from "date-fns";
import { burnBreakdown } from "./burn";
/**
* Compute a financial + operational rollup for a single site by joining
@ -23,6 +26,8 @@ export function summarizeSite(
employees: Employee[],
vehicles: Vehicle[],
fuelReadings: FuelReading[],
hireRates: PlantHireRate[],
pgExpenses: PgExpense[],
now: Date = new Date()
): SiteFinancialSummary {
const monthStart = format(startOfMonth(now), "yyyy-MM-dd");
@ -62,6 +67,8 @@ export function summarizeSite(
? 0
: (spentToDate / site.contractValue) * 100;
const burn = burnBreakdown(hireRates, siteVehicles, pgExpenses);
return {
site,
contractValue: site.contractValue,
@ -77,6 +84,12 @@ export function summarizeSite(
vehiclesOnSite: siteVehicles.length,
fuelCostThisMonth,
fuelLitresThisMonth,
weeklyBurn: burn.weekly,
monthlyBurn: burn.monthly,
weeklyPlantHire: burn.weeklyHire,
monthlyPlantHire: burn.monthlyHire,
weeklyPg: burn.weeklyPg,
monthlyPg: burn.monthlyPg,
};
}
@ -86,7 +99,9 @@ export function summarizeAllSites(
employees: Employee[],
vehicles: Vehicle[],
fuelReadings: FuelReading[],
hireRates: PlantHireRate[],
pgExpenses: PgExpense[],
now: Date = new Date()
): SiteFinancialSummary[] {
return sites.map((s) => summarizeSite(s, orders, employees, vehicles, fuelReadings, now));
return sites.map((s) => summarizeSite(s, orders, employees, vehicles, fuelReadings, hireRates, pgExpenses, now));
}

View file

@ -1,6 +1,18 @@
// Shared domain types for the MEGA INFRASTRUCTURE dashboard.
export type VehicleType = "Excavator" | "Tipper Truck" | "Loader" | "Generator" | "Grader" | "Bulldozer";
export type VehicleType =
| "Excavator"
| "Tipper Truck"
| "Loader"
| "Generator"
| "Grader"
| "Bulldozer"
| "Roller"
| "TLB"
| "Pickup"
| "Water Truck"
| "Compressor"
| "Mixer";
export type FuelUnit = "L" | "gal";
export interface Vehicle {
@ -10,6 +22,19 @@ export interface Vehicle {
model: string;
tankCapacityL: number;
assignedSite: string;
/**
* Optional km/L allowance for vehicles where fuel consumption is
* tracked by distance rather than hours (e.g. pickups, water trucks).
* If omitted, the fuel-anomaly check falls back to `hoursWorked * expectedLPerHour`.
*/
kmPerL?: number;
/**
* Optional fallback L/h allowance for plant where fuel is tracked
* against engine hours (excavators, rollers, TLBs, etc.).
* If omitted, vehicles without `kmPerL` are excluded from the anomaly
* detection rather than treated as zero-consumption.
*/
expectedLPerHour?: number;
}
export interface FuelReading {
@ -149,6 +174,35 @@ export interface SiteFinancialSummary {
vehiclesOnSite: number;
fuelCostThisMonth: number;
fuelLitresThisMonth: number;
weeklyBurn: number;
monthlyBurn: number;
weeklyPlantHire: number;
monthlyPlantHire: number;
weeklyPg: number;
monthlyPg: number;
}
/** Plant hire rate — what it costs per operational hour, in ZAR. */
export interface PlantHireRate {
vehicleId: string;
ratePerHour: number;
}
export type PgCategory =
| "container-hire"
| "security"
| "salaries"
| "overheads"
| "insurance"
| "site-facilities"
| "fuel";
export interface PgExpense {
id: string;
category: PgCategory;
label: string;
/** ZAR per calendar month. */
monthlyAmount: number;
}
export type UploadKind = "orders" | "delivery-notes" | "fuel-logs" | "time-sheets";

View file

@ -44,7 +44,20 @@ export const UPLOAD_ACCEPTANCE: Record<UploadKind, UploadAcceptance> = {
// /dashboard/uploads page will render them.
// ---------------------------------------------------------------------------
export const UPLOAD_HISTORY: UploadEntry[] = [];
export const UPLOAD_HISTORY: UploadEntry[] = [
// MEGCPO9199 was delivered in full on the same day it was raised —
// the source PO has a single delivery note attached.
{
id: "up-1",
kind: "delivery-notes",
fileName: "GRN-IKH-MEGCPO9199.pdf",
size: 458_911,
uploadedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 8).toISOString(),
uploadedBy: "P. Naidoo",
status: "Processed",
note: "Captured against MEGCPO9199",
},
];
// Helper for callers that want to filter by kind. Returns an empty array
// when `UPLOAD_HISTORY` is empty.