SCR-003 — Orders list · web (oms-web) · app/orders/page.tsx

The screen is unchanged in layout. What this ticket adds is a gate in front of it and a conditional inside it: the route requires Orders:View, and the one create entry point in PageHeader requires Orders:Create. Nothing else on this page is permission-sensitive — search, sort, filter and pagination are deliberately left alone (pin 4).

Artboard A draws the changed state, not the shipping one. A Manufacturer · Admin sees exactly today's page, so drawing that would produce a mockup with nothing in it to review. The frame instead shows a Supplier · Staff user holding Orders:View only: no "New Order" button, and a sidebar filtered to the single entry they may open. Pin 3 puts the two header states side by side so the delta is one comparison, not two files.

Two open decisions came out of drawing this and are flagged rather than silently resolved: row-level data scoping — should a Supplier user see all orders or only their own (5) — and the supplier/project master lists leaking through the filter panel to a user without Supplier:View (6). Neither blocks the frame; both change what the frame means.

Viewport 1440 × 900 (desktop only) · tokens from tailwind.config.ts + app/globals.css · shell from app/layout.tsx + components/layout/Sidebar.tsx · page transcribed from app/orders/page.tsx with PageHeader/SearchInput/FilterBar/DataTable/StatusBadge/Pagination · data shape from lib/queries/orders.ts, paging defaults from lib/params.ts · fields["SCR-003"] is [] — the list has no form of its own; its only inputs are the shared search box and the FilterBar panel, both drawn from the real components (see 6)
Artboard A — 1440 × 900 · Supplier · Staff, permissions = { Orders:View }. Clean frame, no annotation overlay — diffable by W21_ui_verify.

Orders

Search by order no...
Filters
Order No Date Supplier Project Status Due Date
OMS-2026-00004726/08/2026Meridian Steel Co.Riverside Clinic Fit-outPending09/09/2026
OMS-2026-00004625/08/2026Ardent FastenersNorthgate WarehousePartially Fulfilled05/09/2026
OMS-2026-00004524/08/2026Northgate TimberDepot Cold StorageFulfilled01/09/2026
OMS-2026-00004421/08/2026Meridian Steel Co.Harbour Point RetrofitPending
OMS-2026-00004320/08/2026Coastline ElectricalsRiverside Clinic Fit-outCancelled03/09/2026
OMS-2026-00004219/08/2026Ardent FastenersDepot Cold StorageFulfilled28/08/2026
OMS-2026-00004118/08/2026Northgate TimberNorthgate WarehousePartially Fulfilled02/09/2026
OMS-2026-00004017/08/2026Meridian Steel Co.Riverside Clinic Fit-outFulfilled27/08/2026
OMS-2026-00003914/08/2026Coastline ElectricalsHarbour Point RetrofitPending
OMS-2026-00003813/08/2026Ardent FastenersDepot Cold StorageFulfilled25/08/2026
OMS-2026-00003712/08/2026Northgate TimberNorthgate WarehouseCancelled24/08/2026
OMS-2026-00003611/08/2026Meridian Steel Co.Riverside Clinic Fit-outPartially Fulfilled26/08/2026
Showing 1–12 of 12
Prev Page 1 of 1 Next

1What Artboard A asserts

Five claims, each checkable against the frame above. If any one of them is wrong the frame is wrong — that is the point of listing them rather than describing the screen in prose.

