Add collection pool mark-ready notes implementation plan
This commit is contained in:
parent
dbbb3e2b2c
commit
4e0d45198b
1 changed files with 742 additions and 0 deletions
|
|
@ -0,0 +1,742 @@
|
|||
# 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.
|
||||
Loading…
Reference in a new issue