docs: implementation plan for DN items expand

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>
This commit is contained in:
Dave 2026-09-01 12:03:35 +02:00
parent 66e2fb9856
commit 0984b7513e

View file

@ -0,0 +1,508 @@
# /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` + `ChevronDown` to 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:
```ts
import {
CheckSquare,
FileText,
Loader2,
PackageOpen,
Paperclip,
Pencil,
Square,
Truck,
User,
} from "lucide-react";
```
Replace with:
```ts
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:
```ts
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:
```tsx
// 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` + `onToggle` into the call site**
At the `<DeliveryNoteRowComponent ... />` call site found in Step 2, replace the existing call with:
```tsx
<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 `DeliveryNoteRowComponent` signature + render chevron + items panel**
In `src/app/(app)/deliveries/page.tsx`, locate `DeliveryNoteRowComponent` (around lines 1475-1588). Replace its entire signature + return block with:
```tsx
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:
```ts
import { useCallback, useEffect, useMemo, useState } from "react";
```
(`useState` and `useCallback` were used in Step 3.)
**If `Link` is not yet imported**, add:
```ts
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`):
```bash
npm run dev
```
Open the browser via agent-browser:
```bash
agent-browser open http://localhost:4001/login
```
Log in as David Els (`david.els1@outlook.com` / `1234567890` from `.env.local`):
```bash
agent-browser fill <email-ref> "david.els1@outlook.com"
agent-browser fill <password-ref> "1234567890"
agent-browser click <sign-in-ref>
```
Navigate to `/deliveries`:
```bash
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:
```bash
agent-browser screenshot /tmp/dn-expanded.png
```
Stop the dev server (Ctrl+C) when done.
- [ ] **Step 7: Commit**
```bash
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.