Element Permission Today Drawn in Artboard A
Sidebar "Orders" entry Orders:View NAV_GROUPS is a hardcoded literal in components/layout/Sidebar.tsx; every user sees all seven entries. Present and active (bg-accent-subtle, font-medium) — this user holds the permission. Everything else is gone, including the entire Admin group label. SCR-027 owns that behaviour; it is drawn here because a filtered page under an unfiltered menu would be incoherent.
The route itself Orders:View middleware.ts only checks that a session cookie exists. Any authenticated user may GET /orders. Renders. The denied case has no pixels on this screen — it is SCR-028's outcome, drawn small in 3.
"New Order" button Orders:Create Always rendered: <PageHeader title="Orders" action={<Link href="/orders/new" className="btn-primary">} /> (app/orders/page.tsx:43-50). Absent. The action prop is not passed, and PageHeader already renders null in that case (PageHeader.tsx:9) — so the title stays left-aligned, the mb-6 gap is unchanged, and no placeholder or disabled ghost is left behind.
Order No cell → detail Orders:View <Link href={`/orders/${r.id}`} className="link"> — always a link. Still a link. Drawn assumption: Orders:View covers both the list and the per-record detail (SCR-005), so a user who can see the row can open it. See 7.
Rows returned (none) listOrders() builds its where from search/filter params only — no ownership or tenancy predicate anywhere (lib/queries/orders.ts:5-19). Unscoped: all 12 orders, from four different suppliers. This is the frame's most consequential assumption and the easiest to miss — 5.

2Where the gate lives — four layers, one screen

The acceptance criterion is "the user should not be able to access the functionality directly through a URL", so hiding the button is the least important of these four. Layers 1 and 2 are the only ones drawn (they are the only ones with pixels); 3 and 4 are listed because a mockup that showed only the hidden button would be read as the whole change.

One registry, not four lists. The menu entry, the route guard and the action check must read the same permission constant. If the sidebar says a user may open /orders but the route guard demands a differently-named permission, the user gets bounced off a menu item they can see — and under SCR-002's redirect-to-first-permitted default that bounce can loop. SCR-002 2 records the same constraint from the other end.

3The four states of this screen

Only one is a full artboard because only one differs from what ships today. The rest are drawn at component scale — that is genuinely all there is to them.

PageHeader — with Orders:Create (Manufacturer · Admin)
Identical to today. No behaviour change for the privileged case, which is what makes this ticket low-risk to ship.

Orders

New Order
PageHeader — without Orders:Create Artboard A
Hidden, not disabled. A greyed-out button advertises a capability the user cannot have and invites a support ticket; nothing is left in its place.

Orders

Empty result — unchanged by permissions
DataTable's existing empty row, with the caller's emptyLabel="No orders found." (app/orders/page.tsx:78). Reached today by a search or filter that matches nothing — and, if row-scoping is adopted (5), by a Supplier user with no orders at all.
Order NoDateSupplierStatus
No orders found.

Same label for two different situations. "No orders found." reads as "your filter is too narrow". If it also becomes the answer to "you have no orders", a Supplier user's first ever visit looks like a broken filter. Only matters if 5 is overturned — flagged so it is not discovered afterwards.

Denied — no Orders:View, typed URL
This screen renders nothing at all. Outcome owned by SCR-028; the default inherited from SCR-000/SCR-002 is drawn.
GET /orders (typed, or a stale bookmark)
↓ session valid — middleware.ts passes
↓ permission set lacks Orders:View
307 → /expenses (first permitted menu entry)
never rendered no order data is queried

The guard must sit above listOrders(), not filter its result. Denying after the query has run still puts every order row into the server's memory and into any log or trace on that path.

4What is deliberately not gated

Recorded so a later reviewer does not read these as oversights. Each was considered and left alone.

ControlDecisionReasoning
Search box (SearchInput, ?q=) not gated Searches orderNo within rows the user is already permitted to see. Gating it would remove a usability affordance without removing any access.
Sortable headers (?sort=, ?dir=) not gated Reordering a permitted result set discloses nothing new. orderBy() already allow-lists the sortable fields (lib/params.ts), so an arbitrary ?sort= cannot reach an un-exposed column.
Pagination + rows-per-page not gated Same set, different window. pageSize is clamped to PAGE_SIZE_OPTIONS so ?pageSize=100000 is already inert.
Row-level Edit / Delete controls nothing to gate The Orders list has no actions column and the app has no order edit or delete route at all — there is no OrderRowActions.tsx and no deleteOrder action anywhere in lib/actions/. An Orders:Edit or Orders:Delete permission would therefore have no surface to control on this screen or any other. Flagged in 7: the permission registry should not grow entries that gate nothing.
Supplier / Project / Status / Date filters flagged Not gated — but the options inside two of them are master data. See 6.

