WFDASH-WEBAPP/docs/superpowers/plans/2026-07-07-collection-confirm-page.md
SJCoetz 9378685164 feat(collection-confirm): add full collection confirm workflow
Implement the complete collection confirm feature set:
- Add new `/collection-confirm` operational page for reviewing and confirming collected order items
- Add sidebar navigation entries for Collection Confirm and Invoicing
- Add `order_items` to the file proxy API whitelist for attachment access
- Install required testing dependencies and configure Vitest test setup
- Add full design documentation, test suite, and page overview screenshot
- Update package.json and build configuration files
2026-07-08 01:35:00 +02:00

28 KiB

Collection Confirm Page 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 new /collection-confirm page that lets operators review collected order items, adjust quantity, add notes, and confirm the row for invoicing.

Architecture: Pure view-model helper for the notes-feed encoding plus a small client page that reads order_items directly through the existing PocketBase browser client, reuses ModalShell.Dialog for the adjust/add-note flows, and uses the existing sidebar pattern for navigation.

Tech Stack: Next.js 16 (client component), React 19, TypeScript, PocketBase browser client, Vitest + @testing-library/react.


File Structure

  • Create: src/app/(app)/collection-confirm/page.tsx — the page UI.
  • Create: src/app/(app)/collection-confirm/view-model.ts — pure helpers for the notes feed and confirmation rules.
  • Create: src/app/(app)/collection-confirm/view-model.test.ts — focused unit tests for the view-model.
  • Create: src/app/(app)/collection-confirm/page.test.tsx — page-level tests for default render and key actions.
  • Modify: src/components/sidebar.tsx — add a sidebar entry under operationsItems.

Task 1: Add the notes-feed view-model helpers

Files:

  • Create: src/app/(app)/collection-confirm/view-model.ts

  • Test: src/app/(app)/collection-confirm/view-model.test.ts

  • Step 1: Write the failing tests

Create src/app/(app)/collection-confirm/view-model.test.ts:

import { describe, expect, it } from "vitest";

import {
  appendNote,
  canConfirmRow,
  nextItemStatusForConfirm,
  parseNotesFeed,
  type ConfirmActor,
  type NoteEntry,
} from "./view-model";

const baseActor: ConfirmActor = {
  id: "user-1",
  name: "Operator One",
  email: "op1@example.com",
};

describe("parseNotesFeed", () => {
  it("returns a single synthetic entry when the row has no notes", () => {
    const entries = parseNotesFeed("", { id: "row-1", drv_collected_at: "2026-07-07T10:00:00.000Z" });
    expect(entries).toHaveLength(1);
    expect(entries[0].category).toBe("collection");
    expect(entries[0].body).toContain("Collected");
  });

  it("parses JSON entries newest-first", () => {
    const encoded = JSON.stringify([
      { ts: "2026-07-07T12:00:00.000Z", actorId: "user-1", actorName: "Op", category: "quantity", body: "Adjusted qty" },
      { ts: "2026-07-07T11:00:00.000Z", actorId: "user-1", actorName: "Op", category: "note", body: "Note text" },
    ]);
    const entries = parseNotesFeed(encoded, { id: "row-1", drv_collected_at: null });
    expect(entries.map((entry) => entry.category)).toEqual(["quantity", "note"]);
  });

  it("returns empty entries when notes is whitespace", () => {
    const entries = parseNotesFeed("   ", { id: "row-1", drv_collected_at: null });
    expect(entries).toEqual([]);
  });
});

describe("appendNote", () => {
  it("appends a new structured entry and preserves existing JSON", () => {
    const existing = JSON.stringify([
      { ts: "2026-07-07T11:00:00.000Z", actorId: "user-1", actorName: "Op", category: "note", body: "Old" },
    ]);
    const result = appendNote(existing, {
      ts: "2026-07-07T12:00:00.000Z",
      actor: baseActor,
      category: "note",
      body: "New note",
    });
    const entries = parseNotesFeed(result, { id: "row-1", drv_collected_at: null });
    expect(entries.map((entry) => entry.body)).toEqual(["New note", "Old"]);
  });

  it("creates a new feed when existing is blank", () => {
    const result = appendNote("", {
      ts: "2026-07-07T12:00:00.000Z",
      actor: baseActor,
      category: "problem",
      body: "Damaged in transit",
    });
    const entries = parseNotesFeed(result, { id: "row-1", drv_collected_at: null });
    expect(entries).toHaveLength(1);
    expect(entries[0].category).toBe("problem");
  });
});

