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:
parent
57584798f3
commit
b39614b4da
1 changed files with 224 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Reference in a new issue