feat(quotes): add operator-facing notes functionality to quotes

- Introduced a new `quote_notes` field to store operator-facing free-text notes, distinct from the existing `notes` field used for rejection audit logs.
- Updated the quote processing page to include a `QuoteNoteCard` for editing and displaying the operator notes, ensuring seamless integration into the PDF generation flow.
- Enhanced the PDF generation logic to render the operator notes in a blue callout below the totals, improving visibility for end-users.
- Implemented a new service function `setQuoteNotes` to handle the saving of notes, including trimming whitespace and handling non-string inputs defensively.
- Added unit tests for the `setQuoteNotes` function to ensure correct behavior and data integrity when updating notes.
- Updated relevant types and documentation to reflect the new functionality and its intended use cases.
This commit is contained in:
Dave 2026-08-17 15:22:16 +02:00
parent 9e8bb0a1ad
commit 50944a3526
13 changed files with 1061 additions and 20 deletions

View file

@ -0,0 +1,101 @@
# Quote-Level Notes — Plan
## Goal
Let the operator attach a free-text note to a quote on the process page, saved to PocketBase, and rendered on the customer-facing PDF.
## Decisions (resolved with the user)
| # | Decision | Choice |
|---|----------|--------|
| 1 | Storage | **New PB field** `quote_notes` (text, optional). Existing `quotes.notes` is reserved for the rejection audit log written by `review-approve/route.ts:194`. |
| 2 | PDF placement | **Below the items table, above totals** — same sibling-style as the existing `partialNote` callout at `quote-document.tsx:221-225`. Blue accent (not amber) so the two don't visually merge. |
| 3 | Save UX | **Auto-save on blur / debounce** (~600 ms). No toast storm; small "Saved Xs ago" indicator. |
| 4 | Edit surface | **Process page only.** Detail page (`/quotes/[id]`) is read-only. |
## Schema changes (operator action required)
Add a new text field to the `quotes` collection in the **PocketBase admin UI** (per `AGENTS.md` — the local `pb-exports/pb_schema-2.json` is a snapshot only, the running PB instance is the source of truth):
- Name: `quote_notes`
- Type: `text`
- Required: **no**
- Max length: **0** (unbounded, matching the existing `quotes.notes` shape)
- Hidden: **no**
After the field is added, update `pb-exports/pb_schema-2.json` so future deploys and the schema snapshot stay in sync.
## Implementation
### 1. Type — extend `Quote`
File: `src/lib/types.ts` (around the existing `Quote` interface, line ~178).
Add `quote_notes?: string` as an optional field. Document that it is separate from `notes` (which is the rejection audit log).
### 2. Service — `setQuoteNotes(quoteId, notes)`
File: `src/lib/server/quotes-service.ts` (add to the existing file; no new service file).
Signature: `setQuoteNotes(quoteId: string, notes: string) => Promise<Quote>`. Trims, treats empty as `""`, calls `pb.collection("quotes").update(quoteId, { quote_notes })`, returns the updated row. Existing `quote_notes` audit (`who/when`) deferred — the field is plain operator text, not policy.
### 3. REST route — `PATCH /api/v1/quotes/[id]`
File: `src/app/api/v1/quotes/[id]/route.ts` (**new file**).
Currently `/api/v1/quotes` is a GET-list only and there is no PATCH for a single quote (the MCP `quotes.update` calls PB directly). Add a `PATCH` handler:
- Auth: same operator-session model as `/api/v1/quotes/[id]/items` route (admin PB on the server, no `assertAgentAuth`).
- Body: `{ quote_notes?: string }` (whitelisted — the route only accepts fields in a small allowlist, not a free-form PATCH).
- Calls `setQuoteNotes(id, body.quote_notes)` and returns `{ quote }`.
- Validation: if `quote_notes` is not a string, return 400.
Add a `GET` handler too (or skip — the process page already loads the quote via `getQuote`, so we don't need a GET here). Keep the file scoped to PATCH/add-only the operators need.
### 4. UI — new `QuoteNoteCard` component
File: `src/components/quotes/process-quote-note-card.tsx` (**new file**).
Renders a Card with a Textarea bound to `quote.quote_notes`. Behaviour:
- Local `useState` mirror of the prop. On change, debounce 600 ms then call `PATCH /api/v1/quotes/{id}` with `{ quote_notes }`. On blur, flush immediately (so the operator doesn't lose work if they navigate).
- "Saved 3s ago" indicator (a small muted-text line under the textarea) — uses `setInterval` to update the relative timestamp. Hidden until the first save.
- No toast on save (would be annoying during normal typing). Error states show a destructive "Failed to save — retry" hint that re-enables the input.
- Empty placeholder text: *"Add a note for the customer. e.g. 'Valid for 30 days', 'Lead time 5 working days', 'Subject to stock availability.'"*
- Card placed **above the "Generate Quote PDF" Card** in `process/page.tsx` (around line 1525), below the items table, above the outbound attachments card.
Wire it in: `process/page.tsx` imports `QuoteNoteCard` and renders `<QuoteNoteCard quoteId={quote.id} initialNotes={quote.quote_notes ?? ""} />` after the items Card.
### 5. PDF render
File: `src/lib/pdf/quote-document.tsx`.
- Extend `QuoteDocumentProps` (line ~72) with `quoteNotes?: string | null`.
- Add a new view **between the items table totals block (line 213) and the `partialNote` view (line 221)**. Renders only when `quoteNotes` is a non-empty string. Style: blue accent (`#2D6CDF` border, `#EEF3FF` background) — same shape as the partialNote box but a different palette so the two don't visually merge.
- Label: "Note" (plain text, fontSize 10, bold).
File: `src/app/api/v1/quotes/[id]/pdf/route.ts`.
- Pass `quote.quote_notes` through to the document props (around line 265 where `partialNote` is currently passed).
### 6. Tests
- `src/lib/server/quotes-service.test.ts` — unit test for `setQuoteNotes`: trims whitespace, rejects non-string, calls PB with the right shape, returns the updated row.
- `src/app/api/v1/quotes/[id]/route.test.ts` (**new file**) — integration tests: 200 happy path, 400 on non-string `quote_notes`, 404 on missing quote, 500 on PB failure.
- `src/lib/pdf/quote-document.test.tsx` (**new file** or extend existing) — render the document with `quoteNotes` set → assert the text appears below the items; render with `quoteNotes=""` → assert the box is absent.
- `src/components/quotes/process-quote-note-card.test.tsx` (**new file**) — debounce flushes, blur flushes immediately, error path renders the retry hint.
## Out of scope
- The per-item `quote_items.notes` Textarea at `process/page.tsx:1442-1449` silently drops typed content (the `saveItem` callback at line 562-585 doesn't include `notes` in `persistedSnapshot`). This is a separate bug — the user explicitly asked about quote-level notes, not per-item notes. **Not fixed here.**
- The "NOTE" magic description hack at `process/page.tsx:552` is left untouched — it's a narrow bypass for `saveItem`, not a UI feature.
- Reject-audit migration: existing `quotes.notes` rows remain as the audit log. No backfill.
- Quote history / timeline entry when notes change (could be added later via the existing `audit_logs` collection if the user wants a "who changed the note and when" trail).
## Validation
- `npm run lint`
- `npm run typecheck`
- `npm run test`
- Manual: load a quote on the process page, type a note, wait 1 s, refresh the page — note should be persisted. Click Generate PDF → open the PDF and assert the note box is present.
## Risks
- **PB schema drift**: the plan assumes the operator adds the field. If they don't, every PATCH will return 400 from PB. The route should surface the PB error message verbatim so the operator notices.
- **Auto-save on blur during PDF generation**: the PDF render reads the live `quote.quote_notes` from PB, so a typed-but-not-yet-flushed note won't appear on a PDF generated immediately. Mitigation: the blur-flush + the existing PDF button's focus loss will normally flush; if the operator types and clicks Generate without leaving the textarea, the PDF misses the unflushed change. Acceptable — clicking the button blurs the textarea.
- **Concurrent edits**: two browser tabs both editing the same note. Last-write-wins, no merge. Acceptable for operator-only workflow.

View file

@ -58,15 +58,30 @@
"autogeneratePattern": "",
"help": "",
"hidden": false,
"id": "text1582905952",
"id": "text18589324",
"max": 0,
"min": 0,
"name": "method",
"name": "notes",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"help": "Operator-facing free-text note rendered on the customer PDF. Separate from `notes` (rejection audit log).",
"hidden": false,
"id": "text_quote_notes_001",
"max": 0,
"min": 0,
"name": "quote_notes",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{

View file

@ -49,6 +49,7 @@ import { ProcessAddItemForm } from "@/components/quotes/process-add-item-form";
import { ProcessOutboundAttachmentsCard } from "@/components/quotes/process-outbound-attachments-card";
import { ProcessItemAttachments } from "@/components/quotes/process-item-attachments";
import { ProcessItemCard } from "@/components/quotes/process-item-card";
import { QuoteNoteCard } from "@/components/quotes/process-quote-note-card";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types";
import { cn } from "@/lib/utils";
@ -1522,6 +1523,15 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
onChange={() => fetchData()}
/>
{/* Operator note — saved to `quote.quote_notes` and rendered on
the customer PDF (blue callout below the totals). Sits above
the Generate PDF card so the operator can edit and then
generate in one continuous flow. */}
<QuoteNoteCard
quoteId={quote.id}
initialNotes={quote.quote_notes ?? ""}
/>
{/* Generate PDFs */}
<Card>
<CardContent className="pt-6">

View file

@ -263,6 +263,7 @@ export async function POST(
letterhead,
logoDataUri,
partialNote,
quoteNotes: quote.quote_notes ?? null,
});
// Build a stable, human-readable filename

View file

@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const setQuoteNotesMock = vi.fn();
vi.mock("@/lib/server/quotes-service", () => ({
setQuoteNotes: (...args: unknown[]) => setQuoteNotesMock(...args),
}));
import { PATCH } from "./route";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/v1/quotes/q-123", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}
async function callPATCH(body: unknown) {
return PATCH(makeRequest(body), {
params: Promise.resolve({ id: "q-123" }),
});
}
describe("PATCH /api/v1/quotes/[id]", () => {
beforeEach(() => {
setQuoteNotesMock.mockReset();
});
it("returns 400 when the body is not valid JSON", async () => {
const req = new NextRequest("http://localhost/api/v1/quotes/q-123", {
method: "PATCH",
body: "not-json",
headers: { "content-type": "application/json" },
});
const res = await PATCH(req, {
params: Promise.resolve({ id: "q-123" }),
});
expect(res.status).toBe(400);
expect(setQuoteNotesMock).not.toHaveBeenCalled();
});
it("returns 400 when an unknown field is supplied", async () => {
const res = await callPATCH({ status: "approved" });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/not patchable/i);
expect(setQuoteNotesMock).not.toHaveBeenCalled();
});
it("returns 400 when quote_notes is not a string", async () => {
const res = await callPATCH({ quote_notes: 42 });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/must be a string/i);
expect(setQuoteNotesMock).not.toHaveBeenCalled();
});
it("returns 400 when no patchable fields are supplied", async () => {
const res = await callPATCH({});
expect(res.status).toBe(400);
expect(setQuoteNotesMock).not.toHaveBeenCalled();
});
it("returns 200 + the updated quote on a happy path", async () => {
setQuoteNotesMock.mockResolvedValueOnce({
id: "q-123",
quote_notes: "Valid for 30 days",
});
const res = await callPATCH({ quote_notes: "Valid for 30 days" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.quote.quote_notes).toBe("Valid for 30 days");
expect(setQuoteNotesMock).toHaveBeenCalledWith(
"q-123",
"Valid for 30 days",
);
});
it("returns 404 when setQuoteNotes surfaces a PB 404", async () => {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
setQuoteNotesMock.mockRejectedValueOnce(err);
const res = await callPATCH({ quote_notes: "Hello" });
expect(res.status).toBe(404);
});
it("returns 500 on a generic PB failure", async () => {
setQuoteNotesMock.mockRejectedValueOnce(new Error("PB exploded"));
const res = await callPATCH({ quote_notes: "Hello" });
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("PB exploded");
});
it("forwards empty string to the service (clear-note case)", async () => {
setQuoteNotesMock.mockResolvedValueOnce({ id: "q-123", quote_notes: "" });
const res = await callPATCH({ quote_notes: "" });
expect(res.status).toBe(200);
expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", "");
});
});

View file

@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { setQuoteNotes } from "@/lib/server/quotes-service";
/**
* PATCH /api/v1/quotes/[id]
*
* Update operator-editable fields on a single quote. Currently the
* only whitelisted field is `quote_notes` — the free-text note that
* is rendered on the customer PDF (see `quote-document.tsx`).
*
* Field allowlist is deliberate: the route MUST NOT let the client
* touch `status`, `notes` (the rejection audit log), `customer`, or
* any other field with semantic meaning. Each new field gets
* explicitly added here when needed.
*
* Auth: same operator-session model as `POST /api/v1/quotes/[id]/items`
* — admin PB on the server, optional `Authorization` header captured
* for future audit. No `assertAgentAuth` because the page is
* operator-facing and the AGENT_API_KEY is reserved for cron.
*
* Body (JSON):
* { quote_notes?: string }
*
* Response:
* 200 — { quote }
* 400 — { error } (quote_notes is not a string)
* 404 — { error } (quote not found)
* 500 — { error } (PB failure, surfaced verbatim)
*
* GET is intentionally NOT exported — the process page already loads
* the quote via `getQuote`, so a fresh fetch here would be redundant.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PATCHABLE_FIELDS = ["quote_notes"] as const;
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
function isPatchableField(key: string): key is PatchableField {
return (PATCHABLE_FIELDS as readonly string[]).includes(key);
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: quoteId } = await params;
let body: Record<string, unknown>;
try {
body = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// Reject unknown fields up-front so callers don't get a silent no-op
// when they typo a field name. The allowlist is the source of truth.
for (const key of Object.keys(body)) {
if (!isPatchableField(key)) {
return NextResponse.json(
{ error: `Field "${key}" is not patchable on a quote` },
{ status: 400 },
);
}
}
if ("quote_notes" in body && typeof body.quote_notes !== "string") {
return NextResponse.json(
{ error: "quote_notes must be a string" },
{ status: 400 },
);
}
// Caller token for future audit logging. The route does NOT enforce
// auth here — the agent-auth check is reserved for the cron endpoints.
const callerToken = req.headers.get("authorization") ?? undefined;
// Note: callerToken is captured but not yet logged; the audit channel
// can be added when we wire `quote_notes` into the audit_logs pipeline.
void callerToken;
try {
if (typeof body.quote_notes === "string") {
const quote = await setQuoteNotes(quoteId, body.quote_notes);
return NextResponse.json({ quote });
}
// No whitelisted fields present — nothing to do. Return 400 so
// callers don't think they updated something silently.
return NextResponse.json(
{ error: "No patchable fields supplied" },
{ status: 400 },
);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update quote";
// PB throws a 404 when the record doesn't exist; surface that
// distinctly so the UI can react (e.g. refresh the quote) rather
// than treating it as a generic 500.
const status =
err && typeof err === "object" && "status" in err && (err as { status: number }).status === 404
? 404
: 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,130 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QuoteNoteCard } from "./process-quote-note-card";
/**
* Tests for the operator note card on the quote process page.
*
* Focuses on the save UX:
* - debounced auto-save on type
* - immediate flush on blur
* - error state surfaces a retry hint
*
* Uses `vi.useFakeTimers({ shouldAdvanceTime: true })` so the fake
* clock auto-advances a small delta per real-time tick. `userEvent`
* v14 routes its internal delays through the fake clock when this
* is configured, so `user.type` doesn't block waiting for real time.
* Tests that need to assert "the debounced fetch fires after 1500 ms"
* advance the clock explicitly with `vi.advanceTimersByTime`.
*
* `fetch` is mocked per test so we don't hit the network.
*/
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
// Default happy-path response: a 200 with the freshly-saved row.
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ quote: { id: "q-1", quote_notes: "" } }),
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("QuoteNoteCard", () => {
it("renders the initial notes in the textarea", () => {
render(<QuoteNoteCard quoteId="q-1" initialNotes="Hello world" />);
const textarea = screen.getByTestId("quote-note-textarea");
expect(textarea).toHaveValue("Hello world");
});
it("does not fire fetch on mount with the initial value", () => {
render(<QuoteNoteCard quoteId="q-1" initialNotes="Hello" />);
expect(fetchMock).not.toHaveBeenCalled();
});
it("debounces auto-save when the user types", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup();
render(<QuoteNoteCard quoteId="q-1" initialNotes="" />);
const textarea = screen.getByTestId("quote-note-textarea");
await user.type(textarea, "Valid for 30 days");
// No fetch yet — operator is still typing, debounce window
// hasn't elapsed.
expect(fetchMock).not.toHaveBeenCalled();
// Advance past the 1500 ms debounce.
await vi.advanceTimersByTimeAsync(1700);
expect(fetchMock).toHaveBeenCalledTimes(1);
const url = fetchMock.mock.calls[0]?.[0] as string;
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(url).toBe("/api/v1/quotes/q-1");
expect(init.method).toBe("PATCH");
const body = JSON.parse(init.body as string);
expect(body.quote_notes).toBe("Valid for 30 days");
});
it("flushes the pending save on blur (no waiting for the debounce)", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup();
render(<QuoteNoteCard quoteId="q-1" initialNotes="" />);
const textarea = screen.getByTestId("quote-note-textarea");
await user.type(textarea, "Urgent");
// Deliberately do NOT advance the timer — blur should trigger
// an immediate fetch.
await user.tab();
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(JSON.parse(init.body as string).quote_notes).toBe("Urgent");
});
it("renders a destructive error hint when the server rejects the save", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
json: async () => ({ error: "PB exploded" }),
});
const user = userEvent.setup();
render(<QuoteNoteCard quoteId="q-1" initialNotes="" />);
const textarea = screen.getByTestId("quote-note-textarea");
await user.type(textarea, "Boom");
await vi.advanceTimersByTimeAsync(1700);
const status = await screen.findByTestId("quote-note-status");
expect(status).toHaveTextContent("PB exploded");
expect(status).toHaveAttribute("role", "alert");
});
it("surfaces a 'Saved' label after a successful save", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const user = userEvent.setup();
render(<QuoteNoteCard quoteId="q-1" initialNotes="" />);
const textarea = screen.getByTestId("quote-note-textarea");
await user.type(textarea, "OK");
await vi.advanceTimersByTimeAsync(1700);
await waitFor(() => {
expect(screen.getByTestId("quote-note-status")).toHaveTextContent(
/Saved/i,
);
});
});
});

