WFDASH-WEBAPP/docs/superpowers/plans/2026-09-01-deliveries-dn-items-expand.md
Dave 0984b7513e 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>
2026-09-01 12:03:35 +02:00

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 + 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:

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 + onToggle into 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 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:

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.