From 50944a3526976915c08a3e2609a9f3d926826b44 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 17 Aug 2026 15:22:16 +0200 Subject: [PATCH] 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. --- .kilo/plans/1786967771107-quote-notes-plan.md | 101 +++++++++ pb-exports/pb_schema-2.json | 23 +- src/app/(app)/quotes/[id]/process/page.tsx | 10 + src/app/api/v1/quotes/[id]/pdf/route.ts | 1 + src/app/api/v1/quotes/[id]/route.test.ts | 103 +++++++++ src/app/api/v1/quotes/[id]/route.ts | 107 +++++++++ .../quotes/process-quote-note-card.test.tsx | 130 +++++++++++ .../quotes/process-quote-note-card.tsx | 207 ++++++++++++++++++ src/lib/pdf/quote-document.test.tsx | 191 ++++++++++++++++ src/lib/pdf/quote-document.tsx | 120 ++++++++-- src/lib/server/quotes-service.test.ts | 44 ++++ src/lib/server/quotes-service.ts | 32 +++ src/lib/types.ts | 12 + 13 files changed, 1061 insertions(+), 20 deletions(-) create mode 100644 .kilo/plans/1786967771107-quote-notes-plan.md create mode 100644 src/app/api/v1/quotes/[id]/route.test.ts create mode 100644 src/app/api/v1/quotes/[id]/route.ts create mode 100644 src/components/quotes/process-quote-note-card.test.tsx create mode 100644 src/components/quotes/process-quote-note-card.tsx create mode 100644 src/lib/pdf/quote-document.test.tsx diff --git a/.kilo/plans/1786967771107-quote-notes-plan.md b/.kilo/plans/1786967771107-quote-notes-plan.md new file mode 100644 index 0000000..0229342 --- /dev/null +++ b/.kilo/plans/1786967771107-quote-notes-plan.md @@ -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`. 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 `` 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. diff --git a/pb-exports/pb_schema-2.json b/pb-exports/pb_schema-2.json index 2e2b306..cba2e53 100644 --- a/pb-exports/pb_schema-2.json +++ b/pb-exports/pb_schema-2.json @@ -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" }, { diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index afdde84..4adabda 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -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. */} + + {/* Generate PDFs */} diff --git a/src/app/api/v1/quotes/[id]/pdf/route.ts b/src/app/api/v1/quotes/[id]/pdf/route.ts index b1a3ca0..09aad6b 100644 --- a/src/app/api/v1/quotes/[id]/pdf/route.ts +++ b/src/app/api/v1/quotes/[id]/pdf/route.ts @@ -263,6 +263,7 @@ export async function POST( letterhead, logoDataUri, partialNote, + quoteNotes: quote.quote_notes ?? null, }); // Build a stable, human-readable filename diff --git a/src/app/api/v1/quotes/[id]/route.test.ts b/src/app/api/v1/quotes/[id]/route.test.ts new file mode 100644 index 0000000..57b5d0e --- /dev/null +++ b/src/app/api/v1/quotes/[id]/route.test.ts @@ -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", ""); + }); +}); diff --git a/src/app/api/v1/quotes/[id]/route.ts b/src/app/api/v1/quotes/[id]/route.ts new file mode 100644 index 0000000..f26d721 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/route.ts @@ -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; + try { + body = (await req.json()) as Record; + } 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 }); + } +} diff --git a/src/components/quotes/process-quote-note-card.test.tsx b/src/components/quotes/process-quote-note-card.test.tsx new file mode 100644 index 0000000..f07ee67 --- /dev/null +++ b/src/components/quotes/process-quote-note-card.test.tsx @@ -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(); + const textarea = screen.getByTestId("quote-note-textarea"); + expect(textarea).toHaveValue("Hello world"); + }); + + it("does not fire fetch on mount with the initial value", () => { + render(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("debounces auto-save when the user types", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup(); + render(); + + 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(); + + 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(); + + 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(); + + 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, + ); + }); + }); +}); diff --git a/src/components/quotes/process-quote-note-card.tsx b/src/components/quotes/process-quote-note-card.tsx new file mode 100644 index 0000000..a8282cd --- /dev/null +++ b/src/components/quotes/process-quote-note-card.tsx @@ -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(initialNotes ?? ""); + const [status, setStatus] = useState({ 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(() => 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 | null>(null); + const latestNotesRef = useRef(notes); + const inFlightRef = useRef(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) => { + 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 ( + + +
+ +

Customer note

+
+

+ Shown on the PDF below the totals. Use for validity, lead time, or + stock-availability caveats. +

+