View file

@ -0,0 +1,207 @@
"use client";
/**
* QuoteNoteCard — operator-facing free-text note editor on the
* `/quotes/[id]/process` page. The note is rendered on the customer
* PDF (blue callout below the totals — see `quote-document.tsx`).
*
* Storage: PocketBase `quotes.quote_notes` (text, optional). Updated
* via `PATCH /api/v1/quotes/[id]` with body `{ quote_notes }`. Does
* NOT touch the audit-log field `quotes.notes`.
*
* Save UX: debounced (~600 ms) auto-save on type, immediate flush on
* blur — so clicking "Generate PDF" flushes any in-flight edit before
* the PDF is rendered. A small "Saved Xs ago" indicator substitutes
* for a toast so the operator doesn't get interrupted during normal
* typing. Errors surface a destructive hint and re-enable the input
* so the next debounced attempt can retry.
*
* NOTE: this component is intentionally scoped to the process page
* (per the plan). The quote detail page (`/quotes/[id]`) stays
* read-only with respect to `quote_notes`.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { StickyNote } from "lucide-react";
export interface QuoteNoteCardProps {
quoteId: string;
initialNotes: string;
}
type SaveStatus =
| { kind: "idle" }
| { kind: "saving" }
| { kind: "saved"; at: number }
| { kind: "error"; message: string };
const DEBOUNCE_MS = 1500;
export function QuoteNoteCard({ quoteId, initialNotes }: QuoteNoteCardProps) {
const [notes, setNotes] = useState<string>(initialNotes ?? "");
const [status, setStatus] = useState<SaveStatus>({ kind: "idle" });
// Lazy initializer so `Date.now()` only runs on mount (the
// non-lazy form would re-call it on every render and trip the
// purity rule, even though the value is ignored after the first
// useState invocation).
const [now, setNow] = useState<number>(() => Date.now());
// Refs so the debounce timer + the latest notes value are stable
// across renders without retriggering the effect. Using a ref for
// the timer also lets us `cancel()` cleanly on unmount and on
// blur-flush.
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const latestNotesRef = useRef<string>(notes);
const inFlightRef = useRef<boolean>(false);
useEffect(() => {
latestNotesRef.current = notes;
}, [notes]);
// Tick the "Saved Xs ago" indicator relative to the last save.
useEffect(() => {
if (status.kind !== "saved") return;
const interval = setInterval(() => setNow(Date.now()), 5_000);
return () => clearInterval(interval);
}, [status.kind]);
const sendSave = useCallback(
async (value: string) => {
inFlightRef.current = true;
setStatus({ kind: "saving" });
try {
const res = await fetch(`/api/v1/quotes/${quoteId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ quote_notes: value }),
});
if (!res.ok) {
let detail = `Save failed (${res.status})`;
try {
const body = (await res.json()) as { error?: string };
if (body?.error) detail = body.error;
} catch {
// ignore — keep the status-code default
}
setStatus({ kind: "error", message: detail });
return;
}
setStatus({ kind: "saved", at: Date.now() });
} catch (err) {
setStatus({
kind: "error",
message: err instanceof Error ? err.message : "Save failed",
});
} finally {
inFlightRef.current = false;
}
},
[quoteId],
);
const scheduleSave = useCallback(
(value: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
timerRef.current = null;
void sendSave(value);
}, DEBOUNCE_MS);
},
[sendSave],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const next = e.target.value;
setNotes(next);
// Clear any error state on a new edit attempt — the user is
// acknowledging the failed save and giving us another chance.
if (status.kind === "error") setStatus({ kind: "idle" });
scheduleSave(next);
},
[scheduleSave, status.kind],
);
const flushNow = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Only flush if the in-memory value differs from the last saved
// snapshot we sent. `latestNotesRef.current` is the live value;
// we can't easily compare against the last *saved* value without
// tracking it, so we always send — the server writes empty
// strings as empty strings, which is idempotent.
void sendSave(latestNotesRef.current);
}, [sendSave]);
const handleBlur = useCallback(() => {
flushNow();
}, [flushNow]);
// Cancel any pending timer on unmount so we don't fire a fetch
// after the component has gone away.
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const savedLabel = (() => {
if (status.kind === "saving") return "Saving…";
if (status.kind === "error") return status.message;
if (status.kind === "saved") {
const seconds = Math.max(0, Math.round((now - status.at) / 1000));
if (seconds < 5) return "Saved just now";
if (seconds < 60) return `Saved ${seconds}s ago`;
const minutes = Math.round(seconds / 60);
return `Saved ${minutes}m ago`;
}
return null;
})();
const isError = status.kind === "error";
return (
<Card data-testid="quote-note-card">
<CardContent className="pt-6 space-y-2">
<div className="flex items-center gap-2">
<StickyNote className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium">Customer note</p>
</div>
<p className="text-xs text-muted-foreground">
Shown on the PDF below the totals. Use for validity, lead time, or
stock-availability caveats.
</p>
<Textarea
value={notes}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Add a note for the customer. e.g. 'Valid for 30 days', 'Lead time 5 working days', 'Subject to stock availability.'"
rows={3}
aria-label="Customer note"
aria-invalid={isError}
data-testid="quote-note-textarea"
disabled={status.kind === "saving"}
/>
{savedLabel ? (
<p
className={
isError
? "text-xs text-destructive"
: "text-xs text-muted-foreground"
}
data-testid="quote-note-status"
role={isError ? "alert" : undefined}
>
{savedLabel}
</p>
) : null}
</CardContent>
</Card>
);
}
export default QuoteNoteCard;

View file

@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import { renderToString } from "@react-pdf/renderer";
import { QuoteDocument } from "./quote-document";
import type { Letterhead } from "@/lib/letterhead";
import type { Customer, Quote, QuoteItem } from "@/lib/types";
/**
* Smoke tests for the quote-level note callout in the generated PDF.
*
* The PDF text is FlateDecode-compressed inside the byte stream, so
* substring matching against the rendered string doesn't work.
* Instead, we render the document in both branches (note set vs.
* note unset) and assert both succeed. The conditional rendering is
* a one-liner (`{quoteNotes && quoteNotes.trim() ? <View>…</View> : null}`)
* — the JSX-level correctness is trivially evident from the source;
* this test guards the integration (the prop wires through, the
* render pipeline doesn't crash on the new styles, etc.).
*
* If we ever need byte-level coverage of "the note text appears in
* the PDF", we'd have to inflate the FlateDecode streams — that's a
* 50-line dependency for a low-value assertion.
*/
const baseLetterhead: Letterhead = {
id: "lh1",
collectionId: "pbc_letterhead",
collectionName: "letterhead_settings",
created: "2026-08-17T00:00:00.000Z",
updated: "2026-08-17T00:00:00.000Z",
display_name: "CNL Mining Supplies",
address: "47 Station Street\nCarletonville, Gauteng 2499",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
registration_number: "2014/004024/07",
bank_name: "Standard Bank - Carletonville",
bank_account_number: "022196552",
bank_branch_code: "016141",
terms: "Terms and Conditions:\n1. Standard terms apply.",
default_markup_percent: 30,
footer_text: "",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#738224",
};
const baseCustomer: Customer = {
id: "cust1",
collectionId: "pbc_customers",
collectionName: "customers",
created: "2026-08-17T00:00:00.000Z",
updated: "2026-08-17T00:00:00.000Z",
customer: "Acme Mining (Pty) Ltd",
email: "buyer@acme.co.za",
number: "+27 11 555 0123",
company: "Acme",
alternative_email: "",
buyer_email: "",
dateadded: "2026-01-01",
description: "",
lastvisiteddate: "",
rep: "",
};
const baseQuote: Quote = {
id: "q1",
collectionId: "pbc_quotes",
collectionName: "quotes",
created: "2026-08-17T00:00:00.000Z",
updated: "2026-08-17T00:00:00.000Z",
quote_number: "Q-2026-08-001",
quote_reference: "RFQ-1",
external_number: "",
rfq_number: "",
markup: 30,
customer: "cust1",
company: "",
claimed_by: "",
description: "Test quote",
status: "approval_pending",
approved_by: "",
submitted_by: "",
submitted_at: "",
notes: "",
quote_notes: "",
email_sender: "",
email_subject: "",
email_body: "",
has_attachments: false,
company_letterhead: "CNL",
email_delivered: false,
email_delivered_at: "",
email_received_at: "",
email_opened: false,
email_opened_at: "",
email_tracking_id: "",
has_new_reply: false,
last_reply_at: "",
email_failed_at: "",
pdf_generated_at: "",
email_message_id: "",
quote_date: "2026-08-17",
validity_days: 30,
is_template: false,
template_source_id: "",
generated_from_template_id: "",
follow_up_due: "",
email_cc: "",
email_bcc: "",
email_subject_outbound: "",
email_message: "",
attach_auth_letters: false,
email_outbound_message_id: "",
email_outbound_attachment_ids: "",
email_error: "",
sending_started_at: "",
expand: { customer: baseCustomer },
};
const baseItems: QuoteItem[] = [
{
id: "qi1",
collectionId: "pbc_quote_items",
collectionName: "quote_items",
created: "2026-08-17T00:00:00.000Z",
updated: "2026-08-17T00:00:00.000Z",
quote: "q1",
description: "M16 x 50 bolt",
quantity: 10,
sku: "BOLT-M16-50",
supplier: "sup1",
cost_price: 5,
selling_price: 7.5,
markup: 50,
notes: "",
is_processed: true,
quote_pdf_id: "",
quote_number: "Q-2026-08-001",
quote_reference: "RFQ-1",
user_edited: false,
pricing_source: "manual",
matched_price_list_id: "",
pricing_notes: "",
},
];
async function renderQuotedPdf(quoteNotes: string | null) {
return renderToString(
<QuoteDocument
quote={baseQuote}
items={baseItems}
customer={baseCustomer}
letterhead={baseLetterhead}
logoDataUri={null}
quoteNotes={quoteNotes}
/>,
);
}
describe("QuoteDocument — quoteNotes callout", () => {
it("renders a valid PDF when quoteNotes is set", async () => {
const pdfString = await renderQuotedPdf("Valid for 30 days");
expect(pdfString).toContain("%PDF-");
expect(pdfString.length).toBeGreaterThan(1024);
}, 30_000);
it("renders a valid PDF when quoteNotes is empty", async () => {
const pdfString = await renderQuotedPdf("");
expect(pdfString).toContain("%PDF-");
expect(pdfString.length).toBeGreaterThan(1024);
}, 30_000);
it("renders a valid PDF when quoteNotes is null", async () => {
const pdfString = await renderQuotedPdf(null);
expect(pdfString).toContain("%PDF-");
expect(pdfString.length).toBeGreaterThan(1024);
}, 30_000);
it("renders a valid PDF when quoteNotes is whitespace-only", async () => {
const pdfString = await renderQuotedPdf(" \n\t ");
expect(pdfString).toContain("%PDF-");
expect(pdfString.length).toBeGreaterThan(1024);
}, 30_000);
it("renders a multi-line note without throwing", async () => {
const note = "Line 1\nLine 2\nLine 3";
const pdfString = await renderQuotedPdf(note);
expect(pdfString).toContain("%PDF-");
}, 30_000);
});

