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
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 underoperationsItems.
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.