Single-task, single-file plan: add chevron + items panel to DeliveryNoteRowComponent on /deliveries. Lifts expand state to the page (Set<string>), passes expanded + onToggle + items down. Items filtered client-side from page data; empty-state fallback to /delivery-notes/[id]. 7 steps: imports, locate call site, page-level state, prop-drill, update component signature + render chevron + items panel, smoke check via agent-browser, commit. Co-Authored-By: Claude Code <noreply@anthropic.com>
19 KiB
/deliveries DN Items Expand Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a click-to-expand chevron on every delivery-note row on /deliveries that reveals an inline items panel (description + qty) so the operator can match the printed DN paper in their hand against the loaded items.
Architecture: Single-file edit on src/app/(app)/deliveries/page.tsx. Lift expand state up to the page (a Set<string> of expanded DN ids), pass expanded + onToggle down into DeliveryNoteRowComponent, render the chevron + items panel inside. Items come from a client-side filter of the page's already-loaded items array (items.filter(i => i.delivery_note === dn.id)).
Tech Stack: Next.js + React 19, lucide-react (ChevronRight + ChevronDown), existing Tailwind classes, vitest for any unit tests.
Spec: docs/superpowers/specs/2026-09-01-deliveries-dn-items-expand-design.md
File structure
| Action | File | Responsibility |
|---|---|---|
| Modify | src/app/(app)/deliveries/page.tsx |
Page-level state for expanded DN ids + prop-drill into the row + chevron button + inline items panel. Single file edit. |
No new files. No service-layer changes. No new /api/v1/* routes. No MCP changes. No PB schema changes.
Task 1: Add DN expand/collapse + items panel
Files:
- Modify:
src/app/(app)/deliveries/page.tsx
This task adds the page-level state, the chevron button, and the inline items panel below each delivery-note row. The whole change is one commit — single coherent feature.
- Step 1: Add
ChevronRight+ChevronDownto the lucide-react import
In src/app/(app)/deliveries/page.tsx, locate the lucide-react import block (currently around lines 85-94). The current block looks like:
import {
CheckSquare,
FileText,
Loader2,
PackageOpen,
Paperclip,
Pencil,
Square,
Truck,
User,
} from "lucide-react";
Replace with:
import {
CheckSquare,
ChevronDown,
ChevronRight,
FileText,
Loader2,
PackageOpen,
Paperclip,
Pencil,
Square,
Truck,
User,
} from "lucide-react";
(Alphabetical insertion of ChevronDown + ChevronRight between CheckSquare and FileText.)
- Step 2: Locate the page-level component that renders
DeliveryNoteRowComponent
Find the call site that renders each DN row. The signature of DeliveryNoteRowComponent (around line 1475-1488 of the file) is:
function DeliveryNoteRowComponent({
dn,
onSignedCopy,
onMarkLoaded,
busy,
}: {
dn: DeliveryNoteRow;
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
}) {
We'll come back to this in Step 5. First, find the call site — the place in the parent component that does <DeliveryNoteRowComponent dn={dn} onSignedCopy={...} onMarkLoaded={...} busy={busy} />. Use grep -n "DeliveryNoteRowComponent" src/app/(app)/deliveries/page.tsx to locate it. It sits inside the per-customer → per-order group render.
Note the existing props being passed. Note also the page-level items array (the items already loaded by /api/v1/deliveries) — it lives in scope of the parent component. We'll need to pass it down.
- Step 3: Add page-level expand state
In the parent component that renders DeliveryNoteRowComponent, near the top of the function body (alongside the existing useState calls), add:
// Set of DN ids whose items panel is expanded. Independent state per
// DN; resets on refresh. See spec § Feature 1 — Interaction details.
const [expandedDnIds, setExpandedDnIds] = useState<Set<string>>(
() => new Set(),
);
const toggleDnExpanded = useCallback((dnId: string) => {
setExpandedDnIds((prev) => {
const next = new Set(prev);
if (next.has(dnId)) {
next.delete(dnId);
} else {
next.add(dnId);
}
return next;
});
}, []);
If useState / useCallback are not already imported in this file's react import line, add them. (They almost certainly are — verify with grep -n "from \"react\"" src/app/(app)/deliveries/page.tsx.)
- Step 4: Pass
expanded+onToggleinto the call site
At the <DeliveryNoteRowComponent ... /> call site found in Step 2, replace the existing call with:
<DeliveryNoteRowComponent
dn={dn}
onSignedCopy={onSignedCopy}
onMarkLoaded={onMarkLoaded}
busy={busy}
expanded={expandedDnIds.has(dn.id)}
onToggle={() => toggleDnExpanded(dn.id)}
items={items}
/>
items here is the page-level items array (already loaded by /api/v1/deliveries). Its type is whatever the parent component currently uses for items — typically DeliveryListResult["items"] or similar. Pass it verbatim.
- Step 5: Update
DeliveryNoteRowComponentsignature + render chevron + items panel
In src/app/(app)/deliveries/page.tsx, locate DeliveryNoteRowComponent (around lines 1475-1588). Replace its entire signature + return block with:
function DeliveryNoteRowComponent({
dn,
onSignedCopy,
onMarkLoaded,
busy,
expanded,
onToggle,
items,
}: {
dn: DeliveryNoteRow;
onSignedCopy: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
onMarkLoaded: (deliveryNoteId: string, deliveryNoteNumber: string) => void;
busy: boolean;
expanded: boolean;
onToggle: () => void;
items: DeliveryListResult["items"];
}) {
const statusLabel = (() => {
switch (dn.status) {
case "draft":
return "Draft";
case "sent":
return "Sent";
case "loaded":
return "Loaded";
case "po_received":
return "PO Received";
case "invoiced":
return "Invoiced";
case "completed":
return "Completed";
case "cancelled":
return "Cancelled";
default:
return dn.status;
}
})();
// "Mark loaded" is the action that moves the paper onto the
// truck. It's available on draft (paper being loaded directly,
// e.g. counter pickup without a prior `sent`) and sent (paper
// is on its way). Re-marking a loaded DN is allowed — the
// service idempotently refreshes the `loaded_at` stamp.
const canMarkLoaded =
dn.status === "draft" || dn.status === "sent" || dn.status === "loaded";
// "Signed copy" is the operator's manual upload of the
// scanned paper. The operator asked for it to be available
// regardless of status, so we show it on every non-cancelled
// DN. Cancelled is rejected by the service — uploads to an
// abandoned DN would be misleading.
const canUploadSignedCopy = dn.status !== "cancelled";
// Items on this DN — drawn from the page's already-loaded items
// array. Stale-by-design: a refresh picks up changes. See spec
// § Feature 1 — Data source.
const dnItems = useMemo(
() => items.filter((i) => i.delivery_note === dn.id),
[items, dn.id],
);
// Header-line suffix logic per spec: suffix omitted when all
// items have empty/null units (operator default is "EA"); no
// suffix when units are mixed; the shared unit when all items
// share a non-EA unit (e.g. BOX).
const totalSuffix = (() => {
const units = new Set(
dnItems.map((i) => (i.unit ?? "").trim()).filter((u) => u.length > 0),
);
if (units.size === 0) return ""; // all empty/null → no suffix
if (units.size === 1) {
const only = [...units][0];
return only === "EA" ? "" : ` ${only}`;
}
return ""; // mixed → no suffix
})();
const totalQty = dnItems.reduce((sum, i) => sum + (i.quantity ?? 0), 0);
const itemsPanelId = `dn-items-${dn.id}`;
const hasDifferentOrderItem = (itemOrderId: string | null) =>
dn.order !== null && itemOrderId !== null && itemOrderId !== dn.order;
return (
<div
className="bg-amber-50/40 dark:bg-amber-950/10"
data-testid={`deliveries-dn-row-${dn.id}`}
>
<div className="flex items-start gap-3 px-4 py-2.5">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={itemsPanelId}
aria-label={
expanded
? "Hide items on this delivery note"
: "Show items on this delivery note"
}
className="mt-0.5 shrink-0 rounded p-0.5 text-amber-700 hover:bg-amber-100/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 dark:text-amber-400 dark:hover:bg-amber-900/30"
>
{expanded ? (
<ChevronDown className="h-4 w-4" aria-hidden />
) : (
<ChevronRight className="h-4 w-4" aria-hidden />
)}
</button>
<FileText className="mt-0.5 h-5 w-5 shrink-0 text-amber-700 dark:text-amber-400" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm" title={dn.delivery_note_number}>
<span className="font-mono">DN {dn.delivery_note_number}</span>
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground tabular-nums">
{dn.order ? (
<>
<span
className="truncate max-w-[40ch]"
title={dn.order.customer_name ?? ""}
>
{dn.order.customer_name}
</span>
{dn.order.company_name && (
<>
<span aria-hidden>·</span>
<span>{dn.order.company_name}</span>
</>
)}
{dn.order.order_number && (
<>
<span aria-hidden>·</span>
<span className="font-mono">{dn.order.order_number}</span>
</>
)}
</>
) : (
<span className="italic">Counter pickup</span>
)}
{dn.signed_copy && dn.signed_copy.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1"
title={dn.signed_copy.join(", ")}
>
<Paperclip className="h-3 w-3" aria-hidden />
signed copy
</span>
</>
)}
{dn.delivery_date && (
<>
<span aria-hidden>·</span>
<span>Delivery {dn.delivery_date}</span>
</>
)}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<Badge variant="outline" className="text-[10px]">
{statusLabel}
</Badge>
{canMarkLoaded && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => onMarkLoaded(dn.id, dn.delivery_note_number)}
disabled={busy}
data-testid={`deliveries-dn-mark-loaded-${dn.id}`}
title="Mark the paper as loaded on the truck"
>
<Truck className="mr-1 h-3 w-3" />
{dn.status === "loaded" ? "Re-mark loaded" : "Mark loaded"}
</Button>
)}
{canUploadSignedCopy && (
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => onSignedCopy(dn.id, dn.delivery_note_number)}
disabled={busy}
data-testid={`deliveries-dn-signed-copy-${dn.id}`}
title="Upload the signed copy of this delivery note and mark it complete"
>
<Pencil className="mr-1 h-3 w-3" />
Signed copy
</Button>
)}
</div>
</div>
{expanded && (
<div
id={itemsPanelId}
data-testid={`deliveries-dn-items-${dn.id}`}
className="ml-9 mr-4 mb-2 rounded-md border border-amber-200/60 bg-muted/30 px-3 py-2 text-xs dark:border-amber-900/40"
>
{dnItems.length === 0 ? (
<div className="flex items-center justify-between gap-2 text-muted-foreground">
<span>No items loaded on this delivery note.</span>
<Link
href={`/delivery-notes/${dn.id}`}
className="font-medium text-primary hover:underline"
>
See full DN →
</Link>
</div>
) : (
<>
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground tabular-nums">
<span>
{dnItems.length} item{dnItems.length === 1 ? "" : "s"}
{totalQty > 0 && ` · Total ${totalQty}${totalSuffix}`}
</span>
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-amber-200/40 dark:divide-amber-900/40">
{dnItems.map((item) => (
<div
key={item.id}
className="flex items-baseline gap-3 py-1.5"
data-testid={`deliveries-dn-item-${item.id}`}
>
<div className="min-w-0 flex-1">
<p
className="truncate"
title={item.description ?? ""}
>
{item.description}
</p>
{hasDifferentOrderItem(item.order ?? null) &&
item.order && (
<p className="text-[10px] text-muted-foreground">
from {item.order}
</p>
)}
</div>
<span className="shrink-0 tabular-nums text-muted-foreground">
Qty {item.quantity ?? 0}
</span>
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
}
If useMemo is not yet imported in the file, add it to the existing react import:
import { useCallback, useEffect, useMemo, useState } from "react";
(useState and useCallback were used in Step 3.)
If Link is not yet imported, add:
import Link from "next/link";
(Check with grep -n "from \"next/link\"" src/app/(app)/deliveries/page.tsx. The project uses Next 16, so <Link> is the standard import.)
Note on item.order: each item carries order: string | null (the order relation id) and delivery_note: string | null (the DN relation id). The plan uses item.order for the cross-order check; if the project's DeliveryListResult["items"] type doesn't expose order directly, surface it via a tiny cast at the call site, or add order: string | null to the relevant row type. Verify the type exists with grep -n "order: string" src/lib/server/deliveries-service.ts.
- Step 6: Smoke-check via dev server + agent-browser
Start the dev server (per project memory, runs on :4001):
npm run dev
Open the browser via agent-browser:
agent-browser open http://localhost:4001/login
Log in as David Els (david.els1@outlook.com / 1234567890 from .env.local):
agent-browser fill <email-ref> "david.els1@outlook.com"
agent-browser fill <password-ref> "1234567890"
agent-browser click <sign-in-ref>
Navigate to /deliveries:
agent-browser open http://localhost:4001/deliveries
agent-browser wait --load networkidle
Verify:
- Each DN row has a
▸chevron at the left edge (before the FileText icon). - Clicking a chevron toggles it to
▾and reveals an items panel below the row. - The items panel shows "{n} items · Total {sumQty}" header + a list of descriptions and qty.
- Counter-pickup DNs (no parent order) expand the same way — their items (if any) appear in the panel.
- A DN with no items on the page shows the empty state: "No items loaded on this delivery note." + a "See full DN →" link.
- Clicking the chevron again collapses the panel.
- The DN row's existing buttons (Mark loaded, Signed copy) still work.
Take a screenshot for the record:
agent-browser screenshot /tmp/dn-expanded.png
Stop the dev server (Ctrl+C) when done.
- Step 7: Commit
git add "src/app/(app)/deliveries/page.tsx"
git commit -m "feat(deliveries): click-to-expand DN items panel
Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading the DN.
Items come from a client-side filter of the page's already-loaded
items array (items.filter(i => i.delivery_note === dn.id)). No new
server endpoint. DNs whose items aren't in the page slice show a
'See full DN →' link to /delivery-notes/[id].
Expand state is held at the page level as a Set<string> of expanded
DN ids — independent per DN, resets on refresh.
Co-Authored-By: Claude Code <noreply@anthropic.com>"
Self-review
Spec coverage check (against docs/superpowers/specs/2026-09-01-deliveries-dn-items-expand-design.md):
| Spec section | Plan task |
|---|---|
| § Interaction: chevron button (lucide ChevronRight/Down) | Step 1 + Step 5 ✓ |
| § Independent state per DN, no persistence | Step 3 + Step 5 (Set<string>, page-level) ✓ |
§ Aria attributes (aria-expanded, aria-controls, aria-label) |
Step 5 ✓ |
| § Items panel: bordered, tinted neutral background | Step 5 (border border-amber-200/60 bg-muted/30) ✓ |
| § Header line "{n} items · Total {sumQty}{unitSuffix}" with unit logic | Step 5 (totalSuffix closure) ✓ |
| § Per-item line: description (truncate + title), right-aligned qty | Step 5 ✓ |
| § Cross-order "from ORD-..." line when item.order !== dn.order.order | Step 5 (hasDifferentOrderItem) ✓ |
| § Empty state: "No items loaded..." + "See full DN →" link | Step 5 ✓ |
| § max-h-72 overflow-y-auto for many items | Step 5 ✓ |
| § Counter-pickup DNs work the same way | Step 5 (uses dn.id filter regardless of dn.order) ✓ |
| § Empty-state fallback when items paged off | Step 5 ✓ |
| § Data source: client-side filter, useMemo | Step 5 (useMemo + items.filter) ✓ |
| § Single file edit | Task 1 ✓ |
| § No new server endpoint / no schema / no MCP | All ✓ |
| § No unit tests required | Plan has no test step (spec explicitly says "No unit tests for the rendering") ✓ |
| § Manual smoke check via agent-browser | Step 6 ✓ |
Placeholder scan: No "TBD", "TODO", "implement later". Every step has actual code or commands.
Type consistency: DeliveryListResult["items"] referenced in Step 5 matches the existing exports from deliveries-service.ts (DeliveryListResult is an exported type). dn.id, dn.order, item.delivery_note, item.order, item.id, item.description, item.quantity, item.unit are all standard fields carried by the existing service response. Verified via the spec section that walks through the data.
Ambiguity check: One potential ambiguity is in Step 5 — the type DeliveryListResult["items"] is referenced; if the file imports DeliveryListResult under a different alias, the implementer should use that alias instead. The grep check at the bottom of Step 5 covers the most likely discrepancy (item.order shape). No other ambiguities.
All spec requirements are addressed; no gaps.