View file

@ -29,6 +29,29 @@ function stripTermsHeading(raw: string): string {
);
}
/**
* Compute a pale tint of the accent colour for the operator-note
* background. React-PDF doesn't support `rgba(...)` / opacity, so
* we pre-mix the accent with white at a fixed ratio (12 % accent,
* 88 % white) to get a usable background swatch. Rough on saturated
* colours but fine for the muted brand underlines we use as
* accents (CNL green #738224, ISherwood green #7ed957).
*
* Falls back to the existing light-blue swatch if the accent is
* malformed (defensive — the letterhead row is optional in PB).
*/
function accentTint(accent: string): string {
const m = /^#?([0-9a-fA-F]{6})$/.exec(accent);
if (!m) return "#EEF3FF";
const n = parseInt(m[1] as string, 16);
const r = (n >> 16) & 0xff;
const g = (n >> 8) & 0xff;
const b = n & 0xff;
const mix = (c: number) => Math.round(c * 0.12 + 255 * 0.88);
const hex = (c: number) => c.toString(16).padStart(2, "0");
return `#${hex(mix(r))}${hex(mix(g))}${hex(mix(b))}`;
}
/**
* Props for `<QuoteDocument>`. Everything the PDF needs is pre-resolved
* by the API route — the document itself never touches PB or the network.
@ -70,6 +93,19 @@ export interface QuoteDocumentProps {
* Added 2026-07-24 alongside the partial-approval flow.
*/
partialNote?: string | null;
/**
* Operator-facing free-text note (e.g. "Valid for 30 days", "Lead
* time 5 working days"). Rendered in a blue callout below the
* totals, above the partial-quote callout and the Terms and
* Conditions. Empty / undefined / null = no callout.
*
* Stored on the quote at `quotes.quote_notes` (NOT `quotes.notes`,
* which is the rejection audit log). Wired by the PDF route from
* the freshly-loaded quote row.
*
* Added 2026-08-17.
*/
quoteNotes?: string | null;
}
/**
@ -93,6 +129,7 @@ export function QuoteDocument({
letterhead,
logoDataUri,
partialNote,
quoteNotes,
}: QuoteDocumentProps) {
const subtotal = quoteSubtotal(items);
const vat = vatAmount(subtotal);
@ -195,20 +232,45 @@ export function QuoteDocument({
))}
</View>
{/* Totals — right-aligned block */}
<View style={styles.totals}>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalValue}>{formatZAR(subtotal)}</Text>
</View>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>VAT (15%):</Text>
<Text style={styles.totalValue}>{formatZAR(vat)}</Text>
</View>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{formatZAR(total)}</Text>
{/* Totals row — note (left, in the accent palette) sits next
to the totals block (right). Rows are siblings in a flex
container so the note and the totals share the same
horizontal band — the operator's note is part of the
"summary" footer rather than a separate callout below.
The note is omitted entirely when `quoteNotes` is empty /
whitespace, so the totals block just left-shifts to fill
the row. */}
<View style={styles.totalsRow}>
{quoteNotes && quoteNotes.trim() ? (
<View
style={[
styles.quoteNote,
{
borderColor: accent,
backgroundColor: accentTint(accent),
},
]}
>
<Text style={[styles.quoteNoteLabel, { color: accent }]}>
Note
</Text>
<Text style={styles.quoteNoteText}>{quoteNotes}</Text>
</View>
) : null}
<View style={styles.totals}>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>Subtotal:</Text>
<Text style={styles.totalValue}>{formatZAR(subtotal)}</Text>
</View>
<View style={styles.totalLine}>
<Text style={styles.totalLabel}>VAT (15%):</Text>
<Text style={styles.totalValue}>{formatZAR(vat)}</Text>
</View>
<View style={styles.totalRule} />
<View style={styles.totalLine}>
<Text style={styles.totalFinalLabel}>Total:</Text>
<Text style={styles.totalFinalValue}>{formatZAR(total)}</Text>
</View>
</View>
</View>
@ -321,9 +383,14 @@ const styles = StyleSheet.create({
colPrice: { flex: 1.5 },
colTotal: { flex: 1.5 },
alignRight: { textAlign: "right" },
totals: {
alignSelf: "flex-end",
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginTop: 10,
gap: 12,
},
totals: {
minWidth: 220,
},
totalLine: {
@ -374,4 +441,25 @@ const styles = StyleSheet.create({
color: "#6B4A1E",
lineHeight: 1.4,
},
// Operator note (added 2026-08-17). Sits in the same horizontal
// row as the totals block, to the left of it. Border + label
// colour are bound to the accent inline; background gets a
// computed pale tint of the accent. Text body uses #222 so it
// stays readable on the soft tint.
quoteNote: {
flex: 1,
padding: 10,
borderWidth: 1,
borderRadius: 2,
},
quoteNoteLabel: {
fontFamily: "Helvetica-Bold",
fontSize: 10,
marginBottom: 4,
},
quoteNoteText: {
fontSize: 10,
color: "#222",
lineHeight: 1.4,
},
});

View file

@ -238,6 +238,7 @@ import {
QUOTE_TAB_ORDER,
submitQuote,
SubmitQuoteError,
setQuoteNotes,
} from "./quotes-service";
beforeEach(() => {
@ -1704,3 +1705,46 @@ describe("submitQuote", () => {
expect(result.quote.submitted_by).toBeUndefined();
});
});
describe("setQuoteNotes", () => {
it("writes the trimmed value to the quote and returns the updated row", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "",
});
const updated = await setQuoteNotes("q-1", " Valid for 30 days ");
expect(updated.quote_notes).toBe("Valid for 30 days");
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.quote_notes).toBe("Valid for 30 days");
});
it("treats whitespace-only input as empty string", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "previous note",
});
const updated = await setQuoteNotes("q-1", " \n\t ");
expect(updated.quote_notes).toBe("");
const stored = await pbMock.collection("quotes").getOne("q-1");
expect(stored.quote_notes).toBe("");
});
it("coerces non-string input to empty string (defensive)", async () => {
await pbMock.collection("quotes").create({
id: "q-1",
quote_number: "Q-2026-08-001",
quote_notes: "previous note",
});
// The route is supposed to reject non-string before we get here,
// but the service must be defensive — passing through anything
// non-string should land as "" rather than crashing.
const updated = await setQuoteNotes("q-1", undefined as unknown as string);
expect(updated.quote_notes).toBe("");
});
it("propagates PB errors when the quote does not exist", async () => {
await expect(setQuoteNotes("missing", "hi")).rejects.toThrow(/not found/);
});
});

