docs: spec for /deliveries DN items expand

Click-to-expand on each delivery-note row reveals an inline items
panel (description + qty) below the row. Operator can match the items
against the printed DN paper in their hand before loading. Pure
client-side render — items already loaded by /api/v1/deliveries are
filtered by delivery_note == dn.id. Empty state with fallback link to
/delivery-notes/[id] for DNs whose items aren't in the page slice.

Single-file edit on src/app/(app)/deliveries/page.tsx. No server,
schema, API, or MCP changes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-01 12:01:12 +02:00
parent 57584798f3
commit b39614b4da

View file

@ -0,0 +1,224 @@
# `/deliveries` DN Items Expand — Design
## Purpose
On `/deliveries`, each delivery-note row currently shows only header
metadata (DN number, status badge, customer / company / order /
signed-copy indicator). The operator can't see *which items are on
that DN* without leaving to `/delivery-notes/[id]`, which breaks the
at-a-glance matching workflow against the printed paper in their
hand.
This spec adds a click-to-expand chevron on each DN row that reveals
a small inline list of that DN's line items (description + qty) so
the operator can match against the paper before loading the DN.
## Scope decisions (confirmed during brainstorming)
- **Interaction**: click-to-expand via a chevron at the left edge of
the DN row. Independent state per DN (no "expand all"). No
persistence across refresh.
- **Item content**: description + qty only (no SKU, no price, no
invoice number — operator matches against the printed paper, which
shows description and qty).
- **Data source**: client-side filter of the items already loaded by
`/api/v1/deliveries`. No new server endpoint. Items not in the
current page slice (200-item cap) get a "see full DN →" link to
`/delivery-notes/[id]`.
- **No PB schema changes.** Pure rendering work.
## Architecture
**Modified files (1):**
```
src/app/(app)/deliveries/page.tsx
```
No service-layer changes. No new `/api/v1/*` routes. No new MCP tools.
No new PB schema.
### State management
The existing `DeliveryNoteRowComponent` is a stateless component
that takes a single `dn` row. To add expand state without changing
every caller, lift state up to the page-level:
- The page keeps `const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(new Set())`.
- Pass `expanded: boolean`, `onToggle: () => void` into `DeliveryNoteRowComponent`.
- When toggled, page updates the set (`setExpandedDnIds(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; })`).
The page already groups items + DNs by customer → order, and we
already pass the expanded state per-DN. State hoisting to the page
component keeps the data ownership clear (page has the items array,
so it knows which DN items belong to).
---
## Feature 1 — DN row expand/collapse
### Current DN row (post the just-shipped compact subline change)
```
[DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
```
### Target
The chevron sits at the left edge of the row, just inside the existing
`<FileText />` icon column:
```
▸ [DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
(when expanded:)
▾ [DN DN26082701] [Draft]
Benjamin · Caliber Machine... · ORD-2026-08-013 · [📎 signed copy]
[Mark loaded] [Signed copy]
┌──────────────────────────────────────────────────────┐
│ 3 items · Total 10 EA │
│ ─────────────────────────────────────────────────── │
│ JEI HSS CUTTER LONG 24mm ... Qty 2 │
│ JEI HSS CUTTER LONG 22mm ... Qty 2 │
│ JEI HSS CUTTER LONG 18mm ... Qty 6 │
└──────────────────────────────────────────────────────┘
```
The items panel sits **inside the same `<div>` as the DN row** but
below the existing `<div className="flex shrink-0 flex-col...">`
button column. Visual hierarchy: existing row keeps its cream/amber
background; the items panel uses a slightly tinted neutral
(`bg-muted/30`) so the operator's eye reads "DN row + its items" as
one unit.
### Interaction details
- **Chevron button** (lucide `ChevronRight` / `ChevronDown`):
- Position: left edge of the row, before `<FileText />`
- Size: `h-4 w-4` (matches the existing icon size used elsewhere
on the page)
- `aria-expanded={expanded}`, `aria-controls={items-panel-id}`,
`aria-label="Show items on this delivery note"`
- Hover background: subtle (`hover:bg-amber-100/40` to match the
amber row tint)
- **Independent state per DN.** Each DN toggles its own expand. The
page-level `Set<string>` of expanded DN ids survives re-renders
but resets on refresh.
- **No "expand all" / "collapse all"** — out of scope. YAGNI.
- **Keyboard**: chevron is a `<button>`, so Enter/Space toggle it
natively.
### Items panel — rendering
A bordered panel immediately below the existing `<div
className="flex shrink-0 flex-col items-end gap-1.5">` button column,
inside the same outer `<div className="flex items-start gap-3 ...">`:
- **Header line**: "{n} items · Total {sumQty} EA" — right-aligned,
text-xs, muted foreground. When `n === 0`, the header line is
omitted; the empty-state takes its place.
- **Per item line** (`<div className="flex items-baseline gap-2">`):
- Description: truncate + `title` for full text
(`truncate`, `text-xs`)
- Right-aligned qty: `Qty {n}` in `text-xs tabular-nums`
- If `item.order !== dn.order?.order` (DN spans multiple orders —
rare): a tiny `from ORD-...` line under the description in
`text-[10px] text-muted-foreground`
- **Empty state** when `items.filter(i => i.delivery_note === dn.id).length === 0`:
```
No items loaded on this delivery note.
See full DN →
```
The link `<Link href={`/delivery-notes/${dn.id}`}>` is styled as a
small primary-coloured link, opens in the same tab. Per-row message
— keeps the operator oriented (some DNs legitimately have zero items
for ad-hoc counter pickups; this is the fallback for "we don't have
the data loaded").
- **Many items**: `max-h-72 overflow-y-auto` on the panel so a DN
with 20+ items doesn't push everything else off screen.
### Data source — client-side filter
The page already calls `listDeliveryItems()` which returns up to 200
items per tab with their `delivery_note` relation id and
`delivery_note_number` text. On render, the items panel computes:
```ts
const dnItems = useMemo(
() => items.filter((i) => i.delivery_note === dn.id),
[items, dn.id],
);
```
`useMemo` keeps the filter from running on every keystroke. The page
already has the full items array in scope, so no new fetch.
### Edge cases
- **Multi-DN under one order**: DNs in the same group expand
independently. Items belong to exactly one DN at a time, so the
lists are disjoint.
- **Counter-pickup DNs** (`dn.order === null`): items can still be on
them via the `delivery_note` relation on `order_items`. The items
show under the DN regardless. The "from ORD-..." line is suppressed
in this case (no parent order to reference).
- **Items paged off**: if the DN's items aren't in the page slice
(e.g. >200 items loaded, this DN's items at position 201+), the
empty-state with `/delivery-notes/[id]` link is the fallback.
Acceptable for v1 — the operator can drill into the DN.
- **Stale data**: client-side filter is stale-by-design — if an item
is added/removed from a DN after the page loaded, the panel
doesn't reflect it until refresh. Acceptable for this at-a-glance
workflow.
- **Loading the page fresh**: all DNs start collapsed.
---
## Files touched
- `src/app/(app)/deliveries/page.tsx` — single-file edit
- Add page-level `useState<Set<string>>` for expanded DN ids
- Pass `expanded` + `onToggle` into `DeliveryNoteRowComponent`
- Inside `DeliveryNoteRowComponent`: add the chevron button at the
left of the row + an inline items panel below the button column
- Filter items from the page-level `items` array
No service / API / schema changes.
---
## What's NOT in scope
- **No new server endpoint.**
- **No PB schema changes.**
- **No select / checkbox on the items** — operator is matching, not
selecting.
- **No SKU / price / invoice column** per the brainstorming decision.
- **No persistent expand state** — refresh collapses everything.
- **No "expand all" / "collapse all" controls.**
- **No item count badge on the DN row** — YAGNI; can add later.
- **No changes to the existing item rows** in the PO groups below
the DNs.
## Testing
- **Manual smoke check** via agent-browser: log in, navigate to
`/deliveries`, expand a DN with items, verify the items panel
renders with correct descriptions + qtys. Collapse and re-expand to
verify state. Trigger the empty state by expanding a counter-pickup
DN with no items.
- **No unit tests** for the rendering — the filter is a one-liner
(`items.filter(i => i.delivery_note === dn.id)`) and the panel is
presentation. If the page already has a render test, verify it
still passes.
## Risk assessment
**Low.** Single-file edit, additive rendering. The expand state is
local to the page and resets on refresh. No data layer touched, no
schema touched, no API touched. Worst case: a layout glitch on the DN
row is easily reverted by removing the new `onClick` and the inline
panel.