Global site scope: one switcher in the topbar, carried in the URL as
?site=, resolved server-side via the getSiteScope() seam, and applied as
a PocketBase site= filter across every dashboard module. Replaces the
four independent per-table project-code dropdowns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
# docs/superpowers/plans/2026-09-11-site-scope.md
Three review findings, all of which break the moment `allowed` stops
being every site — i.e. as soon as PocketBase users land.
siteIdFilter(): the Overview spelled its `sites` filter out as
`mode === "all" ? "" : id="..."`, which drops the `unrestricted` check
that makes "all" safe and would load every site for a manager allowed
only a subset. Both field variants now go through one shared function so
the check cannot be forgotten again. Covered by six new tests.
Site pickers read scope.allowed: upload and create dialogs took their
options from unfiltered `sites` reads, so a restricted user could
attribute a PO, delivery note, timesheet or plant item to a site they
cannot even open — a write path, not just a display leak. View scope
must not narrow a picker, but permission scope must. Also drops one
redundant sites query on the equipment page.
useScopeFallback(): the pill fell back to a value the layout computes
once, and layouts do not re-render on navigation — so after any link
that doesn't carry ?site= forward (24 of them), the pill read
"All sites" over a single site's figures. It now reads the live cookie,
which the switcher keeps current, fixing every link rather than the four
scopeAwareHref already patched.
Verified against the running app: scope survives a client-side
navigation through a bare link, and the Overview reads 1/"The Hills
ext 12" scoped and 2/"Across every site" unscoped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scans a worker's existing QR badge to clock them in/out, writing
directly into timesheet_entries as a "Direct (QR)" entry — the
timekeeping app foreshadowed in the workers-timesheets-qr spec.
Bug: picking a site in the topbar pill updated both URL and cookie,
but the sidebar nav Links were plain paths ('/dashboard/sites',
'/dashboard/financial/purchase-orders', ...). Clicking any of them
stripped the '?site=' query string. The page's getSiteScope() then
fell back to the still-set cookie and filtered the data correctly,
but the pill's useSearchParams() and the layout's frozen fallbackValue
both produced mode='all' — so the topbar pill showed 'All sites · N'
while every table was filtered to the picked site. The visual pill
and the data disagreed on every screen the user clicked.
Fix: extract scopeAwareHref() and route every sidebar Link through
it. The helper carries the current '?site=' (and nothing else) into
the destination URL, so the URL stays the single source of truth
for both the page filter and the topbar pill across navigation.
Layouts do not re-render on navigation, so preserving the URL is
the only viable fix — making the layout re-run getSiteScope() on
every navigation is what the plan explicitly avoided by design.
Tests: 5 new unit tests for scopeAwareHref covering the bare-path,
scope-only, drop-other-params, url-encoding, and 'all' sentinel
cases. Total: 651 tests passing, typecheck clean.
The 'use server'/'use client' boundary for `next/headers` is whole-module:
the build fails when any client-reachable file transitively imports a
module that uses `next/headers`. The dashboard switcher is a 'use
client' component and was importing from `@/lib/site-scope`, which
pulled in `next/headers` via the inline `getSiteScope` server entry.
Move `getSiteScope` to a new `src/lib/site-scope-server.ts` so the
pure helpers (resolveActive, siteFilter, composeFilter, filterOpt, types)
and constants stay client-safe in `site-scope.ts`. Every page that
needed `getSiteScope` now imports it from `-server`; the pure imports
stay where they were.
Build verified: `npm run build` succeeds; typecheck and 646 tests pass.
Re-verified every file:line against the current tree. Three were wrong:
the Overview ranges in task 6 (and it listed two sites.items uses where
there are four), the hamburger line in task 5, and a claim that
plant/page.tsx still had perPage: 5000 caps — it does not.
Also records that tasks 1-5 have landed, so the next worker starts at 6,
and keeps the scope-aware Sites sub-label that was implemented in place
of the deletion this plan originally specified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the per-site project month/week feature: a configurable work-month
start day, a shared Month/Week badge, and a Today/This week/This
month/All time filter on Timesheets, Delivery Notes, Purchase Orders,
and Fuel Logs. Also includes the (not-yet-implemented) site-scope
switcher spec/plan docs.
The migration's docstring pointed at a spec file that was never
written for this feature (the design was worked out via conversation,
not a separate spec doc) — points at the plan instead.
A concurrent project Month/Week feature is landing in the same view
files and has already shifted purchasing-view.tsx by ~10 lines, adding
a periodFilter beside projectFilter. The removal steps now match on
identifiers and snippets, and the plan header says to treat every
file:line as a hint.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 TDD tasks: pure scope helpers, the getSiteScope seam, the topbar
switcher, then per-module server-side filtering. Task 15 records why
the chat agent's default-site change is deferred out of this plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the four independent per-table project-code dropdowns with a
single global scope carried in the URL as ?site=, resolved server-side,
and applied as a PocketBase site= filter. Defines the getSiteScope()
seam so the separate PocketBase users work plugs in without touching
the 23 Topbar call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the "Upload status" buttons on Purchase Orders and Delivery Notes,
the shared badge/KPI/filter UI they use, and removes the now-dead
Uploads sidebar entry and Financial landing card.
Purchase Orders and Delivery Notes each get their own unlinked
"upload status" page reached via a button next to the upload action,
mirroring the Timesheets pattern. Removes the combined
/dashboard/financial/uploads page and its sidebar entry, since its two
halves (PO status, DN status) are now scoped to the page they belong to.
Timesheets now shows only entries + filters; an "Upload status" button
next to Import Timesheets opens the sheet-by-sheet processing view on
its own route, not listed in the sidebar.
Workers becomes a lean roster (name, employee number, position, type,
site, rate) with filters unchanged. Everything currently cluttering the
list row -- total hours, cost, the 8-week sparkline, per-employee entry
history -- moves to a new worker detail page that doesn't exist yet
(today's "detail" is a dialog). Same pattern already used for Suppliers
and Vehicles/Equipment this session: lean list, detail page carries
depth.
Timesheets keeps its flat entries table but gains a genuinely separate
upload-status section -- the timesheets document collection already has
this data, it's just currently squeezed into a per-row badge instead of
being its own thing. The duplicated Import-timesheets button (currently
on both pages) moves here only.
QR badges: encodes employee_no directly, no new PocketBase field --
every worker already has a valid code the moment they exist, with no
generation step. Explicitly a deliberate tradeoff (guessable numbers,
no revocation) the user chose for now, pending seeing how the separate
timekeeping PWA wants identity handled -- moving to a stored opaque
token later is additive, not a rework. Card layout confirmed via the
visual companion (centered, QR-forward). Both single-worker and
batch-sheet print confirmed in scope. Rendering via the qrcode npm
package + the browser's own print dialog (@media print), not a PDF
library -- CSS handles real-world card dimensions more reliably than
fighting a PDF engine's coordinate system.
The timekeeping PWA itself is explicitly out of scope -- a separate
project this spec only needs to stay compatible with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Card is rounded-xl with no overflow-hidden. TableHeader is sticky with
its own opaque background (bg-card/95) that sits flush against the
card's edge -- with nothing clipping it, the header's square background
corners poke past the card's rounded ones. Visible on every page built
with the shared <Card><CardContent className="p-0"><Table> pattern:
Suppliers, Supplier Detail, Uploads, Delivery Notes, Fuel Logs,
Timesheets. Purchase Orders is unaffected -- it uses a card-per-row
layout, not an HTML table.
Added overflow-hidden to each affected Card. Standard, deterministic
Tailwind behavior -- reliably clips child content to the parent's
border-radius, no visual regression risk.
Also added click-to-sort on the Suppliers list (Supplier/POs/Ordered/
Delivered/Outstanding, toggling asc/desc) -- the page previously only
supported a fixed server-side default (outstanding descending) with no
way to change it. No existing sort-header convention exists elsewhere
in the app, so this is self-contained rather than a premature shared
abstraction for what is still a single use site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>