diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 01ed4f6..6ab1f68 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -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 } - icon={Wallet} + icon={Banknote} tone="default" /> + +
@@ -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 ( +
+ + + + + Burn rate — weekly vs monthly + + + Plant hire at 45h/week per unit + P&G monthly overheads, applied to every active unit on the project. + + + +
+
+
+ This week + {formatNumber((burn.weekly / Math.max(1, contractValue)) * 100)}% of PO value +
+

{formatCurrency(burn.weekly)}

+
    +
  • + Plant hire + {formatCurrency(burn.weeklyHire)} +
  • +
  • + P&G (weekly share) + {formatCurrency(burn.weeklyPg)} +
  • +
+
+
+
+ This month + {formatNumber((burn.monthly / Math.max(1, contractValue)) * 100)}% of PO value +
+

{formatCurrency(burn.monthly)}

+
    +
  • + Plant hire + {formatCurrency(burn.monthlyHire)} +
  • +
  • + P&G (monthly) + {formatCurrency(burn.monthlyPg)} +
  • +
+
+
+
+
+ + + Cost breakdown + Where the burn comes from + + +
    +
  • + Plant hire (weekly × {VEHICLES.filter((v) => PLANT_HIRE_RATES.some((r) => r.vehicleId === v.id)).length} units) + {formatCurrency(burn.weeklyHire)} +
  • +
  • + P&G (monthly) + {formatCurrency(burn.monthlyPg)} +
  • +
  • + Total / month + {formatCurrency(burn.monthly)} +
  • +
  • + Fuel (per L) + R25.65 +
  • +
+
+
+
+ ); +} diff --git a/src/app/dashboard/sites/[id]/page.tsx b/src/app/dashboard/sites/[id]/page.tsx index 9186712..40708dc 100644 --- a/src/app/dashboard/sites/[id]/page.tsx +++ b/src/app/dashboard/sites/[id]/page.tsx @@ -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] ); diff --git a/src/app/dashboard/sites/page.tsx b/src/app/dashboard/sites/page.tsx index 961a465..afa5f65 100644 --- a/src/app/dashboard/sites/page.tsx +++ b/src/app/dashboard/sites/page.tsx @@ -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("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), [] ); diff --git a/src/lib/burn.ts b/src/lib/burn.ts new file mode 100644 index 0000000..27fb741 --- /dev/null +++ b/src/lib/burn.ts @@ -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, + }; +} diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index 7136541..86f7d6b 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -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 = { -// 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> +): 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 }; diff --git a/src/lib/sites.ts b/src/lib/sites.ts index a0185cf..437d7ba 100644 --- a/src/lib/sites.ts +++ b/src/lib/sites.ts @@ -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)); } \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/types.ts index 190f201..764126e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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"; diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts index d83d667..f9bee97 100644 --- a/src/lib/uploads.ts +++ b/src/lib/uploads.ts @@ -44,7 +44,20 @@ export const UPLOAD_ACCEPTANCE: Record = { // /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.