mega-plant/app/layout.tsx

159 lines
6.1 KiB
TypeScript
Raw Normal View History

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,
Boxes,
} 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";
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";
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: [
{ url: "/icons/favicon-32.png", sizes: "32x32" },
{ url: "/icons/icon-192.png", sizes: "192x192" },
{ url: "/icons/icon-512.png", sizes: "512x512" },
],
apple: [
{ 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" },
],
},
{ 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" },
{ href: "/expenses/vehicles", label: "Vehicle Costs" },
{ href: "/expenses/finance", label: "Finance (Bastion)" },
{ href: "/expenses/personnel", label: "Personnel" },
{ href: "/expenses/subscriptions", label: "Subscriptions" },
{ href: "/expenses/rent", label: "Rent & Utilities" },
{ href: "/expenses/petty-cash", label: "Cash Floats" },
],
},
{ 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" />
<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" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
{/* 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"
/>
</head>
<body className="min-h-screen">
<ThemeProvider>
{/* 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">
<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">
<img
src="/brand/logo.png"
alt="Mega Plant"
className="w-9 h-9 rounded-xl object-contain bg-white dark:bg-bg shadow-sm"
/>
<div className="leading-tight hidden sm:block">
<div className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</div>
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overheads 2026</div>
</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">
<span className="text-xs uppercase tracking-wider text-white/70">FY</span>
<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>
);
}