From ef6193367d55f8b2c53a7482dd86f49c7f30fca7 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 19:45:44 +0200 Subject: [PATCH] feat(quote-assistant): ai colour token, marker, fill button Co-Authored-By: Claude Opus 5.5 --- src/app/globals.css | 14 ++ src/components/ai-fill-marker.test.tsx | 132 ++++++++++++++ src/components/ai-fill-marker.tsx | 175 +++++++++++++++++++ src/components/ai-fill-quote-button.test.tsx | 71 ++++++++ src/components/ai-fill-quote-button.tsx | 79 +++++++++ 5 files changed, 471 insertions(+) create mode 100644 src/components/ai-fill-marker.test.tsx create mode 100644 src/components/ai-fill-marker.tsx create mode 100644 src/components/ai-fill-quote-button.test.tsx create mode 100644 src/components/ai-fill-quote-button.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 86efb07..5989db4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -35,6 +35,8 @@ --color-warn-foreground: var(--warn-foreground); --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-ai: var(--ai); + --color-ai-foreground: var(--ai-foreground); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); @@ -56,6 +58,18 @@ --radius-4xl: calc(var(--radius) * 2.6); } +/* AI quote assistant purple. No theme in themes.css defines --ai, so every + * theme inherits these from :root; the .dark block must stay after :root. */ +:root { + --ai: oklch(0.55 0.20 300); + --ai-foreground: oklch(0.98 0 0); +} + +.dark { + --ai: oklch(0.72 0.17 300); + --ai-foreground: oklch(0.15 0 0); +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/src/components/ai-fill-marker.test.tsx b/src/components/ai-fill-marker.test.tsx new file mode 100644 index 0000000..130a806 --- /dev/null +++ b/src/components/ai-fill-marker.test.tsx @@ -0,0 +1,132 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { QuoteItemAiFill } from "@/lib/types"; +import { AiBoardBadge, AiFillMarker } from "./ai-fill-marker"; + +function fill(over: Partial = {}): QuoteItemAiFill { + return { + status: "filled", + stock_item_id: "stk1", + stock_item_name: "M10 Bolt Zinc", + stock_item_sku: "BLT-M10", + cost_price: 10, + selling_price: 18, + markup: 80, + supplier_id: "sup1", + confidence: "high", + reason: "Same size and finish", + doubts: ["Length not stated", "Could be stainless"], + candidates: [], + description_snapshot: "M10 bolt", + model: "m", + attempted_at: "2026-10-16 10:00:00.000Z", + ...over, + }; +} + +afterEach(cleanup); + +describe("AiFillMarker", () => { + it("renders nothing without ai_fill", () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders a purple button for a pending fill", () => { + render(); + const btn = screen.getByRole("button", { name: "AI filled this line: view notes" }); + expect(btn.className).toContain("text-ai"); + }); + + it("renders a muted button once reviewed", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "Filled by AI (checked): view notes" }); + expect(btn.className).not.toContain("text-ai"); + }); + + it("shows the full notes in the popover", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "AI filled this line: view notes" })); + expect(await screen.findByText("BLT-M10")).toBeInTheDocument(); + const link = screen.getByRole("link", { name: /M10 Bolt Zinc/ }); + expect(link).toHaveAttribute("href", "/stock/stk1"); + expect(screen.getByText(/R\s?10,00/)).toBeInTheDocument(); + expect(screen.getByText(/R\s?18,00/)).toBeInTheDocument(); + expect(screen.getByText("80%")).toBeInTheDocument(); + expect(screen.getByText(/high/i)).toBeInTheDocument(); + expect(screen.getByText("Same size and finish")).toBeInTheDocument(); + expect(screen.getByText("Length not stated")).toBeInTheDocument(); + expect(screen.getByText("Could be stainless")).toBeInTheDocument(); + expect(screen.queryByText("Selling price from default markup")).not.toBeInTheDocument(); + }); + + it("flags a selling price taken from the default markup", async () => { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "AI filled this line: view notes" })); + expect(await screen.findByText("Selling price from default markup")).toBeInTheDocument(); + }); + + it.each(["no_match", "no_candidates"] as const)("shows a muted badge for %s", (status) => { + render( + , + ); + expect(screen.getByText("AI: no match")).toBeInTheDocument(); + }); + + it("lists candidates in the no_match popover", async () => { + render( + , + ); + fireEvent.click(screen.getByText("AI: no match")); + expect(await screen.findByText("None were the right size")).toBeInTheDocument(); + expect(screen.getByText("M12 Bolt")).toBeInTheDocument(); + expect(screen.getByText("M8 Bolt")).toBeInTheDocument(); + }); +}); + +describe("AiBoardBadge", () => { + it("renders pending/total", () => { + render(); + expect(screen.getByText("AI 6/10")).toBeInTheDocument(); + }); + + it("renders nothing when nothing is pending", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/components/ai-fill-marker.tsx b/src/components/ai-fill-marker.tsx new file mode 100644 index 0000000..6916b9e --- /dev/null +++ b/src/components/ai-fill-marker.tsx @@ -0,0 +1,175 @@ +"use client"; + +import Link from "next/link"; +import { Bot } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { isAiFillPending } from "@/lib/quote-assistant-review"; +import type { QuoteItem, QuoteItemAiFill } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const zar = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }); + +function formatMarkup(markup: number): string { + const n = Number(markup) || 0; + return `${Number.isInteger(n) ? n : n.toFixed(1)}%`; +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function FilledNotes({ fill }: { fill: QuoteItemAiFill }) { + return ( +
+
+ + AI match from POS + + {fill.stock_item_id ? ( + + {fill.stock_item_name} + + ) : ( + {fill.stock_item_name} + )} + {fill.stock_item_sku ? ( + {fill.stock_item_sku} + ) : null} +
+ +
+ {zar.format(Number(fill.cost_price) || 0)} + {zar.format(Number(fill.selling_price) || 0)} + {formatMarkup(fill.markup)} + {fill.confidence ? ( + + {fill.confidence} + + ) : null} +
+ + {fill.price_from_default_markup ? ( +

Selling price from default markup

+ ) : null} + + {fill.reason ?

{fill.reason}

: null} + + {fill.doubts.length > 0 ? ( +
+ Doubts +
    + {fill.doubts.map((d, i) => ( +
  • {d}
  • + ))} +
+
+ ) : null} +
+ ); +} + +function NoMatchNotes({ fill }: { fill: QuoteItemAiFill }) { + return ( +
+ + AI found no match + + {fill.reason ?

{fill.reason}

: null} + {fill.candidates.length > 0 ? ( +
+ POS items considered +
    + {fill.candidates.map((c) => ( +
  • + + {c.name} + + {c.sku} +
  • + ))} +
+
+ ) : null} +
+ ); +} + +export function AiFillMarker({ + item, + className, +}: { + item: Pick; + className?: string; +}) { + const fill = item.ai_fill; + if (!fill) return null; + + if (fill.status === "no_match" || fill.status === "no_candidates") { + return ( + + + } + > + AI: no match + + + + + + ); + } + + if (fill.status !== "filled") return null; + + const pending = isAiFillPending(item); + return ( + + + + + + + + + ); +} + +/** "AI 6/10" chip for the Quotes board; hidden when nothing is pending. */ +export function AiBoardBadge({ pending, total }: { pending: number; total: number }) { + if (pending <= 0) return null; + return ( + + + {`AI ${pending}/${total}`} + + ); +} diff --git a/src/components/ai-fill-quote-button.test.tsx b/src/components/ai-fill-quote-button.test.tsx new file mode 100644 index 0000000..7a41c1f --- /dev/null +++ b/src/components/ai-fill-quote-button.test.tsx @@ -0,0 +1,71 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() })); +vi.mock("sonner", () => ({ toast })); +vi.mock("@/lib/pocketbase", () => ({ + default: () => ({ authStore: { token: "tok" } }), +})); + +import { AiFillQuoteButton } from "./ai-fill-quote-button"; + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + toast.success.mockReset(); + toast.error.mockReset(); + toast.info.mockReset(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const json = (status: number, body: unknown) => + Promise.resolve(new Response(JSON.stringify(body), { status })); + +describe("AiFillQuoteButton", () => { + it("POSTs the run route with the bearer token and reports the result", async () => { + fetchMock.mockReturnValue( + json(200, { quoteId: "q1", filled: 3, unmatched: 2, skipped: 0, error: null }), + ); + const onDone = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /let ai fill this quote/i })); + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/quotes/q1/assistant/run"); + expect(init.method).toBe("POST"); + expect(init.headers.Authorization).toBe("Bearer tok"); + expect(toast.success).toHaveBeenCalledWith("AI filled 3 lines · 2 without a match"); + }); + + it("toasts the error text on a 409", async () => { + fetchMock.mockReturnValue(json(409, { error: "AI is already working on this quote" })); + const onDone = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("AI is already working on this quote"), + ); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("is disabled while the request runs", async () => { + let resolve!: (r: Response) => void; + fetchMock.mockReturnValue(new Promise((r) => (resolve = r))); + render( {}} />); + const btn = screen.getByRole("button"); + fireEvent.click(btn); + await waitFor(() => expect(btn).toBeDisabled()); + fireEvent.click(btn); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolve(new Response(JSON.stringify({ filled: 0, unmatched: 0 }), { status: 200 })); + await waitFor(() => expect(btn).not.toBeDisabled()); + }); +}); diff --git a/src/components/ai-fill-quote-button.tsx b/src/components/ai-fill-quote-button.tsx new file mode 100644 index 0000000..d031f8b --- /dev/null +++ b/src/components/ai-fill-quote-button.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { Bot, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import getPocketBase from "@/lib/pocketbase"; + +interface RunResponse { + filled?: number; + unmatched?: number; + error?: string | null; +} + +function plural(n: number): string { + return `${n} line${n === 1 ? "" : "s"}`; +} + +export function AiFillQuoteButton({ + quoteId, + onDone, + size = "sm", +}: { + quoteId: string; + onDone: () => void; + size?: "sm" | "default"; +}) { + const [running, setRunning] = useState(false); + + async function run() { + if (running) return; + setRunning(true); + try { + const token = getPocketBase().authStore.token; + const res = await fetch(`/api/v1/quotes/${encodeURIComponent(quoteId)}/assistant/run`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + const body = (await res.json().catch(() => ({}))) as RunResponse; + if (!res.ok) { + toast.error(body.error || `AI fill failed (${res.status})`); + return; + } + const filled = body.filled ?? 0; + const unmatched = body.unmatched ?? 0; + if (body.error) { + toast.error(body.error); + } else if (filled === 0 && unmatched === 0) { + toast.info("AI had no open lines to fill"); + } else { + toast.success( + unmatched > 0 + ? `AI filled ${plural(filled)} · ${unmatched} without a match` + : `AI filled ${plural(filled)}`, + ); + } + onDone(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "AI fill failed"); + } finally { + setRunning(false); + } + } + + return ( + + ); +}