describe("canConfirmRow", () => {
  it("returns true when there is no problem flag", () => {
    expect(canConfirmRow({ problem_flag: false, notes: "" }, "Op")).toBe(true);
  });

  it("returns false when a problem flag is set with no confirming note", () => {
    expect(canConfirmRow({ problem_flag: true, notes: "" }, "Op")).toBe(false);
  });

  it("returns true when a problem flag is set with a confirming note", () => {
    const entries: NoteEntry[] = [
      { ts: "2026-07-07T12:00:00.000Z", actorId: "user-1", actorName: "Op", category: "confirm", body: "Confirming with problem" },
    ];
    expect(canConfirmRow({ problem_flag: true, notes: JSON.stringify(entries) }, "Op")).toBe(true);
  });
});

describe("nextItemStatusForConfirm", () => {
  it("returns the next step in the invoicing lifecycle", () => {
    expect(nextItemStatusForConfirm("collected")).toBe("processed");
  });
});
  • Step 2: Run tests to verify they fail

Run: npm run test:run -- "src/app/(app)/collection-confirm/view-model.test.ts" Expected: FAIL with module not found.

  • Step 3: Write the minimal implementation

Create src/app/(app)/collection-confirm/view-model.ts:

export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm";

export type NoteEntry = {
  ts: string;
  actorId: string;
  actorName: string;
  category: NoteCategory;
  body: string;
};

export type ConfirmActor = {
  id: string;
  name: string;
  email?: string;
};

export type RawCollectionRow = {
  id: string;
  drv_collected_at?: string | null;
};

export type RowGuardInput = {
  problem_flag?: boolean | null;
  notes?: string | null;
};

const NOTE_CATEGORIES: NoteCategory[] = ["collection", "note", "problem", "quantity", "confirm"];

function isNoteEntry(value: unknown): value is NoteEntry {
  if (!value || typeof value !== "object") return false;
  const candidate = value as Record<string, unknown>;
  return (
    typeof candidate.ts === "string" &&
    typeof candidate.actorId === "string" &&
    typeof candidate.actorName === "string" &&
    typeof candidate.body === "string" &&
    typeof candidate.category === "string" &&
    NOTE_CATEGORIES.includes(candidate.category as NoteCategory)
  );
}

function parseStoredNotes(value: string): NoteEntry[] {
  const trimmed = value.trim();
  if (!trimmed) return [];
  try {
    const parsed = JSON.parse(trimmed);
    if (!Array.isArray(parsed)) return [];
    return parsed.filter(isNoteEntry);
  } catch {
    return [];
  }
}

export function parseNotesFeed(rawNotes: string | null | undefined, row: RawCollectionRow): NoteEntry[] {
  const stored = parseStoredNotes(rawNotes ?? "");
  if (stored.length > 0) {
    return [...stored].sort((a, b) => b.ts.localeCompare(a.ts));
  }
  if (row.drv_collected_at) {
    return [
      {
        ts: row.drv_collected_at,
        actorId: "driver",
        actorName: "Driver",
        category: "collection",
        body: "Collected",
      },
    ];
  }
  return [];
}

export function appendNote(
  rawNotes: string | null | undefined,
  entry: { ts: string; actor: ConfirmActor; category: NoteCategory; body: string },
): string {
  const existing = parseStoredNotes(rawNotes ?? "");
  const next: NoteEntry[] = [
    ...existing,
    {
      ts: entry.ts,
      actorId: entry.actor.id,
      actorName: entry.actor.name,
      category: entry.category,
      body: entry.body,
    },
  ];
  return JSON.stringify(next);
}

