feat(buyer-tasks): add shared notes to sent POs
This commit is contained in:
parent
694c2a2292
commit
fc497fad96
10 changed files with 462 additions and 3 deletions
|
|
@ -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",
|
||||
|
|
|
|||
34
scripts/migrations/2026-11-11-add-buyer-task-note.ts
Normal file
34
scripts/migrations/2026-11-11-add-buyer-task-note.ts
Normal file
|
|
@ -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;
|
||||
});
|
||||
52
src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts
Normal file
52
src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.test.ts
Normal file
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
58
src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts
Normal file
58
src/app/api/v1/buyer-tasks/notes/[source]/[id]/route.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
<BuyerTaskGroupCard group={{ ...group, step: "po_draft" }} onAction={vi.fn()} />
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Notes" })).not.toBeInTheDocument();
|
||||
rerender(<BuyerTaskGroupCard group={{ ...group, step: "po_sent" }} onAction={vi.fn()} />);
|
||||
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(
|
||||
<BuyerTaskGroupCard
|
||||
group={{ ...baseGroup, step: "po_sent", purchaseOrderId: "po-1" }}
|
||||
onAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{group.step === "po_sent" && group.purchaseOrderId && (
|
||||
<BuyerTaskNote
|
||||
key={`${group.source}:${group.purchaseOrderId}`}
|
||||
source={group.source}
|
||||
purchaseOrderId={group.purchaseOrderId}
|
||||
/>
|
||||
)}
|
||||
<BuyerTaskItemsList items={group.items as BuyerTaskItem[]} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
123
src/components/buyer-tasks/buyer-task-note.tsx
Normal file
123
src/components/buyer-tasks/buyer-task-note.tsx
Normal file
|
|
@ -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<SavedNote | null | undefined>();
|
||||
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 (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${fieldId}-panel`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setOpen(!open);
|
||||
if (!open) {
|
||||
setNote(undefined);
|
||||
void requestNote("GET");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StickyNote aria-hidden="true" />
|
||||
Notes
|
||||
</Button>
|
||||
<div id={`${fieldId}-panel`} hidden={!open} className="mt-2 space-y-2">
|
||||
{note !== undefined && (
|
||||
<>
|
||||
{note && (
|
||||
<ul aria-label="Posted notes" className="divide-y rounded-md border">
|
||||
{[...(note.previous ?? []), note].filter((entry) => entry.text).map((entry) => (
|
||||
<li key={entry.revision} className="flex flex-col gap-2 px-3 py-2 sm:flex-row sm:gap-3">
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm">{entry.text}</p>
|
||||
<div className="min-w-0 text-xs sm:w-36 sm:shrink-0">
|
||||
<p className="break-words font-medium">#{entry.editor}</p>
|
||||
<time className="block text-muted-foreground" dateTime={entry.savedAt}>
|
||||
{noteDate.format(new Date(entry.savedAt))}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<label className="sr-only" htmlFor={fieldId}>New PO note</label>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
disabled={busy}
|
||||
/>
|
||||
{conflict && (
|
||||
<p role="alert" className="text-sm">
|
||||
Another note was posted. Your draft is still here. Post it again to add it below.
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" size="sm" disabled={busy || !draft.trim()} onClick={() => void requestNote("PUT")}>
|
||||
Post note
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{busy && <p role="status" className="text-xs text-muted-foreground">{note === undefined ? "Loading note…" : "Saving note…"}</p>}
|
||||
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
||||
{error && note === undefined && (
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => void requestNote("GET")}>Retry</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/lib/server/buyer-task-note-service.test.ts
Normal file
62
src/lib/server/buyer-task-note-service.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { saveBuyerTaskNote } from "./buyer-task-note-service";
|
||||
|
||||
const { collection, getOne, update } = vi.hoisted(() => ({
|
||||
collection: vi.fn(),
|
||||
getOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./pocketbase-admin", () => ({
|
||||
getAdminPocketBase: async () => ({ collection }),
|
||||
}));
|
||||
|
||||
describe("saveBuyerTaskNote", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
collection.mockReturnValue({ getOne, update });
|
||||
});
|
||||
|
||||
it("preserves the first save when two buyers save the same revision", async () => {
|
||||
let record = { status: "sent", buyer_task_note: null as unknown };
|
||||
getOne.mockImplementation(async () => ({ ...record }));
|
||||
update.mockImplementation(async (_id, patch) => {
|
||||
record = { ...record, ...patch };
|
||||
return record;
|
||||
});
|
||||
|
||||
const first = saveBuyerTaskNote("stock", "po-1", "Collect Friday", null, "Sarah");
|
||||
const second = saveBuyerTaskNote("stock", "po-1", "Phone supplier", null, "David");
|
||||
const results = await Promise.all([first, second]);
|
||||
|
||||
expect(results).toEqual([
|
||||
{ note: expect.objectContaining({ text: "Collect Friday", editor: "Sarah" }), conflict: false },
|
||||
{ note: expect.objectContaining({ text: "Collect Friday", editor: "Sarah" }), conflict: true },
|
||||
]);
|
||||
expect(collection).toHaveBeenCalledWith("stock_purchases");
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("appends a new entry and preserves the original editor and timestamp", async () => {
|
||||
const original = { text: "Collect Friday", editor: "David", savedAt: "2026-11-11T12:35:00Z", revision: "rev-1" };
|
||||
getOne.mockResolvedValue({ status: "sent", buyer_task_note: original });
|
||||
update.mockImplementation(async (_id, patch) => patch);
|
||||
|
||||
const result = await saveBuyerTaskNote("customer", "po-1", "Supplier confirmed", "rev-1", "Sarah");
|
||||
|
||||
expect(result.note).toMatchObject({
|
||||
text: "Supplier confirmed",
|
||||
editor: "Sarah",
|
||||
previous: [original],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports when PocketBase silently drops the note field", async () => {
|
||||
getOne.mockResolvedValue({ status: "sent" });
|
||||
update.mockResolvedValue({ status: "sent" });
|
||||
|
||||
await expect(saveBuyerTaskNote("customer", "po-1", "Call back", null, "Sarah"))
|
||||
.rejects.toMatchObject({ status: 503 });
|
||||
expect(collection).toHaveBeenCalledWith("purchase_orders");
|
||||
});
|
||||
});
|
||||
81
src/lib/server/buyer-task-note-service.ts
Normal file
81
src/lib/server/buyer-task-note-service.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||
import type { BuyerTaskGroupSource } from "./buyer-tasks-service";
|
||||
|
||||
export const BUYER_TASK_NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
type BuyerTaskNoteEntry = {
|
||||
text: string;
|
||||
editor: string;
|
||||
savedAt: string;
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type BuyerTaskNote = BuyerTaskNoteEntry & { previous?: BuyerTaskNoteEntry[] };
|
||||
|
||||
const collections = { customer: "purchase_orders", stock: "stock_purchases" } as const;
|
||||
const pendingSaves = new Map<string, Promise<unknown>>();
|
||||
|
||||
async function loadNote(source: BuyerTaskGroupSource, id: string) {
|
||||
const pb = await getAdminPocketBase();
|
||||
const collection = pb.collection(collections[source]);
|
||||
const record = await collection.getOne(id, { fields: "id,status,buyer_task_note" });
|
||||
if (record.status !== "sent" && record.status !== "sending") {
|
||||
throw Object.assign(new Error("This PO is no longer in PO Sent."), { status: 409 });
|
||||
}
|
||||
|
||||
return {
|
||||
collection,
|
||||
note: (record.buyer_task_note as BuyerTaskNote | null | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readBuyerTaskNote(source: BuyerTaskGroupSource, id: string) {
|
||||
return (await loadNote(source, id)).note;
|
||||
}
|
||||
|
||||
export async function saveBuyerTaskNote(
|
||||
source: BuyerTaskGroupSource,
|
||||
id: string,
|
||||
text: string,
|
||||
expectedRevision: string | null,
|
||||
editor: string,
|
||||
) {
|
||||
const key = `${source}:${id}`;
|
||||
// Serialize the revision check and save per PO within the app server.
|
||||
const previous = pendingSaves.get(key) ?? Promise.resolve();
|
||||
const save = previous.catch(() => {}).then(async () => {
|
||||
const { collection, note } = await loadNote(source, id);
|
||||
if ((note?.revision ?? null) !== expectedRevision) {
|
||||
return { note, conflict: true };
|
||||
}
|
||||
|
||||
const entries = [...(note?.previous ?? [])];
|
||||
if (note?.text) {
|
||||
const { text, editor, savedAt, revision } = note;
|
||||
entries.push({ text, editor, savedAt, revision });
|
||||
}
|
||||
const next: BuyerTaskNote = {
|
||||
text,
|
||||
editor,
|
||||
savedAt: new Date().toISOString(),
|
||||
revision: randomUUID(),
|
||||
...(entries.length ? { previous: entries } : {}),
|
||||
};
|
||||
if (Buffer.byteLength(JSON.stringify(next), "utf8") > 16_384) {
|
||||
throw Object.assign(new Error("This PO scratchpad is full."), { status: 413 });
|
||||
}
|
||||
const saved = await collection.update(id, { buyer_task_note: next });
|
||||
if (saved.buyer_task_note?.revision !== next.revision) {
|
||||
throw Object.assign(new Error("PO note storage is unavailable."), { status: 503 });
|
||||
}
|
||||
|
||||
return { note: next, conflict: false };
|
||||
});
|
||||
pendingSaves.set(key, save);
|
||||
try {
|
||||
return await save;
|
||||
} finally {
|
||||
if (pendingSaves.get(key) === save) pendingSaves.delete(key);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,8 +18,7 @@ export type BuyerTaskTab = "needs_po" | "po_draft" | "po_sent";
|
|||
export type BuyerTaskGroupSource = "customer" | "stock";
|
||||
|
||||
export type BuyerTaskGroup = {
|
||||
/** Encoded as `${source}::${headerId}::${supplierId}` so a stock
|
||||
* group and a customer group for the same supplier are distinct. */
|
||||
/** Source, header and supplier identify the card. Sent customer cards also include the PO ID. */
|
||||
groupId: string;
|
||||
/**
|
||||
* Identifier of the parent record (customer order for
|
||||
|
|
@ -202,7 +201,9 @@ export async function listBuyerTasks(
|
|||
if (tab === "po_draft" && po?.status !== "draft" && po?.status !== "mail_failed") continue;
|
||||
if (tab === "po_sent" && !["sent", "sending"].includes(po?.status ?? "")) continue;
|
||||
|
||||
const groupId = `customer::${orderId}::${supplierId}`;
|
||||
const groupId = tab === "po_sent"
|
||||
? `customer::${orderId}::${supplierId}::${po?.id}`
|
||||
: `customer::${orderId}::${supplierId}`;
|
||||
let group = groups.get(groupId);
|
||||
if (!group) {
|
||||
const order = item.expand?.order as { order_number?: string } | undefined;
|
||||
|
|
|
|||
Loading…
Reference in a new issue