224 lines
9.2 KiB
Markdown
224 lines
9.2 KiB
Markdown
|
|
# `/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.
|