export function canConfirmRow(row: RowGuardInput, _actorName: string): boolean {
  if (!row.problem_flag) return true;
  const entries = parseStoredNotes(row.notes ?? "");
  return entries.some((entry) => entry.category === "confirm");
}

export function nextItemStatusForConfirm(current: string): string {
  if (current === "collected") return "processed";
  return current;
}
  • Step 4: Run tests to verify they pass

Run: npm run test:run -- "src/app/(app)/collection-confirm/view-model.test.ts" Expected: PASS with 9 tests.

  • Step 5: Commit
git add src/app/(app)/collection-confirm/view-model.ts src/app/(app)/collection-confirm/view-model.test.ts
git commit -m "feat(collection-confirm): add notes-feed view-model helpers"

Task 2: Build the page UI shell and load collected rows

Files:

  • Create: src/app/(app)/collection-confirm/page.tsx

  • Test: src/app/(app)/collection-confirm/page.test.tsx

  • Step 1: Write the failing test

Create src/app/(app)/collection-confirm/page.test.tsx:

import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";

vi.mock("@/lib/pocketbase", () => ({
  getPocketBase: () => ({
    collection: () => ({
      getFullList: vi.fn(async () => [
        {
          id: "row-1",
          description: "Wheel hub kit",
          order_qty: 10,
          qty_collected: 9,
          item_status: "collected",
          problem_flag: false,
          notes: "",
          drv_collected_at: "2026-07-07T10:00:00.000Z",
          expand: {
            purchase_order: { id: "po-1", po_number: "PO-1", expand: { supplier: { id: "sup-1", suppliername: "Alpha Bearings" } } },
            order: { id: "ord-1", order_number: "SO-1", expand: { company: { company: "Anglo" }, customer: { customer: "North Pit" } } },
          },
        },
      ]),
    }),
  }),
}));

import Page from "./page";

describe("CollectionConfirmPage", () => {
  beforeEach(() => {
    vi.spyOn(window, "fetch").mockResolvedValue({
      ok: true,
      json: async () => [],
    } as Response);
  });

  it("renders collected rows with the required fields", async () => {
    render(<Page />);
    await waitFor(() => expect(screen.getByText("Wheel hub kit")).toBeInTheDocument());
    expect(screen.getByText("Alpha Bearings")).toBeInTheDocument();
    expect(screen.getByText("North Pit")).toBeInTheDocument();
    expect(screen.getByText("Anglo")).toBeInTheDocument();
    expect(screen.getByText(/PO-1/)).toBeInTheDocument();
  });
});
  • Step 2: Run the test to verify it fails

Run: npm run test:run -- "src/app/(app)/collection-confirm/page.test.tsx" Expected: FAIL with module not found.

  • Step 3: Implement the page shell

Create src/app/(app)/collection-confirm/page.tsx:

"use client";

import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AlertTriangle, ExternalLink, FileText, Loader2, MessageSquare, Pencil, Truck, Building2, UserRound, ImageIcon } from "lucide-react";
import { toast } from "sonner";

import { Button } from "@/components/ui/button";
import { ModalShell } from "@/components/ui/modal-shell";
import { getPocketBase } from "@/lib/pocketbase";
import { cn } from "@/lib/utils";

import {
  appendNote,
  canConfirmRow,
  nextItemStatusForConfirm,
  parseNotesFeed,
  type ConfirmActor,
  type NoteEntry,
} from "./view-model";

type ConfirmRow = {
  id: string;
  description?: string;
  po_description?: string;
  order_qty?: number;
  qty_collected?: number;
  quantity?: number;
  item_status?: string;
  problem_flag?: boolean | null;
  problem_type?: string | null;
  problem_notes?: string | null;
  notes?: string | null;
  drv_collected_at?: string | null;
  collection_photo?: string;
  expand?: {
    purchase_order?: { id: string; po_number?: string; expand?: { supplier?: { id: string; suppliername?: string } } };
    order?: { id: string; order_number?: string; expand?: { company?: { company?: string }; customer?: { customer?: string } } };
  };
};

