diff --git a/package.json b/package.json index 1310e5b..a48787a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts", "pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts", "pb:migrate:quote-email-to": "npx tsx scripts/migrations/2026-10-30-add-quote-email-to.ts", + "pb:migrate:buyer-task-note": "npx tsx scripts/migrations/2026-11-11-add-buyer-task-note.ts", "pb:migrate:company-letterhead": "npx tsx scripts/migrations/2026-10-31-add-company-letterhead.ts", "pb:migrate:collection-auto-push": "npx tsx scripts/migrations/2026-10-28-add-collection-auto-push.ts", "pb:migrate:order-assistant": "npx tsx scripts/migrations/2026-10-24-add-order-assistant-fields.ts", diff --git a/scripts/migrations/2026-11-11-add-buyer-task-note.ts b/scripts/migrations/2026-11-11-add-buyer-task-note.ts new file mode 100644 index 0000000..7028896 --- /dev/null +++ b/scripts/migrations/2026-11-11-add-buyer-task-note.ts @@ -0,0 +1,34 @@ +import "./_env-loader"; +import PocketBase from "pocketbase"; + +async function main() { + const url = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090"; + const email = process.env.PB_ADMIN_EMAIL; + const password = process.env.PB_ADMIN_PASSWORD; + if (!email || !password) throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD."); + + const pb = new PocketBase(url); + await pb.collection("_superusers").authWithPassword(email, password); + for (const name of ["purchase_orders", "stock_purchases"]) { + const collection = await pb.collections.getOne(name); + const existing = collection.fields.find((field: { name: string }) => field.name === "buyer_task_note"); + if (existing && existing.type !== "json") { + throw new Error(`${name}.buyer_task_note already exists with a different type.`); + } + if (!existing) { + await pb.collections.update(collection.id, { + fields: [...collection.fields, { name: "buyer_task_note", type: "json", maxSize: 16384 }], + }); + } + const verified = await pb.collections.getOne(name); + if (!verified.fields.some((field: { name: string; type: string }) => field.name === "buyer_task_note" && field.type === "json")) { + throw new Error(`Could not verify ${name}.buyer_task_note.`); + } + console.log(`Verified ${name}.buyer_task_note`); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : "PO note migration failed."); + process.exitCode = 1; +}); diff --git a/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts new file mode 100644 index 0000000..4923ebd --- /dev/null +++ b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts @@ -0,0 +1,52 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { readBuyerTaskNote, saveBuyerTaskNote } from "@/lib/server/buyer-task-note-service"; +import { GET, PUT } from "./route"; + +vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() })); +vi.mock("@/lib/server/buyer-task-note-service", () => ({ + BUYER_TASK_NOTE_MAX_LENGTH: 2000, + readBuyerTaskNote: vi.fn(), + saveBuyerTaskNote: vi.fn(), +})); + +const url = "http://localhost/api/v1/buyer-tasks/notes/customer/po-1"; +const ctx = { params: Promise.resolve({ source: "customer", id: "po-1" }) }; +const note = { + text: "Collect Friday", + editor: "Sarah", + savedAt: "2026-11-11T12:35:00Z", + revision: "rev-1", +}; + +describe("PO note route", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("requires a browser session before reading a note", async () => { + vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error(), { status: 401 })); + const response = await GET(new NextRequest(url), ctx); + expect(response.status).toBe(401); + expect(readBuyerTaskNote).not.toHaveBeenCalled(); + }); + + it("attributes the saved note to the browser session and returns it on read", async () => { + vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "user-1", name: "Sarah", user_type: null }); + vi.mocked(saveBuyerTaskNote).mockResolvedValue({ note, conflict: false }); + vi.mocked(readBuyerTaskNote).mockResolvedValue(note); + + const response = await PUT(new NextRequest(url, { + method: "PUT", + body: JSON.stringify({ text: note.text, revision: null, editor: "Someone else" }), + }), ctx); + + expect(response.status).toBe(200); + expect(saveBuyerTaskNote).toHaveBeenCalledWith("customer", "po-1", note.text, null, "Sarah"); + + const read = await GET(new NextRequest(url), ctx); + expect(read.headers.get("Cache-Control")).toBe("no-store"); + expect(await read.json()).toEqual({ note }); + }); +}); diff --git a/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts new file mode 100644 index 0000000..f114138 --- /dev/null +++ b/src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { + BUYER_TASK_NOTE_MAX_LENGTH, + readBuyerTaskNote, + saveBuyerTaskNote, +} from "@/lib/server/buyer-task-note-service"; + +type Context = { params: Promise<{ source: string; id: string }> }; + +function noteError(error: unknown) { + const status = (error as { status?: number } | null)?.status; + if (status === 401) return NextResponse.json({ error: "Sign in to access PO notes." }, { status }); + if (status === 404) return NextResponse.json({ error: "PO not found." }, { status }); + if (status === 409) return NextResponse.json({ error: "This PO is no longer in PO Sent." }, { status }); + if (status === 413) return NextResponse.json({ error: "This PO scratchpad is full." }, { status }); + if (status === 503) return NextResponse.json({ error: "PO note storage is unavailable." }, { status }); + return NextResponse.json({ error: "Could not access the PO note." }, { status: 500 }); +} + +export async function GET(req: NextRequest, { params }: Context) { + try { + await getBrowserSessionUser(req); + const { source, id } = await params; + if (source !== "customer" && source !== "stock") { + return NextResponse.json({ error: "Invalid PO source." }, { status: 400 }); + } + const note = await readBuyerTaskNote(source, id); + return NextResponse.json({ note }, { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + return noteError(error); + } +} + +export async function PUT(req: NextRequest, { params }: Context) { + try { + const user = await getBrowserSessionUser(req); + const { source, id } = await params; + if (source !== "customer" && source !== "stock") { + return NextResponse.json({ error: "Invalid PO source." }, { status: 400 }); + } + + const body = await req.json().catch(() => null); + if ( + typeof body?.text !== "string" || + !body.text.trim() || + body.text.length > BUYER_TASK_NOTE_MAX_LENGTH || + (body.revision !== null && typeof body.revision !== "string") + ) { + return NextResponse.json({ error: "Invalid note or revision." }, { status: 400 }); + } + + const result = await saveBuyerTaskNote(source, id, body.text.trim(), body.revision, user.name || user.id); + return NextResponse.json(result, { status: result.conflict ? 409 : 200 }); + } catch (error) { + return noteError(error); + } +} diff --git a/src/components/buyer-tasks/buyer-task-group-card.test.tsx b/src/components/buyer-tasks/buyer-task-group-card.test.tsx index e1ed4fc..12803da 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.test.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.test.tsx @@ -52,6 +52,45 @@ describe("BuyerTaskGroupCard", () => { } }); + it("offers shared notes only on a sent PO card", () => { + const group = { ...baseGroup, purchaseOrderId: "po-1" }; + const { rerender } = render( + + ); + expect(screen.queryByRole("button", { name: "Notes" })).not.toBeInTheDocument(); + rerender(); + expect(screen.getByRole("button", { name: "Notes" })).toBeInTheDocument(); + }); + + it("shows the saved editor as a hashtag beside the note", async () => { + const fetchNote = vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json({ + note: { + text: "Supplier confirmed", editor: "Sarah", savedAt: "2026-11-11T12:40:00Z", revision: "rev-2", + previous: [{ text: "Collect Friday", editor: "David", savedAt: "2026-11-11T12:35:00Z", revision: "rev-1" }], + }, + }) + ); + try { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: "Notes" })); + + expect(await screen.findByText("#David")).toBeVisible(); + expect(screen.getByText("#Sarah")).toBeVisible(); + expect(screen.getByText("Collect Friday")).toBeVisible(); + expect(screen.getByText("Supplier confirmed")).toBeVisible(); + expect(screen.getByRole("textbox", { name: "New PO note" })).toHaveValue(""); + expect(screen.getByText(/14:35/)).toHaveAttribute("datetime", "2026-11-11T12:35:00Z"); + } finally { + fetchNote.mockRestore(); + } + }); + it("renders two buttons on the PO Sent tab", () => { const onResend = vi.fn(); render( diff --git a/src/components/buyer-tasks/buyer-task-group-card.tsx b/src/components/buyer-tasks/buyer-task-group-card.tsx index 9d72504..2ac6568 100644 --- a/src/components/buyer-tasks/buyer-task-group-card.tsx +++ b/src/components/buyer-tasks/buyer-task-group-card.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { BuyerTaskItemsList, type BuyerTaskItem } from "./buyer-task-items-list"; import type { BuyerTaskGroup } from "@/lib/server/buyer-tasks-service"; +import { BuyerTaskNote } from "./buyer-task-note"; import { supplierColor } from "@/lib/supplier-color"; import { withAlpha } from "@/lib/item-status-ui"; @@ -221,6 +222,13 @@ export function BuyerTaskGroupCard({ )} + {group.step === "po_sent" && group.purchaseOrderId && ( + + )} diff --git a/src/components/buyer-tasks/buyer-task-note.tsx b/src/components/buyer-tasks/buyer-task-note.tsx new file mode 100644 index 0000000..00a98f3 --- /dev/null +++ b/src/components/buyer-tasks/buyer-task-note.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useId, useState } from "react"; +import { StickyNote } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { getPocketBase } from "@/lib/pocketbase"; +import type { BuyerTaskNote as SavedNote } from "@/lib/server/buyer-task-note-service"; +import type { BuyerTaskGroupSource } from "@/lib/server/buyer-tasks-service"; + +const noteDate = new Intl.DateTimeFormat("en-ZA", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "Africa/Johannesburg", +}); + +export function BuyerTaskNote({ source, purchaseOrderId }: { + source: BuyerTaskGroupSource; + purchaseOrderId: string; +}) { + const fieldId = useId(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [note, setNote] = useState(); + const [draft, setDraft] = useState(""); + const [error, setError] = useState(""); + const [conflict, setConflict] = useState(false); + const endpoint = `/api/v1/buyer-tasks/notes/${source}/${encodeURIComponent(purchaseOrderId)}`; + + async function requestNote(method: "GET" | "PUT") { + setBusy(true); + setError(""); + try { + const headers = new Headers({ Authorization: `Bearer ${getPocketBase().authStore.token}` }); + const options: RequestInit = { method, headers }; + if (method === "PUT") { + headers.set("Content-Type", "application/json"); + options.body = JSON.stringify({ text: draft, revision: note?.revision ?? null }); + } + const response = await fetch(endpoint, options); + const data = await response.json(); + if (response.status === 409 && data.conflict) { + setNote(data.note); + setConflict(true); + return; + } + if (!response.ok) throw new Error(data.error || "Could not access the PO note."); + setNote(data.note); + if (method === "PUT") setDraft(""); + setConflict(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Could not access the PO note."); + } finally { + setBusy(false); + } + } + + return ( +
+ +