The 'Total {sumQty}{unitSuffix}' header line now explicitly documents
how mixed / missing units are handled: suffix is omitted when all
items have empty/null units (operator defaults to 'EA'); when units
are mixed, no suffix is shown; when all items share a non-EA unit
(e.g. BOX), the suffix is shown.
Self-review pass; no other changes.
Co-Authored-By: Claude Code <noreply@anthropic.com>
9.5 KiB
/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: () => voidintoDeliveryNoteRowComponent. - 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/40to match the amber row tint)
- Position: left edge of the row, before
- 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}{unitSuffix}" —
right-aligned, text-xs, muted foreground.
sumQtyis the sum ofitem.quantityacross the panel's items.unitSuffixis empty when the items' units are mixed or empty/null; otherwise it's a single shared unit string (e.g." EA"or" BOX"). When all items have unit "EA" or empty, the suffix is omitted entirely (the operator's mental default is "EA"). Whenn === 0, the header line is omitted; the empty-state takes its place. - Per item line (
<div className="flex items-baseline gap-2">):- Description: truncate +
titlefor full text (truncate,text-xs) - Right-aligned qty:
Qty {n}intext-xs tabular-nums - If
item.order !== dn.order?.order(DN spans multiple orders — rare): a tinyfrom ORD-...line under the description intext-[10px] text-muted-foreground
- Description: truncate +
- Empty state when
items.filter(i => i.delivery_note === dn.id).length === 0:
The linkNo items loaded on this delivery note. See full DN →<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-autoon 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:
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 thedelivery_noterelation onorder_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+onToggleintoDeliveryNoteRowComponent - 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
itemsarray
- Add page-level
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.