const SUPABASE_FILE_URL = "/api/v1/files/order_items";

function fileUrl(row: ConfirmRow, filename: string): string {
  return `${SUPABASE_FILE_URL}/${row.id}/${encodeURIComponent(filename)}`;
}

function EmptyState({ message }: { message: string }) {
  return (
    <div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center">
      <Truck className="mb-3 h-8 w-8 text-muted-foreground/50" />
      <p className="text-sm text-muted-foreground">{message}</p>
    </div>
  );
}

function categoryLabel(category: NoteEntry["category"]): string {
  switch (category) {
    case "quantity":
      return "Quantity";
    case "problem":
      return "Problem";
    case "confirm":
      return "Confirm";
    case "collection":
      return "Collection";
    default:
      return "Note";
  }
}

export default function CollectionConfirmPage() {
  const [rows, setRows] = useState<ConfirmRow[] | null>(null);
  const [actor, setActor] = useState<ConfirmActor>({ id: "self", name: "Operator" });
  const [busyId, setBusyId] = useState<string | null>(null);

  const load = useCallback(async () => {
    setRows(null);
    try {
      const pb = getPocketBase();
      const items = await pb.collection("order_items").getFullList<ConfirmRow>({
        filter: 'item_status = "collected"',
        sort: "-drv_collected_at",
        expand: "purchase_order.supplier,order.company,order.customer",
      });
      setRows(items);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      toast.error(message);
      setRows([]);
    }
  }, []);

  useEffect(() => {
    const handle = window.setTimeout(() => {
      void load();
    }, 0);
    return () => window.clearTimeout(handle);
  }, [load]);

  useEffect(() => {
    if (typeof window === "undefined") return;
    const cached = window.localStorage?.getItem("wfdash.actor");
    if (cached) {
      try {
        const parsed = JSON.parse(cached) as ConfirmActor;
        if (parsed?.id) setActor(parsed);
      } catch {
        /* ignore */
      }
    }
  }, []);

  const persistRow = useCallback(
    async (id: string, patch: Partial<ConfirmRow>, notesPatch?: string) => {
      setBusyId(id);
      try {
        const pb = getPocketBase();
        const next: Record<string, unknown> = { ...patch };
        if (typeof notesPatch === "string") next.notes = notesPatch;
        await pb.collection("order_items").update(id, next);
        await load();
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        toast.error(message);
      } finally {
        setBusyId(null);
      }
    },
    [load],
  );

  const handleQuantity = useCallback(
    async (row: ConfirmRow, newQty: number, reason: string) => {
      const note = appendNote(row.notes ?? "", {
        ts: new Date().toISOString(),
        actor,
        category: "quantity",
        body: `Adjusted qty ${row.qty_collected ?? 0} → ${newQty}. ${reason}`.trim(),
      });
      await persistRow(row.id, { qty_collected: newQty }, note);
    },
    [actor, persistRow],
  );

  const handleAddNote = useCallback(
    async (row: ConfirmRow, body: string) => {
      const note = appendNote(row.notes ?? "", {
        ts: new Date().toISOString(),
        actor,
        category: "note",
        body,
      });
      await persistRow(row.id, {}, note);
    },
    [actor, persistRow],
  );

  const handleConfirm = useCallback(
    async (row: ConfirmRow) => {
      const confirmEntry = appendNote(row.notes ?? "", {
        ts: new Date().toISOString(),
        actor,
        category: "confirm",
        body: "Confirmed for invoicing",
      });
      await persistRow(row.id, { item_status: nextItemStatusForConfirm(row.item_status ?? "collected") }, confirmEntry);
      toast.success("Row confirmed for invoicing");
    },
    [actor, persistRow],
  );

  const rowsView = useMemo(() => rows ?? [], [rows]);

  return (
    <div className="space-y-6">
      <header className="flex items-start justify-between gap-4">
        <div>
          <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
            <Truck className="h-5 w-5 text-muted-foreground" />
            Collection Confirm
          </h1>
          <p className="mt-1 text-sm text-muted-foreground">
            Review collected order items, adjust quantity, add notes, and confirm for invoicing.
          </p>
        </div>
        <Button variant="outline" size="sm" onClick={() => void load()}>
          Refresh
        </Button>
      </header>

      {rows === null && (
        <div className="space-y-2">
          {Array.from({ length: 4 }).map((_, i) => (
            <div key={i} className="h-20 w-full animate-pulse rounded-md bg-muted/30" aria-label="Loading..." />
          ))}
        </div>
      )}

      {rows !== null && rows.length === 0 && (
        <EmptyState message="No items are awaiting confirmation." />
      )}

      {rows !== null && rowsView.length > 0 && (
        <ul className="space-y-3" data-od-id="collection-confirm-list">
          {rowsView.map((row) => (
            <ConfirmCard
              key={row.id}
              row={row}
              busy={busyId === row.id}
              actor={actor}
              onQuantity={handleQuantity}
              onAddNote={handleAddNote}
              onConfirm={handleConfirm}
            />
          ))}
        </ul>
      )}
    </div>
  );
}