5Row-level scoping — the decision Artboard A silently makes unscoped drawn

The requirement introduces Supplier and Manufacturer as user types, not just roles. That naming strongly implies a Supplier user is a supplier — in which case a list showing Meridian, Ardent, Northgate and Coastline orders side by side shows one supplier its competitors' order book. Artboard A draws exactly that, and it is a choice, not an accident.

Drawn: no row-level scoping drawn
  • No acceptance criterion asks for it. Every stated criterion is about menus, routes, actions and direct-URL access — the vocabulary is capability, never ownership. Inventing record ownership would be building beyond the requirement, on a security question, without being asked.
  • The schema cannot express it. prisma/schema.prisma has no User model at all (SCR-029), so there is nothing to hang a user.supplierId off. Row-scoping is not a filter this ticket forgot; it is a data model this ticket does not have.
  • It is additive later. Adding a where predicate to listOrders() changes no pixels and no components — only which rows appear. The reverse (shipping a scoping rule and then relaxing it) is the migration that hurts.

The counter-argument, recorded honestly. If "Supplier user type" does mean an external supplier login, then shipping this unscoped is a cross-tenant data disclosure that no amount of correct permission gating catches — the user has Orders:View and is being shown orders, so every check in 2 passes and the audit is clean. This is the single highest-severity question on the screen and it is not visible in the pixels, which is precisely why it is written here. N31 should answer "does a Supplier user see other suppliers' orders?" explicitly, in words, before this is built.

If overturned — what changes
Small in code, large in meaning. No component changes; the frame's row count does.
SchemaUser.supplierId (nullable; null = Manufacturer)
QuerylistOrders() takes the session and adds supplierId to its where
Detail — SCR-005 needs the same predicate, or /orders/<id> re-opens the hole one record at a time
Filter — the Supplier dropdown collapses to one option, or disappears
Artboard A would show 3 rows, all "Meridian Steel Co."

6The filter panel leaks master data — and gating it would be theatre

app/orders/page.tsx:18-22 calls activeSuppliers() and activeProjects() to populate the filter dropdowns. Every supplier name and every project name is therefore delivered to anyone who can open the Orders list — including a user with no Supplier:View and no Project:View, for whom the entire Admin group is hidden from the menu. The panel is drawn here in its open state because it is the only part of this screen a checklist sweep would miss.

FilterBar panel, open — w-80 card p-4 shadow-lg
Drawn from components/ui/FilterBar.tsx. Closed by default (it auto-opens only when a filter is already active), which is why Artboard A shows the button, not the panel.
Filters
Supplier All
Project All
Status All
Order Date dd/mm/yyyy
Drawn: do not gate the dropdowns drawn
  • The table already shows the same names. Artboard A's Supplier and Project columns print "Meridian Steel Co." and "Riverside Clinic Fit-out" in plain text. Hiding a dropdown that lists the names visible three columns to the left protects nothing and costs the screen its two most useful filters.
  • Master data is not the master screen. Supplier:View gates /admin/suppliers — the place you manage suppliers, with contact details, supplier numbers and an edit path. A name rendered inside an order is order data.
  • The real containment is 5. If a Supplier user should not know who the other suppliers are, the fix is scoping the rows — at which point the dropdown narrows to match on its own. Gating the dropdown while leaving the column is the worst of both: less usable, equally leaky.

What would change the answer. This reasoning holds only because the leak is limited to names of records already on screen. If the filter ever lists suppliers with no orders — i.e. names that appear nowhere in the table — it becomes a genuine enumeration of the supplier master, and activeSuppliers() must be narrowed to suppliers present in the permitted result set. Worth checking against the real data before build: activeSuppliers() selects on isActive, not on "has orders", so today it very likely does.

7Approximations — flagged, not silently drawn

Per this node's instruction: anything drawn without a real control behind it is called out here. An unflagged approximation is what gets built.

