2026-10-06 02:02:08 +00:00
|
|
|
import Link from "next/link";
|
|
|
|
|
import { LayoutDashboard, TrendingUp, Wrench, Wallet, LineChart as LineChartIcon, Receipt, Boxes } from "lucide-react";
|
|
|
|
|
import { Sidebar, MobileNav, type NavItem } from "@/components/Sidebar";
|
|
|
|
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
|
|
|
import { GlobalSearch } from "@/components/GlobalSearch";
|
|
|
|
|
import { AccountMenu } from "@/components/AccountMenu";
|
|
|
|
|
import { getAssets } from "@/lib/server-data";
|
|
|
|
|
import { requireUser } from "@/lib/auth";
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
|
|
|
const navItems: NavItem[] = [
|
|
|
|
|
{
|
|
|
|
|
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" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Income",
|
|
|
|
|
icon: <TrendingUp className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/income", label: "Overview" },
|
Split the invoice register into Internal and External Invoices
Renames the existing /income/invoices page to "Internal Invoices" and adds a
sibling /income/external-invoices page, splitting ONE `invoices` collection by
the debtor printed on the invoice: a Mega group company is internal, anything
else is external.
Never two collections. An invoice must not be able to exist on neither page, and
the halves must stay reconcilable against /income. Both routes render through the
single components/InvoiceRegisterPage.tsx so they cannot drift apart.
lib/invoice-party-policy.json holds the group entity list and lib/invoice-party.ts
does the matching. Matching is on debtor name OR debtor_no, never name alone:
all three external invoices (PHI 178/186/192) carry an empty debtor name and only
a third-party number, so a name-only match would misfile every one of them. An
invoice naming neither is never internal - it lands on External and is reported
as unclassified rather than silently filed.
The split is 16 internal / 3 external across the 19 invoices in the register.
Also fixes three things the split would otherwise break:
- Booking a line, importing a PDF and retrying an intake can all land on either
side depending on the debtor, so the five write paths only revalidated
/income/invoices and would have left the other page stale. They now call
revalidateInvoicePages() from lib/invoice-revalidate.ts. Route paths live in
lib/invoice-routes.ts, which imports no next/* so a client component may import
it; the revalidate helper imports next/cache and is server-only.
- A site's "Go and assign them" link hard-coded the internal path, so a site whose
outstanding lines sit on external invoices would dead-end. SiteSummary now
carries the outstanding lines split by party.
- verify-auth-routes.mjs now covers the new route.
The two routes are siblings by design: isGroupActive matches a child by path
prefix, so a nested /income/invoices/external would highlight both leaves at once.
Verified: typecheck clean, 41 tests pass (7 new), production build green,
anonymous access to both routes 307s to /login, and the mobile checklist passes at
375x812 and 768x1024 - no horizontal page scroll, no tap target under 44px, no
text under 12px. The nested line-item table still scrolls inside its own container
without stretching the outer table.
Note: lib/rows.ts, lib/server-data.ts and the running/insurance feature are
someone else's in-flight work and were deliberately left uncommitted here.
2026-10-08 09:49:48 +00:00
|
|
|
// The invoice register is split in two by the debtor on the invoice — see
|
|
|
|
|
// lib/invoice-party-policy.json. The two are SIBLING routes on purpose: a nested
|
|
|
|
|
// path would satisfy isGroupActive's prefix test for both leaves at once and
|
|
|
|
|
// highlight both at the same time.
|
|
|
|
|
{ href: "/income/invoices", label: "Internal Invoices" },
|
|
|
|
|
{ href: "/income/external-invoices", label: "External Invoices" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
// Running Costs became a group so Insurance could sit under it. The Overview
|
|
|
|
|
// leaf stays at the EXISTING /running path — the current page becomes the
|
|
|
|
|
// overview, it does not move, so no bookmark or GlobalSearch target breaks.
|
|
|
|
|
{
|
|
|
|
|
kind: "group",
|
|
|
|
|
label: "Running Costs",
|
|
|
|
|
icon: <Wrench className="w-4 h-4" />,
|
|
|
|
|
children: [
|
|
|
|
|
{ href: "/running", label: "Overview" },
|
|
|
|
|
{ href: "/running/insurance", label: "Insurance" },
|
2026-10-06 02:02:08 +00:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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 DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
|
|
|
const { user } = await requireUser();
|
|
|
|
|
const assets = await getAssets();
|
|
|
|
|
return <>
|
|
|
|
|
{/* 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-2 sm:gap-6 px-4 sm:px-6 h-full">
|
|
|
|
|
{/* The hamburger must live HERE, in the full-bleed header — not in
|
|
|
|
|
the content flex row below, where it indented every page on
|
|
|
|
|
mobile. See MobileNav for the portal detail. */}
|
|
|
|
|
<MobileNav items={navItems} />
|
|
|
|
|
|
|
|
|
|
<Link href="/" className="flex items-center gap-2 shrink-0 min-h-11 min-w-11">
|
|
|
|
|
<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">Plant management</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="ml-auto flex items-center gap-2 sm: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>
|
|
|
|
|
<div className="hidden sm:block"><ThemeToggle /></div>
|
|
|
|
|
<AccountMenu name={typeof user.name === "string" ? user.name : ""} email={user.email} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div className="flex">
|
|
|
|
|
<Sidebar items={navItems} />
|
|
|
|
|
<main className="flex-1 min-w-0">{children}</main>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</>;
|
|
|
|
|
}
|