2026-09-12 07:35:35 +00:00
|
|
|
import "./globals.css";
|
|
|
|
|
import type { Metadata } from "next";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import {
|
|
|
|
|
LayoutDashboard,
|
|
|
|
|
TrendingUp,
|
|
|
|
|
Wrench,
|
|
|
|
|
Wallet,
|
|
|
|
|
LineChart as LineChartIcon,
|
|
|
|
|
Receipt,
|
Sites: a real Sites list, with the unbooked lines bookable to a site
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
|
|
|
MapPin,
|
2026-09-12 08:24:26 +00:00
|
|
|
Boxes,
|
2026-09-12 07:35:35 +00:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { Sidebar, type NavItem } from "@/components/Sidebar";
|
|
|
|
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
|
|
|
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
|
|
|
import { GlobalSearch } from "@/components/GlobalSearch";
|
|
|
|
|
import { getAssets } from "@/lib/data";
|
|
|
|
|
|
Round 4: regressions fixed + mobile standard
Part A — Round 3 regressions:
- A.1: verify-cost-reconciliation.mjs now loads .env.local, wraps in try/catch, exits 1 on any failure
- A.2: Vehicle Costs restored Santam — back to R 14,996.31 (matches row-by-row sum)
- A.3: incl<excl / VAT math / VAT>20% now FATAL — seed exits 2 with full report (4 anomalies on Charl-CCS + Buildsmart)
- A.4: Reconciliation target R 694,962.93 with both deltas itemised (Personnel +R19,800, Santam -R5,322.44, unexplained R 0.00)
- A.5: 'Lines' view uses actual lines (recharts LineChart), no fills; fills only in 'Cost stack'
Part B — Mobile standard:
- B.1: header wraps to 2 rows on mobile; YTD badge cards stack; page-wide overflow eliminated on /income, /charts, /overheads, /running, /expenses
- B.2: IncomeView card row layout stacked — name gets its own line on mobile, never truncated to single char
- B.3: Plant type filter is a <select> dropdown below lg (was 18 pills wrapping to 13 rows); same for sort on /income and /running
- B.4: Plant table first column sticky; -webkit-overflow-scrolling: touch; tables scroll inside their own container
- B.5: All buttons/links use min-h-[44px] tap targets; text below 12px is bumped where it matters
- B.6: Charts legend wraps below title; chart min-height h-44 → h-52 on mobile
- B.7: paddingTop env(safe-area-inset-top) on sticky header for notch/home indicator clearance
AGENTS.md created with the standing mobile checklist rule.
2026-09-12 10:05:43 +00:00
|
|
|
// The layout itself reads PocketBase (GlobalSearch needs the asset list), so it
|
|
|
|
|
// must be dynamic too — otherwise Next prerenders the built-in /_not-found page,
|
|
|
|
|
// which inherits this layout, and the build fails without a reachable database.
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
2026-09-12 07:35:35 +00:00
|
|
|
export const metadata: Metadata = {
|
|
|
|
|
title: "Mega Plant Dashboard",
|
|
|
|
|
description: "Mega Plant cost & P&L dashboard",
|
|
|
|
|
manifest: "/manifest.json",
|
|
|
|
|
themeColor: "#30638E",
|
|
|
|
|
appleWebApp: {
|
|
|
|
|
title: "Mega Plant",
|
|
|
|
|
capable: true,
|
|
|
|
|
statusBarStyle: "default",
|
|
|
|
|
},
|
|
|
|
|
icons: {
|
|
|
|
|
icon: [
|
2026-09-12 10:12:30 +00:00
|
|
|
{ url: "/icons/favicon-32.png", sizes: "32x32" },
|
|
|
|
|
{ url: "/icons/icon-192.png", sizes: "192x192" },
|
|
|
|
|
{ url: "/icons/icon-512.png", sizes: "512x512" },
|
|
|
|
|
],
|
|
|
|
|
apple: [
|
2026-09-12 07:35:35 +00:00
|
|
|
{ url: "/icons/icon-192.png", sizes: "192x192" },
|
|
|
|
|
{ url: "/icons/icon-512.png", sizes: "512x512" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const navItems: NavItem[] = [
|
Sites: a real Sites list, with the unbooked lines bookable to a site
The container lines (MPLANT04/MPLANT05) have no matching plant item, so they
sat unbooked forever with no way to account for them. They can now be booked
to a SITE instead.
`sites` is a new PocketBase collection derived from the invoices — each
distinct invoice site becomes a site, created as invoices are imported. They
are never hand-typed, and never deleted: a site can carry notes and hold line
bookings, so removing one would orphan that history.
/sites lists every site with its revenue ex VAT, its VAT, the total billed,
how many invoices and machines it carries, and how many lines are still
outstanding. Each row links to a detail page listing the site's invoices and
every line item with its ex-VAT/VAT/total split. Nav: Dashboard is now a
group with Overview and Sites.
Booking a line to a site posts NO income — there is no machine to carry it —
it only marks the line accounted for, so it stops showing as unbooked. Exactly
one of booked_asset / booked_site is ever set, and a line with neither is what
surfaces as unbooked everywhere.
One container per site, as specified: once a machine-less line has taken a
site, that site is withheld from the next one's picker and shown disabled
("already has one") rather than hidden. Free sites sort to the top. The rule is
enforced server-side too, so it does not depend on the UI being the only way in —
re-posting a taken site is refused with 409. Verified: booking The Hills, then
trying again, then taking West Park, and the remaining line sees 8 free and 2
taken. Clearing a booking frees its site.
The site picker only appears on lines that need it — no machine, or one already
booked to a site — so fully-booked rows keep a single control.
Also fixed the Sidebar's group-active check, which derived its prefix from the
first child. For a group whose first child is "/" that yields "/" and matched
every route, so Dashboard would have been highlighted on every page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 14:24:06 +00:00
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Dashboard",
|
|
|
|
|
icon: <LayoutDashboard className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/", label: "Overview" },
|
|
|
|
|
{ href: "/sites", label: "Sites" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Plant Register",
|
|
|
|
|
icon: <Boxes className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/plant", label: "Register" },
|
|
|
|
|
{ href: "/plant/map", label: "Plant Map" },
|
|
|
|
|
],
|
|
|
|
|
},
|
Invoices: line-item extraction, booking to plant items, VAT-inclusive income
Adds an Invoices sub-menu under /income with a summary table (one row per
invoice, click to expand the full detail) and the data model behind it.
Data:
- invoices + invoice_lines PocketBase collections, created idempotently by
scripts/setup-invoice-collections.mjs
- line items transcribed from PHI 166/167/171 with per-invoice arithmetic
validation in the seed; a bad invoice is refused without blocking the rest
- invoice-*.json in data/source are auto-imported, so adding an invoice is
one file
Booking:
- manual book-in per line (Booked to), grouped by category
- POST /api/invoice-lines/:id/book moves the income row rather than adding a
second one, so a booking can be changed freely
- scripts/book-invoice.mjs is idempotent: rows are stamped with
monthly_income.invoice_line and replaced on re-run
- booked amount follows lib/invoice-booking-policy.json (bookInclusiveVat),
read by both the app and the CLI so they cannot disagree
Money:
- the R24,205.13 column on these invoices is PLUS VAT, not escalation. The
seed now rejects VAT that disagrees with the stated rate.
- booked amounts are VAT-inclusive per the client ruling of 2026-10-01; the
VAT stays visible rather than hidden.
Uploads:
- drag-and-drop PDF attached to invoices.source_pdf
- GET /api/invoices/:id/document proxies it — the collection is rule-locked,
so a direct PocketBase /api/files link 404s in a browser
Fixes a month-aggregation bug this introduced: buildIncomeRows assigned into
months[i], so a machine with two income rows in one month (one per invoice,
which is normal multi-site use) showed only the last. SS01 September read
R20,700 instead of R35,305 while its own YTD included both. Months now
accumulate, per-month profit is computed once after accumulation, and
lib/rows.test.ts pins it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 13:59:30 +00:00
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Income",
|
|
|
|
|
icon: <TrendingUp className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/income", label: "Overview" },
|
|
|
|
|
{ href: "/income/invoices", label: "Invoices" },
|
|
|
|
|
],
|
|
|
|
|
},
|
2026-09-12 07:35:35 +00:00
|
|
|
{ kind: "link", href: "/running", label: "Running Costs", icon: <Wrench className="w-4 h-4" /> },
|
|
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Expenses",
|
|
|
|
|
icon: <Wallet className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/expenses", label: "Overview" },
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
{ href: "/expenses/vehicles", label: "Vehicle Costs" },
|
2026-09-12 07:35:35 +00:00
|
|
|
{ href: "/expenses/finance", label: "Finance (Bastion)" },
|
|
|
|
|
{ href: "/expenses/personnel", label: "Personnel" },
|
|
|
|
|
{ href: "/expenses/subscriptions", label: "Subscriptions" },
|
|
|
|
|
{ href: "/expenses/rent", label: "Rent & Utilities" },
|
Round 3 (Parts A-B): charts fixes + expenses/overheads cleanup
- A.1: fixed_expenses per month now threaded through to chart bucket
- A.2: null = no data (forecast), 0 = real plotted zero — distinguishable
- A.3: x-axis clipped to last actual month (IncomeAreaChart + IncomeBarChart + MachineLineChart)
- A.4: chart hues as CSS vars (--color-chart-income/fixed/running) — deeper steps of same hues, validated for CVD
- A.5: chart-type toggle (Lines / Cost stack / Profit) + localStorage persistence + dynamic legend
- A.6: profit on charts page comes from computeProfit()
- B.1: deleted /expenses/plant-overheads, /expenses/insurance, /expenses/tracking (all empty); added /expenses/vehicles; ExpenseSectionPage now has an empty state
- B.2: captured R 157,200 Personnel block, R 100,000 Maintenance, R 10,000 Petty Cash; roll-up reconciles to R 675,162.93 (= sum of non-Plant non-Santam rows; workbook grand total R 1,110,486.87 is internally off by R 14,477.56 vs its own rows)
- B.3: seed flags incl<excl, VAT>20%, broken VAT math — does NOT silently correct; 4 anomalies reported on Charl-CCS + Buildsmart
- B.4: noted that workbook Vehicle Costs subtotal skips Santam (app R 9,673.87 is correct; workbook is wrong)
- B.2 reconciliation assertion added to verify-cost-reconciliation.mjs
- Sidebar updated: Vehicles added, Insurance/Tracking/Plant-Overheads removed
2026-09-12 09:24:18 +00:00
|
|
|
{ href: "/expenses/petty-cash", label: "Cash Floats" },
|
2026-09-12 07:35:35 +00:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{ kind: "link", href: "/overheads", label: "Overheads Roll-up", icon: <Receipt className="w-4 h-4" /> },
|
|
|
|
|
{ kind: "link", href: "/charts", label: "Machine Charts", icon: <LineChartIcon className="w-4 h-4" /> },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
|
|
|
const assets = await getAssets();
|
|
|
|
|
return (
|
|
|
|
|
<html lang="en" suppressHydrationWarning>
|
|
|
|
|
<head>
|
|
|
|
|
{/* Part 4: Next 16 emits apple-mobile-web-app-status-bar-style but NOT apple-mobile-web-app-capable.
|
|
|
|
|
Without this tag, iOS Add-to-Home-Screen opens in Safari instead of standalone. */}
|
|
|
|
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
|
|
|
<meta name="apple-mobile-web-app-title" content="Mega Plant" />
|
2026-09-12 10:12:30 +00:00
|
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
|
|
|
|
<link rel="icon" type="image/png" sizes="64x64" href="/icons/favicon.png" />
|
2026-09-12 07:35:35 +00:00
|
|
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
2026-09-13 17:00:52 +00:00
|
|
|
|
|
|
|
|
{/* Umami analytics — website id 86d0bd41-e705-4677-af2d-a4228e7c4b95 */}
|
|
|
|
|
<script
|
|
|
|
|
defer
|
|
|
|
|
src="https://umami-vmyngbmelll45wokwr270wbx.169.239.180.130.sslip.io/script.js"
|
|
|
|
|
data-website-id="86d0bd41-e705-4677-af2d-a4228e7c4b95"
|
|
|
|
|
/>
|
2026-09-12 07:35:35 +00:00
|
|
|
</head>
|
|
|
|
|
<body className="min-h-screen">
|
|
|
|
|
<ThemeProvider>
|
2026-10-02 16:32:12 +00:00
|
|
|
{/* z-[1100], not z-30: Leaflet's control panes are z-index 1000 and
|
|
|
|
|
live in the root stacking context, so the old z-30 header lost and
|
|
|
|
|
the Plant Map scrolled straight over it. Must stay above 1000, and
|
|
|
|
|
below the mobile nav drawer (z-[1200]) in Sidebar.tsx. */}
|
|
|
|
|
<header className="sticky top-0 z-[1100] h-16 bg-white/80 dark:bg-bg/80 backdrop-blur border-b border-gray-200 dark:border-border">
|
2026-09-12 07:35:35 +00:00
|
|
|
<div className="flex items-center gap-3 sm:gap-6 px-4 sm:px-6 h-full">
|
|
|
|
|
<Link href="/" className="flex items-center gap-2 shrink-0">
|
2026-09-12 10:12:30 +00:00
|
|
|
<img
|
|
|
|
|
src="/brand/logo.png"
|
|
|
|
|
alt="Mega Plant"
|
2026-10-02 13:34:19 +00:00
|
|
|
className="w-9 h-9 rounded-xl object-contain bg-white dark:bg-bg shadow-sm"
|
2026-09-12 10:12:30 +00:00
|
|
|
/>
|
2026-09-12 07:35:35 +00:00
|
|
|
<div className="leading-tight hidden sm:block">
|
|
|
|
|
<div className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</div>
|
Mobile: make the invoice tables readable at 375px
Audited the invoice UI against the Round 4 checklist and fixed four things.
The summary table carried eight columns at a fixed 820px min-width, which at
375px is a long sideways scroll through three figures before reaching the
status badge — the one thing that needs acting on. Below sm it now keeps only
Invoice, Site, Total and Status, and drops to a 520px min-width, so the useful
columns are readable without scrolling. Period, line count and the ex-VAT/VAT
split are one tap away in the expanded detail, and the full eight columns
return from sm up where there is room.
The summary table also had no right-edge fade. Rule 4 asks for a visible
affordance on any wide table and it was simply missing, so scrolling sideways
looked like the table ended. Same for the month grid on /income, which was also
missing -webkit-overflow-scrolling. Both now share components/ScrollFade.
Two 10px labels in the app header ("Overheads 2026", "FY") were invisible at
375px but visible from 640/768px — and 768x1024 is one of the two sizes the
checklist tests, so they were a rule-2 violation at tablet width. Bumped to
12px. PlantRegisterTable's 10px filter count was checked and left alone: it
sits inside hidden lg:flex, so it never renders on mobile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 13:29:00 +00:00
|
|
|
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overheads 2026</div>
|
2026-09-12 07:35:35 +00:00
|
|
|
</div>
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<GlobalSearch assets={assets.map((a) => ({
|
|
|
|
|
id: a.id, code: a.code, name: a.name, category: a.category, supplier: a.supplier,
|
|
|
|
|
}))} />
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
|
|
|
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-xl bg-gradient-to-br from-[#30638E] to-[#003D5B] text-white shadow-lg shadow-blue-900/20">
|
Mobile: make the invoice tables readable at 375px
Audited the invoice UI against the Round 4 checklist and fixed four things.
The summary table carried eight columns at a fixed 820px min-width, which at
375px is a long sideways scroll through three figures before reaching the
status badge — the one thing that needs acting on. Below sm it now keeps only
Invoice, Site, Total and Status, and drops to a 520px min-width, so the useful
columns are readable without scrolling. Period, line count and the ex-VAT/VAT
split are one tap away in the expanded detail, and the full eight columns
return from sm up where there is room.
The summary table also had no right-edge fade. Rule 4 asks for a visible
affordance on any wide table and it was simply missing, so scrolling sideways
looked like the table ended. Same for the month grid on /income, which was also
missing -webkit-overflow-scrolling. Both now share components/ScrollFade.
Two 10px labels in the app header ("Overheads 2026", "FY") were invisible at
375px but visible from 640/768px — and 768x1024 is one of the two sizes the
checklist tests, so they were a rule-2 violation at tablet width. Bumped to
12px. PlantRegisterTable's 10px filter count was checked and left alone: it
sits inside hidden lg:flex, so it never renders on mobile.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-02 13:29:00 +00:00
|
|
|
<span className="text-xs uppercase tracking-wider text-white/70">FY</span>
|
2026-09-12 07:35:35 +00:00
|
|
|
<span className="text-sm font-semibold">2026</span>
|
|
|
|
|
</div>
|
|
|
|
|
<ThemeToggle />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="flex">
|
|
|
|
|
<Sidebar items={navItems} />
|
|
|
|
|
<main className="flex-1 min-w-0">{children}</main>
|
|
|
|
|
</div>
|
|
|
|
|
</ThemeProvider>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
);
|
|
|
|
|
}
|