function ConfirmCard({
  row,
  busy,
  actor,
  onQuantity,
  onAddNote,
  onConfirm,
}: {
  row: ConfirmRow;
  busy: boolean;
  actor: ConfirmActor;
  onQuantity: (row: ConfirmRow, qty: number, reason: string) => Promise<void>;
  onAddNote: (row: ConfirmRow, body: string) => Promise<void>;
  onConfirm: (row: ConfirmRow) => Promise<void>;
}) {
  const [quantityOpen, setQuantityOpen] = useState(false);
  const [noteOpen, setNoteOpen] = useState(false);
  const [photoOpen, setPhotoOpen] = useState(false);
  const entries = parseNotesFeed(row.notes ?? "", row);
  const canConfirm = canConfirmRow({ problem_flag: row.problem_flag, notes: row.notes }, actor.name);

  return (
    <li className="overflow-hidden rounded-xl border bg-card shadow-sm">
      <div className="grid gap-3 p-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.9fr)_auto]">
        <div className="min-w-0">
          <p className="truncate text-sm font-semibold">
            {row.expand?.purchase_order?.expand?.supplier?.suppliername ?? "Unknown supplier"}
          </p>
          <p className="truncate text-sm text-foreground">{row.description || "(no description)"}</p>
          <div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
            {row.expand?.order?.expand?.customer?.customer && (
              <span className="inline-flex items-center gap-1">
                <UserRound className="h-3 w-3" />
                {row.expand.order.expand.customer.customer}
              </span>
            )}
            {row.expand?.order?.expand?.company?.company && (
              <span className="inline-flex items-center gap-1">
                <Building2 className="h-3 w-3" />
                {row.expand.order.expand.company.company}
              </span>
            )}
            {row.expand?.purchase_order?.po_number && (
              <span className="inline-flex items-center gap-1 font-mono">
                <FileText className="h-3 w-3" />
                {row.expand.purchase_order.po_number}
              </span>
            )}
            {row.expand?.order && (
              <Link href={`/orders/${row.expand.order.id}`} className="inline-flex items-center gap-1 hover:underline">
                Order {row.expand.order.order_number ?? row.expand.order.id.slice(0, 6)}
                <ExternalLink className="h-3 w-3" />
              </Link>
            )}
          </div>
        </div>

        <div className="min-w-0 space-y-1 text-sm">
          <div className="flex items-center gap-3">
            <span className="text-xs text-muted-foreground">Ordered</span>
            <span className="font-mono">{row.order_qty ?? row.quantity ?? 0}</span>
          </div>
          <div className="flex items-center gap-3">
            <span className="text-xs text-muted-foreground">Collected</span>
            <span className="font-mono">{row.qty_collected ?? 0}</span>
          </div>
          {row.problem_flag && (
            <div className="inline-flex items-center gap-1 rounded-md bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">
              <AlertTriangle className="h-3 w-3" />
              Problem: {row.problem_type ?? "flagged"}
            </div>
          )}
          {row.collection_photo && (
            <Button size="xs" variant="outline" onClick={() => setPhotoOpen(true)}>
              <ImageIcon className="mr-1 h-3 w-3" />
              View photo
            </Button>
          )}
        </div>

        <div className="flex flex-col items-stretch gap-2 lg:min-w-[12rem]">
          <Button size="sm" variant="outline" onClick={() => setQuantityOpen(true)} disabled={busy}>
            <Pencil className="mr-1 h-3.5 w-3.5" />
            Adjust Quantity
          </Button>
          <Button size="sm" variant="outline" onClick={() => setNoteOpen(true)} disabled={busy}>
            <MessageSquare className="mr-1 h-3.5 w-3.5" />
            Add Note
          </Button>
          <Button size="sm" disabled={busy || !canConfirm} onClick={() => void onConfirm(row)} className="bg-emerald-600 text-white hover:bg-emerald-700">
            {busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
            Confirm for invoicing
          </Button>
          {!canConfirm && (
            <p className="text-[11px] text-muted-foreground">Add a confirming note before invoicing this row.</p>
          )}
        </div>
      </div>

      <div className="border-t bg-muted/30 px-4 py-3">
        <p className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">Notes</p>
        {entries.length === 0 ? (
          <p className="mt-1 text-xs text-muted-foreground">No notes yet.</p>
        ) : (
          <ul className="mt-2 space-y-2">
            {entries.map((entry, idx) => (
              <li key={`${entry.ts}-${idx}`} className="rounded-md border bg-card p-2 text-xs">
                <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
                  <span>{new Date(entry.ts).toLocaleString()}</span>
                  <span>·</span>
                  <span>{entry.actorName}</span>
                  <span>·</span>
                  <span className="font-semibold uppercase tracking-wide">{categoryLabel(entry.category)}</span>
                </div>
                <p className="mt-1 text-foreground">{entry.body}</p>
              </li>
            ))}
          </ul>
        )}
      </div>

      <QuantityDialog
        open={quantityOpen}
        row={row}
        busy={busy}
        onClose={() => setQuantityOpen(false)}
        onSubmit={(qty, reason) => {
          setQuantityOpen(false);
          return onQuantity(row, qty, reason);
        }}
      />

      <NoteDialog
        open={noteOpen}
        busy={busy}
        onClose={() => setNoteOpen(false)}
        onSubmit={(body) => {
          setNoteOpen(false);
          return onAddNote(row, body);
        }}
      />

      <ModalShell.Dialog open={photoOpen} onOpenChange={setPhotoOpen} size="lg" title="Collection photo">
        {row.collection_photo ? (
          <img src={fileUrl(row, row.collection_photo)} alt="Collection photo" className="max-h-[60vh] w-full rounded-md object-contain" />
        ) : (
          <p className="text-sm text-muted-foreground">No photo attached.</p>
        )}
      </ModalShell.Dialog>
    </li>
  );
}

function QuantityDialog({
  open,
  row,
  busy,
  onClose,
  onSubmit,
}: {
  open: boolean;
  row: ConfirmRow;
  busy: boolean;
  onClose: () => void;
  onSubmit: (qty: number, reason: string) => Promise<void>;
}) {
  const [value, setValue] = useState<string>(String(row.qty_collected ?? 0));
  const [reason, setReason] = useState<string>("");

  useEffect(() => {
    if (open) {
      setValue(String(row.qty_collected ?? 0));
      setReason("");
    }
  }, [open, row.qty_collected]);

  const numeric = Number(value);
  const valid = Number.isFinite(numeric) && numeric >= 0 && reason.trim().length > 0 && !busy;

  return (
    <ModalShell.Dialog
      open={open}
      onOpenChange={(state) => {
        if (!state) onClose();
      }}
      size="md"
      eyebrow={`${row.expand?.purchase_order?.po_number ?? "Item"}`}
      title="Adjust collected quantity"
      description={`Ordered ${row.order_qty ?? row.quantity ?? 0}. Driver recorded ${row.qty_collected ?? 0}.`}
      footer={
        <div className="flex justify-end gap-2">
          <Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
            Cancel
          </Button>
          <Button
            type="button"
            disabled={!valid}
            onClick={() => void onSubmit(numeric, reason.trim())}
          >
            {busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
            Confirm adjustment
          </Button>
        </div>
      }
    >
      <div className="space-y-3">
        <label className="block text-xs font-medium text-muted-foreground" htmlFor="qty-input">New collected quantity</label>
        <input
          id="qty-input"
          type="number"
          min={0}
          value={value}
          onChange={(e) => setValue(e.target.value)}
          className="w-full rounded-md border bg-background px-3 py-2 text-sm"
        />
        <label className="block text-xs font-medium text-muted-foreground" htmlFor="qty-reason">Reason</label>
        <textarea
          id="qty-reason"
          value={reason}
          onChange={(e) => setReason(e.target.value)}
          rows={3}
          className="w-full rounded-md border bg-background px-3 py-2 text-sm"
          placeholder="Why is this changing?"
        />
      </div>
    </ModalShell.Dialog>
  );
}

function NoteDialog({
  open,
  busy,
  onClose,
  onSubmit,
}: {
  open: boolean;
  busy: boolean;
  onClose: () => void;
  onSubmit: (body: string) => Promise<void>;
}) {
  const [body, setBody] = useState("");
  useEffect(() => {
    if (open) setBody("");
  }, [open]);

  const valid = body.trim().length > 0 && !busy;

  return (
    <ModalShell.Dialog
      open={open}
      onOpenChange={(state) => {
        if (!state) onClose();
      }}
      size="md"
      title="Add note"
      description="Visible on this row only. Append-only."
      footer={
        <div className="flex justify-end gap-2">
          <Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
            Cancel
          </Button>
          <Button
            type="button"
            disabled={!valid}
            onClick={() => void onSubmit(body.trim())}
          >
            {busy ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
            Save note
          </Button>
        </div>
      }
    >
      <textarea
        value={body}
        onChange={(e) => setBody(e.target.value)}
        rows={4}
        className={cn("w-full rounded-md border bg-background px-3 py-2 text-sm")}
        placeholder="Type a note for the audit trail."
      />
    </ModalShell.Dialog>
  );
}
  • Step 4: Run tests to verify they pass

Run: npm run test:run -- "src/app/(app)/collection-confirm/page.test.tsx" Expected: PASS.

  • Step 5: Lint the page

Run: npm run lint -- "src/app/(app)/collection-confirm/page.tsx" "src/app/(app)/collection-confirm/page.test.tsx" Expected: PASS.

  • Step 6: Commit
git add src/app/(app)/collection-confirm/page.tsx src/app/(app)/collection-confirm/page.test.tsx
git commit -m "feat(collection-confirm): add page UI with collected rows and actions"

Task 3: Add the sidebar entry

Files:

  • Modify: src/components/sidebar.tsx:84-87

  • Step 1: Add the new entry

In src/components/sidebar.tsx, update the operationsItems array:

const operationsItems: NavEntry[] = [
  { href: "/collection-pool", label: "Collection Pool", icon: Package },
  { href: "/collection-map", label: "Collection Map", icon: MapPin },
  { href: "/collection-confirm", label: "Collection Confirm", icon: CheckCircle },
];
  • Step 2: Verify the build still compiles

Run: npm run lint -- "src/components/sidebar.tsx" Expected: PASS.

  • Step 3: Commit
git add src/components/sidebar.tsx
git commit -m "feat(collection-confirm): surface new page in sidebar"

Task 4: Browser validation

Files: no code changes expected.

  • Step 1: Validate in the browser

Use the running dev server at http://localhost:4000/collection-confirm. Confirm:

  • the page renders collected rows,

  • all required fields appear (supplier, item, customer, company, PO, qty ordered/collected, problem flag, photo, notes preview),

  • the sidebar shows the new entry,

  • the Adjust Quantity modal requires a reason and updates the row,

  • the Add Note modal appends to the notes feed,

  • the Confirm for invoicing action flips the item status and records a confirm entry.

  • Step 2: Commit validation notes

No code commit; record observations only.