742 lines
No EOL
23 KiB
Markdown
742 lines
No EOL
23 KiB
Markdown
# Collection Pool Mark-Ready Notes — 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:** Require a free-text note when an operator marks a collection-pool row ready. The note is stored in the existing `OrderItem.notes` field and surfaces as a small chip on the row.
|
|
|
|
**Architecture:** New modal on the Collection Pool page; extend the existing PATCH route's body schema to accept `ready_notes`; add a notes chip on each row.
|
|
|
|
**Tech Stack:** Next.js 16 client component, shadcn/ui (Dialog + Textarea + Button + Tooltip), Vitest, PocketBase admin.
|
|
|
|
**Spec:** [docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md](file:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/docs/superpowers/specs/2026-07-09-collection-pool-mark-ready-notes-design.md)
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
| File | Responsibility | Touched? |
|
|
| --- | --- | --- |
|
|
| `src/app/api/v1/collection-map/items/[id]/assign/route.ts` | Accept `ready_notes`, validate required-on-ready, write to `notes` | Modify |
|
|
| `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts` | Cover new validation + write paths | Create |
|
|
| `src/components/ui/mark-ready-dialog.tsx` | New controlled modal | Create |
|
|
| `src/components/ui/mark-ready-dialog.test.tsx` | New component tests | Create |
|
|
| `src/app/(app)/collection-pool/page.tsx` | Open modal from `Mark Ready`, render notes chip | Modify |
|
|
|
|
---
|
|
|
|
## Task 1: Server — accept `ready_notes` and write to `notes`
|
|
|
|
**Files:**
|
|
- Modify: `src/app/api/v1/collection-map/items/[id]/assign/route.ts`
|
|
|
|
- [ ] **Step 1: Extend the body type**
|
|
|
|
In the route file, update the `BodyInput` type:
|
|
|
|
```ts
|
|
type BodyInput = {
|
|
driverId?: string;
|
|
date?: string;
|
|
ready_for_collection?: boolean;
|
|
ready_notes?: string;
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Validate `ready_notes` is required when ready**
|
|
|
|
Right after the `body = ...` parse, add the validation block:
|
|
|
|
```ts
|
|
// `ready_notes` is required (non-empty after trim) when the operator
|
|
// is marking a row ready. The Collection Pool modal enforces this
|
|
// client-side, but we re-check here so a direct API call can't skip
|
|
// it. `ready_notes` is ignored (and not required) on the unmark
|
|
// path and on pure driver-assign paths.
|
|
const readyNotes = typeof body.ready_notes === "string" ? body.ready_notes.trim() : "";
|
|
const isMarkingReady = body.ready_for_collection === true;
|
|
if (isMarkingReady && readyNotes.length === 0) {
|
|
return NextResponse.json(
|
|
{ error: "A note is required when marking ready." },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
```
|
|
|
|
Place this AFTER the existing `assignedAt` validation and BEFORE the `isAssigning` block.
|
|
|
|
- [ ] **Step 3: Add `notes: readyNotes` to the patch when marking ready**
|
|
|
|
Inside the `if (typeof body.ready_for_collection === "boolean")` block (currently sets `patch.ready_for_collection`), also set the `notes` field when ready is true. The whole block becomes:
|
|
|
|
```ts
|
|
if (typeof body.ready_for_collection === "boolean") {
|
|
patch.ready_for_collection = body.ready_for_collection;
|
|
if (body.ready_for_collection && readyNotes.length > 0) {
|
|
// Trimmed server-side so the audit log is always clean.
|
|
patch.notes = readyNotes;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Surface the saved note in the response**
|
|
|
|
The `applied` object is informational; add `notes: patch.notes ?? null` so the client can confirm what was written:
|
|
|
|
```ts
|
|
applied: {
|
|
item_status: patch.item_status,
|
|
drv_status: patch.drv_status,
|
|
drv_id: patch.drv_id,
|
|
assigned_at: patch.assigned_at,
|
|
notes: patch.notes ?? null,
|
|
ready_for_collection: patch.ready_for_collection,
|
|
},
|
|
```
|
|
|
|
- [ ] **Step 5: Lint**
|
|
|
|
```bash
|
|
npx eslint 'src/app/api/v1/collection-map/items/[id]/assign/route.ts'
|
|
```
|
|
|
|
Expected: exit 0. Pre-existing errors (if any) on unrelated lines are fine.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add 'src/app/api/v1/collection-map/items/[id]/assign/route.ts'
|
|
git commit -m "feat(collection-map): require ready_notes when marking ready"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Failing tests for the route
|
|
|
|
**Files:**
|
|
- Create: `src/app/api/v1/collection-map/items/[id]/assign/route.test.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
The tests need the same admin-PB mock pattern as the other v1 route tests. Mock `getAdminPocketBase` to return a fake client with `collection(name).getOne` and `update` on it. Use the production admin pattern: `collection(name).getOne(id).catch(() => null)` for the existence check, then `update(id, patch)`.
|
|
|
|
```ts
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { NextRequest } from "next/server";
|
|
|
|
const getOneMock = vi.fn();
|
|
const updateMock = vi.fn();
|
|
|
|
vi.mock("@/lib/server/pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue({
|
|
collection: () => ({
|
|
getOne: getOneMock,
|
|
update: updateMock,
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
import { PATCH } from "./route";
|
|
|
|
function makeReq(body: unknown) {
|
|
return new NextRequest(
|
|
"http://localhost/api/v1/collection-map/items/item-1/assign",
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
}
|
|
|
|
describe("PATCH /api/v1/collection-map/items/[id]/assign", () => {
|
|
beforeEach(() => {
|
|
getOneMock.mockReset();
|
|
updateMock.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("writes trimmed notes to the item when ready_notes is provided", async () => {
|
|
getOneMock.mockResolvedValueOnce({ id: "item-1" });
|
|
updateMock.mockResolvedValueOnce({ id: "item-1" });
|
|
|
|
const res = await PATCH(
|
|
makeReq({
|
|
ready_for_collection: true,
|
|
ready_notes: " spoke to David, ETA tomorrow ",
|
|
}),
|
|
{ params: Promise.resolve({ id: "item-1" }) },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const payload = updateMock.mock.calls[0][1] as Record<string, unknown>;
|
|
expect(payload.notes).toBe("spoke to David, ETA tomorrow");
|
|
expect(payload.ready_for_collection).toBe(true);
|
|
});
|
|
|
|
it("returns 400 when ready_for_collection is true and ready_notes is empty", async () => {
|
|
const res = await PATCH(
|
|
makeReq({ ready_for_collection: true, ready_notes: "" }),
|
|
{ params: Promise.resolve({ id: "item-1" }) },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when ready_for_collection is true and ready_notes is whitespace only", async () => {
|
|
const res = await PATCH(
|
|
makeReq({ ready_for_collection: true, ready_notes: " \n\t " }),
|
|
{ params: Promise.resolve({ id: "item-1" }) },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 400 when ready_for_collection is true and ready_notes is missing", async () => {
|
|
const res = await PATCH(
|
|
makeReq({ ready_for_collection: true }),
|
|
{ params: Promise.resolve({ id: "item-1" }) },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
expect(updateMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts the unmark path without a note (ready_for_collection: false)", async () => {
|
|
getOneMock.mockResolvedValueOnce({ id: "item-1", notes: "existing" });
|
|
updateMock.mockResolvedValueOnce({ id: "item-1" });
|
|
|
|
const res = await PATCH(
|
|
makeReq({ ready_for_collection: false }),
|
|
{ params: Promise.resolve({ id: "item-1" }) },
|
|
);
|
|
|
|
expect(res.status).toBe(200);
|
|
const payload = updateMock.mock.calls[0][1] as Record<string, unknown>;
|
|
expect(payload.ready_for_collection).toBe(false);
|
|
// The unmark path must not silently overwrite the existing note.
|
|
expect(payload.notes).toBeUndefined();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test file**
|
|
|
|
```bash
|
|
npm run test:run -- 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
|
|
```
|
|
|
|
Expected: 5/5 pass (the implementation from Task 1 should make all of them green).
|
|
|
|
- [ ] **Step 3: Lint**
|
|
|
|
```bash
|
|
npx eslint 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
|
|
```
|
|
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add 'src/app/api/v1/collection-map/items/[id]/assign/route.test.ts'
|
|
git commit -m "test(collection-map): cover ready_notes required-on-ready"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: New `MarkReadyDialog` component
|
|
|
|
**Files:**
|
|
- Create: `src/components/ui/mark-ready-dialog.tsx`
|
|
|
|
- [ ] **Step 1: Create the component**
|
|
|
|
```tsx
|
|
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
/**
|
|
* Modal that asks the operator for a free-text note before marking a
|
|
* collection-pool row ready. The note is required (non-empty after
|
|
* trim) and is saved into `OrderItem.notes` by the server route. We
|
|
* also surface a small "Notes" chip on the row whenever the row has
|
|
* any notes, so the operator can see at a glance whether they've
|
|
* already written something.
|
|
*
|
|
* The submit button is disabled until the textarea is non-empty.
|
|
* Whitespace-only content is treated as empty for the disabled
|
|
* state and the server validation.
|
|
*/
|
|
export interface MarkReadyDialogProps {
|
|
open: boolean;
|
|
/** PO number + item description, formatted for the title. */
|
|
poLabel: string;
|
|
/** Item description, formatted for the title. */
|
|
itemLabel: string;
|
|
/** Any pre-existing note (not currently used; reserved for edit flows). */
|
|
initialNote?: string;
|
|
busy?: boolean;
|
|
serverError?: string | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSubmit: (note: string) => void;
|
|
}
|
|
|
|
export function MarkReadyDialog({
|
|
open,
|
|
poLabel,
|
|
itemLabel,
|
|
initialNote = "",
|
|
busy = false,
|
|
serverError = null,
|
|
onOpenChange,
|
|
onSubmit,
|
|
}: MarkReadyDialogProps) {
|
|
const [note, setNote] = useState(initialNote);
|
|
const trimmed = note.trim();
|
|
const canSubmit = trimmed.length > 0 && !busy;
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!busy) {
|
|
if (!o) setNote("");
|
|
onOpenChange(o);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-md max-h-[90vh] grid-rows-[auto_1fr_auto] gap-0 p-0 overflow-hidden">
|
|
<DialogHeader className="px-6 pt-6 pb-4 border-b">
|
|
<DialogTitle>
|
|
Mark ready: {poLabel} · {itemLabel}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Add a note for the audit log. Required.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="px-6 py-4 space-y-2 overflow-y-auto">
|
|
<Textarea
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
placeholder="e.g. Spoke to David — confirmed pickup tomorrow 09:00"
|
|
rows={4}
|
|
maxLength={500}
|
|
autoFocus
|
|
disabled={busy}
|
|
className="min-h-24"
|
|
onKeyDown={(e) => {
|
|
// Cmd/Ctrl+Enter submits, like a comment box.
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canSubmit) {
|
|
e.preventDefault();
|
|
onSubmit(trimmed);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
<span>{note.length}/500</span>
|
|
<span className="hidden sm:inline">⌘/Ctrl + Enter to save</span>
|
|
</div>
|
|
{serverError && (
|
|
<p
|
|
role="alert"
|
|
className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive"
|
|
>
|
|
{serverError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter className="px-6 py-4 border-t">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
disabled={busy}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => onSubmit(trimmed)}
|
|
disabled={!canSubmit}
|
|
>
|
|
{busy && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
|
Save & Mark Ready
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
```
|
|
|
|
Note: this component imports the existing `Dialog` / `DialogContent` / etc. from `@/components/ui/dialog`. It also imports `Textarea` and `Button`. If any of those are missing, add them.
|
|
|
|
- [ ] **Step 2: Lint**
|
|
|
|
```bash
|
|
npx eslint src/components/ui/mark-ready-dialog.tsx
|
|
```
|
|
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 3: Commit (component only)**
|
|
|
|
```bash
|
|
git add src/components/ui/mark-ready-dialog.tsx
|
|
git commit -m "feat(collection-pool): add MarkReadyDialog component"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Failing tests for `MarkReadyDialog`
|
|
|
|
**Files:**
|
|
- Create: `src/components/ui/mark-ready-dialog.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
```tsx
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
import { MarkReadyDialog } from "./mark-ready-dialog";
|
|
|
|
const PO_LABEL = "PO-2026-07-009";
|
|
const ITEM_LABEL = "Magnetic drill";
|
|
|
|
describe("MarkReadyDialog", () => {
|
|
beforeEach(() => {
|
|
class ResizeObserverMock {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
}
|
|
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it("renders the PO and item in the title", () => {
|
|
render(
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
/>,
|
|
);
|
|
expect(
|
|
screen.getByText(`Mark ready: ${PO_LABEL} · ${ITEM_LABEL}`),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("disables Save when the textarea is empty", () => {
|
|
render(
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole("button", { name: /save & mark ready/i })).toBeDisabled();
|
|
});
|
|
|
|
it("disables Save when the textarea is whitespace only", () => {
|
|
render(
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
/>,
|
|
);
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: " \n " },
|
|
});
|
|
expect(screen.getByRole("button", { name: /save & mark ready/i })).toBeDisabled();
|
|
});
|
|
|
|
it("enables Save and submits the trimmed note", () => {
|
|
const onSubmit = vi.fn();
|
|
render(
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={onSubmit}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
/>,
|
|
);
|
|
fireEvent.change(screen.getByRole("textbox"), {
|
|
target: { value: " spoke to David " },
|
|
});
|
|
const save = screen.getByRole("button", { name: /save & mark ready/i });
|
|
expect(save).not.toBeDisabled();
|
|
fireEvent.click(save);
|
|
expect(onSubmit).toHaveBeenCalledWith("spoke to David");
|
|
});
|
|
|
|
it("renders a server error without closing the modal", () => {
|
|
render(
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
onSubmit={vi.fn()}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
serverError="A note is required when marking ready."
|
|
/>,
|
|
);
|
|
expect(
|
|
screen.getByRole("alert", { name: /a note is required when marking ready/i }) ??
|
|
screen.getByText(/a note is required when marking ready/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not call onSubmit when the dialog is closed", () => {
|
|
const onSubmit = vi.fn();
|
|
const onOpenChange = vi.fn();
|
|
render(
|
|
<MarkReadyDialog
|
|
open={false}
|
|
onOpenChange={onOpenChange}
|
|
onSubmit={onSubmit}
|
|
poLabel={PO_LABEL}
|
|
itemLabel={ITEM_LABEL}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.queryByRole("button", { name: /save & mark ready/i })!);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test file**
|
|
|
|
```bash
|
|
npm run test:run -- src/components/ui/mark-ready-dialog.test.tsx
|
|
```
|
|
|
|
Expected: 6/6 pass.
|
|
|
|
- [ ] **Step 3: Lint**
|
|
|
|
```bash
|
|
npx eslint src/components/ui/mark-ready-dialog.test.tsx
|
|
```
|
|
|
|
Expected: exit 0.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/ui/mark-ready-dialog.test.tsx
|
|
git commit -m "test(collection-pool): cover MarkReadyDialog behaviour"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Wire the modal into the Collection Pool page
|
|
|
|
**Files:**
|
|
- Modify: `src/app/(app)/collection-pool/page.tsx`
|
|
|
|
- [ ] **Step 1: Add the new state and import**
|
|
|
|
Near the top of the page component, alongside `assignDialog`, add the new state for the mark-ready modal:
|
|
|
|
```ts
|
|
const [readyDialogRow, setReadyDialogRow] = useState<CollectionPoolVisibleRow | null>(null);
|
|
const [readyDialogError, setReadyDialogError] = useState<string | null>(null);
|
|
```
|
|
|
|
Also import the new dialog component near the top of the file (alongside the existing `AssignDriverDialog` import):
|
|
|
|
```ts
|
|
import { MarkReadyDialog } from "@/components/ui/mark-ready-dialog";
|
|
```
|
|
|
|
- [ ] **Step 2: Replace `handleToggleReady` with the modal-opening handler**
|
|
|
|
The current `handleToggleReady` calls the route directly. Replace it with two handlers — one that opens the modal when marking ready, one that just toggles the flag when unmarking. Also add a `submitMarkReady` handler.
|
|
|
|
Find the existing `handleToggleReady` callback (search for `handleToggleReady`) and replace it with:
|
|
|
|
```ts
|
|
const handleToggleReady = useCallback(
|
|
async (row: CollectionPoolVisibleRow, ready: boolean) => {
|
|
// Unmarking is a single PATCH with no note. The modal only opens
|
|
// for the "mark ready" direction.
|
|
if (!ready) {
|
|
setBusyId(row.id);
|
|
try {
|
|
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ ready_for_collection: false }),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
throw new Error(text || `HTTP ${res.status}`);
|
|
}
|
|
toast.success("Marked awaiting supplier confirmation.");
|
|
await load();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
return;
|
|
}
|
|
// Marking ready opens the modal. The actual PATCH is fired from
|
|
// `submitMarkReady` once the operator has typed a note.
|
|
setReadyDialogError(null);
|
|
setReadyDialogRow(row);
|
|
},
|
|
[load],
|
|
);
|
|
|
|
const submitMarkReady = useCallback(
|
|
async (note: string) => {
|
|
const row = readyDialogRow;
|
|
if (!row) return;
|
|
setBusyId(row.id);
|
|
setReadyDialogError(null);
|
|
try {
|
|
const res = await fetch(`/api/v1/collection-map/items/${row.id}/assign`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
ready_for_collection: true,
|
|
ready_notes: note,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
const message = body?.error || `HTTP ${res.status}`;
|
|
// Keep the modal open so the operator can fix the error.
|
|
setReadyDialogError(message);
|
|
throw new Error(message);
|
|
}
|
|
toast.success("Marked ready — note saved.");
|
|
setReadyDialogRow(null);
|
|
await load();
|
|
} catch (err) {
|
|
// Error already shown via readyDialogError / toast.
|
|
console.error("Mark ready failed:", err);
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
},
|
|
[load, readyDialogRow],
|
|
);
|
|
```
|
|
|
|
- [ ] **Step 3: Update the existing row button to open the modal**
|
|
|
|
Find the `Mark Ready` / `Mark Awaiting` button in the row JSX (search for `Mark Ready`). The `onClick` currently calls `() => onToggleReady(row.id, !row.ready_for_collection)`. Change it to call the new handler with the row object instead of just the id:
|
|
|
|
```tsx
|
|
onClick={() => handleToggleReady(row, !row.ready_for_collection)}
|
|
```
|
|
|
|
You will need to confirm `row` here is a `CollectionPoolVisibleRow` (not the raw `CollectionPoolRow`). Check the surrounding `row` variable. If it's the raw row, look at the parent loop to see if there's a visible-row type you can use. If only the raw row is in scope, adapt the handler to take the raw row (the `useCallback` deps will need `row.id` etc.).
|
|
|
|
- [ ] **Step 4: Render the dialog and the notes chip**
|
|
|
|
Near the bottom of the component, just before the closing `</div>` of the page root (search for the existing `AssignDriverDialog` JSX to find the spot), add:
|
|
|
|
```tsx
|
|
{readyDialogRow && (
|
|
<MarkReadyDialog
|
|
open
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
setReadyDialogRow(null);
|
|
setReadyDialogError(null);
|
|
}
|
|
}}
|
|
onSubmit={submitMarkReady}
|
|
poLabel={readyDialogRow.poNumber}
|
|
itemLabel={readyDialogRow.description}
|
|
busy={busyId === readyDialogRow.id}
|
|
serverError={readyDialogError}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
For the notes chip, find the row's existing badge area (search for the `Ready`/`Awaiting` badge — the `readyLabel` constant). Add a notes chip next to it. The chip is `inline-flex items-center rounded-md border bg-muted/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground` and shows `title={row.notes}` (browser tooltip) + `Notes` text. Render only if `row.notes && row.notes.trim().length > 0`. If you're uncertain about the row type having `notes`, check the `CollectionPoolRow` type at the top of the file (it already has `notes: string` from the base `OrderItem` type — confirmed by the audit earlier).
|
|
|
|
- [ ] **Step 5: Lint**
|
|
|
|
```bash
|
|
npx eslint 'src/app/(app)/collection-pool/page.tsx'
|
|
```
|
|
|
|
Expected: exit 0. The pre-existing 6 test failures in this file are not lint errors — they're the ones we already documented as a pre-existing issue. Lint should pass cleanly.
|
|
|
|
- [ ] **Step 6: Run any existing collection-pool tests**
|
|
|
|
```bash
|
|
npm run test:run -- 'src/app/(app)/collection-pool'
|
|
```
|
|
|
|
Expected: same 6 pre-existing failures. No new failures.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add 'src/app/(app)/collection-pool/page.tsx'
|
|
git commit -m "feat(collection-pool): require note on Mark Ready"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Final verification
|
|
|
|
- [ ] **Step 1: Targeted tests**
|
|
|
|
```bash
|
|
npm run test:run -- 'src/app/api/v1/collection-map/items/[id]/assign' src/components/ui/mark-ready-dialog
|
|
```
|
|
|
|
Expected: all targeted tests pass (5 route + 6 component = 11).
|
|
|
|
- [ ] **Step 2: Full suite**
|
|
|
|
```bash
|
|
npm run test:run
|
|
```
|
|
|
|
Expected: same passing set as before plus the new tests. The 6 pre-existing `collection-pool` failures remain (they're a separate issue).
|
|
|
|
- [ ] **Step 3: Manual smoke**
|
|
|
|
1. Open the Collection Pool page.
|
|
2. Find any row, click `Mark Ready`. The modal should open.
|
|
3. Try clicking `Save & Mark Ready` with an empty textarea — button is disabled.
|
|
4. Type a note, click save. The modal should close, the row should show a `NOTES` chip, and a toast should confirm.
|
|
5. On the same row, click `Mark Awaiting`. No modal, just a toast.
|
|
6. Repeat the `Mark Ready` path with a network error (e.g. pause PB) — the modal should stay open with the error visible. |