ItemKindDetail
Unscoped rows in Artboard A assumption Twelve orders across four suppliers, shown to a Supplier · Staff user. The drawn default from 5. If overturned, this frame is re-cut with three rows and a single supplier name.
Order No stays a link assumption Assumes Orders:View covers list and detail. If SCR-005 splits them, the cell becomes conditional — plain text when detail is denied, which is a visual change (the blue underline disappears from every row) and needs its own frame.
Filter panel drawn closed in Artboard A accurate Not an omission. FilterBar initialises open to activeCount > 0, and the frame draws an unfiltered first load. The open state is 6.
Listbox / DatePicker controls approximation Drawn as static .field-control boxes at their real geometry (1px border, 8×12px padding, min-height: 2.625rem) showing their real placeholders ("All" — placeholderSelectable; dd/mm/yyyy). They are custom popover components, not native selects; their open/expanded states are outside this screen's scope.
Order numbers, supplier and project names, dates, statuses sample data Invented content at realistic shape. OMS-2026-000047 follows nextOrderNo()'s real OMS-YYYY-###### format (lib/queries/orders.ts:47-52); dates use toLocaleDateString(); the in Due Date is the literal em-dash fallback from app/orders/page.tsx:30. All four order statuses are exercised so every StatusBadge tone on this screen is visible at once.
Twelve rows, "Showing 1–12 of 12" sample data Row count chosen to fill 900px without clipping the pager. Real default pageSize is 20 (lib/params.ts:7), so a full page would overflow the viewport and scroll — as it does in the shipped app. Prev/Next are drawn at opacity-40, the real single-page state.
Icon glyphs approximation Inline SVG traces of the lucide-react icons the app imports (ClipboardList, LogOut, Search, SlidersHorizontal, ArrowUpDown, X) at their real h-4 w-4 / h-3.5 w-3.5 sizes. Same glyphs, hand-transcribed paths — sub-pixel differences from the shipped icons are expected.
Sidebar shown filtered borrowed SCR-027's design, not this screen's. Drawn because a page gated on Orders:View under a menu offering six routes this user cannot open would misrepresent the change. If SCR-027's hide-vs-disable default flips, this frame is re-cut with it.
Hover / focus states not drawn The real components carry hover:bg-accent-subtle/60 on rows, hover:text-text on sort links and focus:ring-accent on inputs. A static artboard has no pointer; unchanged by this ticket and deliberately omitted.

8Open decisions carried into N31 — and the default this mockup drew

None of these blocked the mockup. A defensible default was drawn for each and is recorded here so the reviewer overturns it explicitly rather than discovering it after it ships. The inventory marks SCR-003 ambiguous: false — the first two below are why that is optimistic.

9Tokens used — no new colour, radius or type scale is introduced

bg #fcfcfd
surface #ffffff
text #302828
text-muted #836f6f
border #ede0ce
accent #453028
accent-subtle
#f7f1e9
success #1f8a4c
warning #b8860b
danger #c0392b
neutral #6b7280
info #2563eb
annotation
(NOT a token)

Classes reproduced verbatim: .card (surface + --border + rounded-lg 8px), .btn-primary (--accent, white text, px-4 py-2, rounded-md 6px, 14px/500), .btn-secondary (surface + 1px --border), .field-input (@tailwindcss/forms base geometry: 1px border, 8px×12px padding, 14px), .field-control (the same geometry restored for non-form elements, min-height: 2.625rem), .field-label, .link (--status-info + persistent underline — the fix from the "orders can't be clicked" report, and the reason every Order No in Artboard A is blue rather than brown). Layout: sidebar w-64 = 256px with px-5 py-4 brand and px-3/mb-5 nav groups; main.flex-1.p-6 = 24px gutter; PageHeader mb-6 = 24px; toolbar mb-4 = 16px; DataTable cells px-4 py-3 with an empty row at px-4 py-8; Pagination mt-4; FilterBar panel w-80 p-4. StatusBadge tones are the token colour at /10 alpha on a rounded-full px-2 py-0.5 text-xs pill, exactly as components/ui/StatusBadge.tsx defines them.

All five status tones are exercised on this screen except danger (no REJECTED value exists on OrderStatus — it belongs to ExpenseStatus); it is shown in the swatch row only. This screen introduces zero new components, zero new strings and zero new tokens — the entire visible change is one prop that stops being passed.