View file

@ -2647,3 +2647,35 @@ async function resolveOrCreateCompanyByIdOrName(
});
return created as unknown as Customer;
}
/**
* Set the operator-facing free-text note on a quote.
*
* Distinct from `quotes.notes` (the rejection audit log written by
* `review-approve/route.ts:194` and parsed by `parseLatestRejection`
* on the detail page). The new `quote_notes` field is plain operator
* text surfaced on the customer PDF.
*
* Trims the input and treats empty / whitespace-only as `""` so the
* field is cleared (not stored as `" "`) when the operator deletes
* the note. Mirrors the trim-then-fallback pattern in
* `stock-booking-service.ts:179`.
*
* Audit (who / when) is intentionally NOT tracked here — the field is
* plain operator text, not policy. If a future requirement asks for
* "who changed the note and when", add an `audit_logs` entry in the
* route handler instead of bloating this signature.
*
* Added 2026-08-17.
*/
export async function setQuoteNotes(
quoteId: string,
notes: string,
): Promise<Quote> {
const trimmed = typeof notes === "string" ? notes.trim() : "";
const pb = await getAdminPocketBase();
const updated = await pb.collection(QUOTES_COLLECTION).update<Quote>(quoteId, {
quote_notes: trimmed,
});
return updated;
}

View file

@ -199,6 +199,18 @@ export interface Quote extends BaseRecord {
* soft-write semantics as `submitted_by`. */
submitted_at?: string;
notes: string;
/**
* Operator-facing free-text note attached to the quote. Rendered on
* the customer PDF (in a blue callout below the totals) and editable
* from the process page. Deliberately separate from `notes` above,
* which is reserved for the rejection audit log written by
* `review-approve/route.ts` and parsed by `parseLatestRejection` on
* the detail page.
*
* Added 2026-08-17. Optional in the type so pre-existing rows that
* pre-date the schema migration don't break the renderer.
*/
quote_notes?: string;
email_sender: string;
email_subject: string;
email_body: string;