2026-09-12 07:35:35 +00:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
import { createPortal } from "react-dom";
|
2026-09-12 07:35:35 +00:00
|
|
|
|
import Link from "next/link";
|
|
|
|
|
|
import { usePathname } from "next/navigation";
|
|
|
|
|
|
import { ChevronDown, Menu, X } from "lucide-react";
|
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
|
|
|
|
|
|
|
export type NavLeaf = { href: string; label: string };
|
|
|
|
|
|
export type NavItem =
|
|
|
|
|
|
| { kind: "link"; href: string; label: string; icon: React.ReactNode }
|
|
|
|
|
|
| { kind: "group"; label: string; icon: React.ReactNode; children: NavLeaf[] };
|
|
|
|
|
|
|
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
|
|
|
|
// A group is active when the current path is one of its children, or sits under one
|
|
|
|
|
|
// of them. A child of "/" only matches exactly — otherwise every route would count as
|
|
|
|
|
|
// "under" the dashboard.
|
|
|
|
|
|
function isGroupActive(item: Extract<NavItem, { kind: "group" }>, pathname: string): boolean {
|
|
|
|
|
|
return item.children.some((c) =>
|
|
|
|
|
|
c.href === "/" ? pathname === "/" : pathname === c.href || pathname.startsWith(`${c.href}/`),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
// The desktop rail and the mobile drawer render the same list from this one
|
|
|
|
|
|
// component, so the two navs can never drift apart.
|
|
|
|
|
|
function NavList({ items, onNavigate }: { items: NavItem[]; onNavigate?: () => void }) {
|
2026-09-12 07:35:35 +00:00
|
|
|
|
const pathname = usePathname();
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
return (
|
2026-09-12 07:35:35 +00:00
|
|
|
|
<nav className="p-3 space-y-0.5 text-sm">
|
|
|
|
|
|
{items.map((item, i) =>
|
|
|
|
|
|
item.kind === "link" ? (
|
|
|
|
|
|
<LeafLink
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
href={item.href}
|
|
|
|
|
|
label={item.label}
|
|
|
|
|
|
icon={item.icon}
|
|
|
|
|
|
active={pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href))}
|
|
|
|
|
|
onClick={onNavigate}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
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
|
|
|
|
<Group key={i} item={item} active={isGroupActive(item, pathname)} />
|
2026-09-12 07:35:35 +00:00
|
|
|
|
)
|
|
|
|
|
|
)}
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
);
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
}
|
2026-09-12 07:35:35 +00:00
|
|
|
|
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Desktop rail only. It is `hidden lg:block`, so it contributes ZERO width below
|
|
|
|
|
|
* `lg` and must stay a pure sibling of `<main>` — anything visible on mobile
|
|
|
|
|
|
* rendered in this flex row steals width from the page. The mobile trigger lives
|
|
|
|
|
|
* in the header instead; see `MobileNav`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function Sidebar({ items }: { items: NavItem[] }) {
|
2026-09-12 07:35:35 +00:00
|
|
|
|
return (
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
|
|
|
|
|
|
<NavList items={items} />
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-12 07:35:35 +00:00
|
|
|
|
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* Mobile nav: the hamburger plus the drawer it opens. Rendered INSIDE the header,
|
|
|
|
|
|
* which is the whole point — the old version put the button in the content flex
|
|
|
|
|
|
* row, where its 36px was subtracted from every page below `lg` and indented the
|
|
|
|
|
|
* entire site on phones and tablets.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The header is full-bleed, so a 44px trigger there costs no content width.
|
|
|
|
|
|
* `-ml-3` pulls the hit area into the header's own `px-4`, which lands the icon's
|
|
|
|
|
|
* left edge exactly on the logo's (16px) without changing the logo's position.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function MobileNav({ items }: { items: NavItem[] }) {
|
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
// Close drawer on navigation
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setOpen(false);
|
|
|
|
|
|
}, [pathname]);
|
|
|
|
|
|
|
|
|
|
|
|
// Scroll lock, plus the SSR gate for the portal below — `document` does not
|
|
|
|
|
|
// exist while this component is server-rendered.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setMounted(true);
|
|
|
|
|
|
document.body.style.overflow = open ? "hidden" : "";
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.body.style.overflow = "";
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-09-12 07:35:35 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
onClick={() => setOpen(true)}
|
2026-09-12 07:35:35 +00:00
|
|
|
|
aria-label="Open navigation"
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
aria-expanded={open}
|
|
|
|
|
|
className="lg:hidden -ml-3 w-11 h-11 shrink-0 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
|
2026-09-12 07:35:35 +00:00
|
|
|
|
>
|
|
|
|
|
|
<Menu className="w-5 h-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
{/* ⚠️ THE PORTAL IS LOAD-BEARING — do not "simplify" it back to inline JSX.
|
|
|
|
|
|
The header carries `backdrop-blur`, and a filter/backdrop-filter makes an
|
|
|
|
|
|
element the containing block for its `position: fixed` descendants. Inline,
|
|
|
|
|
|
this drawer would size and position itself against the 64px header box
|
|
|
|
|
|
instead of the viewport — a 64px-tall sliver pinned under the header.
|
|
|
|
|
|
Portalling to <body> returns it to the root stacking context.
|
|
|
|
|
|
|
|
|
|
|
|
This is the same trap class as MachineCombobox: a portal is NOT a DOM
|
|
|
|
|
|
descendant of the component that made it, so any hit-testing that reaches
|
|
|
|
|
|
across this boundary (click-away, focus traps) will read as "outside". */}
|
|
|
|
|
|
{mounted &&
|
|
|
|
|
|
createPortal(
|
|
|
|
|
|
<>
|
|
|
|
|
|
{open && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
|
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<aside
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
|
|
|
|
|
|
open ? "translate-x-0" : "-translate-x-full"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<img
|
|
|
|
|
|
src="/brand/logo.png"
|
|
|
|
|
|
alt="Mega Plant"
|
|
|
|
|
|
className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
|
aria-label="Close navigation"
|
2026-10-21 04:39:15 +00:00
|
|
|
|
className="w-11 h-11 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
|
Mobile: the menu button was indenting every page
The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.
Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.
Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.
The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.
- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
backdrop-blur, and a backdrop-filter makes an element the containing block for
its position:fixed descendants — inline, the drawer would have sized itself to
the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
from 247px to 215px at 375px, and the search is the only thing that yields.
Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
2026-10-03 14:08:04 +00:00
|
|
|
|
>
|
|
|
|
|
|
<X className="w-5 h-5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<NavList items={items} onNavigate={() => setOpen(false)} />
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</>,
|
|
|
|
|
|
document.body,
|
2026-09-12 07:35:35 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function LeafLink({
|
|
|
|
|
|
href, label, icon, active, onClick,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
href: string; label: string; icon: React.ReactNode; active: boolean; onClick?: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href={href}
|
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
|
className={cn(
|
2026-10-21 04:39:15 +00:00
|
|
|
|
"min-h-11 flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
|
2026-09-12 07:35:35 +00:00
|
|
|
|
active
|
|
|
|
|
|
? "bg-gradient-to-r from-[#30638E]/10 to-[#00798C]/5 text-[#003D5B] dark:from-[#30638E]/20 dark:to-[#00798C]/10 dark:text-white"
|
|
|
|
|
|
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-10-02 13:34:19 +00:00
|
|
|
|
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{icon}</span>
|
2026-09-12 07:35:35 +00:00
|
|
|
|
<span>{label}</span>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Group({ item, active }: { item: Extract<NavItem, { kind: "group" }>; active: boolean }) {
|
|
|
|
|
|
const [open, setOpen] = useState(active);
|
|
|
|
|
|
const pathname = usePathname();
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setOpen((o) => !o)}
|
|
|
|
|
|
className={cn(
|
2026-10-21 04:39:15 +00:00
|
|
|
|
"w-full min-h-11 flex items-center gap-3 px-3 py-2 rounded-lg font-medium transition",
|
2026-09-12 07:35:35 +00:00
|
|
|
|
active
|
|
|
|
|
|
? "text-[#003D5B] dark:text-white"
|
|
|
|
|
|
: "text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
2026-10-02 13:34:19 +00:00
|
|
|
|
<span className={cn(active ? "text-accent-ink" : "text-gray-400")}>{item.icon}</span>
|
2026-09-12 07:35:35 +00:00
|
|
|
|
<span className="flex-1 text-left">{item.label}</span>
|
|
|
|
|
|
<ChevronDown className={cn("w-4 h-4 text-gray-400 transition-transform", open && "rotate-180")} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{open && (
|
2026-10-02 13:34:19 +00:00
|
|
|
|
<div className="ml-7 mt-0.5 space-y-0.5 border-l-2 border-gray-100 dark:border-border pl-2">
|
2026-09-12 07:35:35 +00:00
|
|
|
|
{item.children.map((child) => {
|
|
|
|
|
|
const childActive = pathname === child.href;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Link
|
|
|
|
|
|
key={child.href}
|
|
|
|
|
|
href={child.href}
|
|
|
|
|
|
className={cn(
|
2026-10-21 04:39:15 +00:00
|
|
|
|
"flex items-center min-h-11 px-3 py-1.5 rounded-lg text-xs font-medium transition",
|
2026-09-12 07:35:35 +00:00
|
|
|
|
childActive
|
|
|
|
|
|
? "bg-[#30638E]/10 text-[#30638E] dark:bg-[#00798C]/15 dark:text-[#00798C]"
|
|
|
|
|
|
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white"
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{child.label}
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|