feat(quote-assistant): ai colour token, marker, fill button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:45:44 +02:00
parent d34be52a66
commit ef6193367d
5 changed files with 471 additions and 0 deletions

View file

@ -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;

View file

@ -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> = {}): 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(
<AiFillMarker item={{ id: "i1", ai_fill: null, pricing_source: "manual" }} />,
);
expect(container).toBeEmptyDOMElement();
});
it("renders a purple button for a pending fill", () => {
render(<AiFillMarker item={{ id: "i1", ai_fill: fill(), pricing_source: "ai_pos" }} />);
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(
<AiFillMarker
item={{
id: "i1",
ai_fill: fill({ reviewed_at: "2026-10-16 11:00:00.000Z" }),
pricing_source: "ai_pos",
}}
/>,
);
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(<AiFillMarker item={{ id: "i1", ai_fill: fill(), pricing_source: "ai_pos" }} />);
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(
<AiFillMarker
item={{
id: "i1",
ai_fill: fill({ price_from_default_markup: true }),
pricing_source: "ai_pos",
}}
/>,
);
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(
<AiFillMarker
item={{ id: "i1", ai_fill: fill({ status, stock_item_id: null }), pricing_source: "" }}
/>,
);
expect(screen.getByText("AI: no match")).toBeInTheDocument();
});
it("lists candidates in the no_match popover", async () => {
render(
<AiFillMarker
item={{
id: "i1",
ai_fill: fill({
status: "no_match",
stock_item_id: null,
reason: "None were the right size",
candidates: [
{ id: "c1", name: "M12 Bolt", sku: "BLT-M12", cost_price: 12, selling_price: 20 },
{ id: "c2", name: "M8 Bolt", sku: "BLT-M8", cost_price: 8, selling_price: 14 },
],
}),
pricing_source: "",
}}
/>,
);
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(<AiBoardBadge pending={6} total={10} />);
expect(screen.getByText("AI 6/10")).toBeInTheDocument();
});
it("renders nothing when nothing is pending", () => {
const { container } = render(<AiBoardBadge pending={0} total={10} />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -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 (
<div className="flex items-baseline justify-between gap-3 text-xs">
<span className="text-muted-foreground">{label}</span>
<span className="text-right font-medium tabular-nums">{children}</span>
</div>
);
}
function FilledNotes({ fill }: { fill: QuoteItemAiFill }) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-ai">
AI match from POS
</span>
{fill.stock_item_id ? (
<Link
href={`/stock/${fill.stock_item_id}`}
className="text-sm font-medium text-primary underline-offset-2 hover:underline"
>
{fill.stock_item_name}
</Link>
) : (
<span className="text-sm font-medium">{fill.stock_item_name}</span>
)}
{fill.stock_item_sku ? (
<span className="font-mono text-xs text-muted-foreground">{fill.stock_item_sku}</span>
) : null}
</div>
<div className="flex flex-col gap-1">
<Row label="Cost">{zar.format(Number(fill.cost_price) || 0)}</Row>
<Row label="Selling price">{zar.format(Number(fill.selling_price) || 0)}</Row>
<Row label="Markup">{formatMarkup(fill.markup)}</Row>
{fill.confidence ? (
<Row label="Confidence">
<span className="capitalize">{fill.confidence}</span>
</Row>
) : null}
</div>
{fill.price_from_default_markup ? (
<p className="text-xs text-warn">Selling price from default markup</p>
) : null}
{fill.reason ? <p className="text-xs">{fill.reason}</p> : null}
{fill.doubts.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">Doubts</span>
<ul className="list-disc pl-4 text-xs text-muted-foreground">
{fill.doubts.map((d, i) => (
<li key={`${i}-${d}`}>{d}</li>
))}
</ul>
</div>
) : null}
</div>
);
}
function NoMatchNotes({ fill }: { fill: QuoteItemAiFill }) {
return (
<div className="flex flex-col gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
AI found no match
</span>
{fill.reason ? <p className="text-xs">{fill.reason}</p> : null}
{fill.candidates.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">POS items considered</span>
<ul className="flex flex-col gap-1">
{fill.candidates.map((c) => (
<li key={c.id} className="flex items-baseline justify-between gap-2 text-xs">
<Link
href={`/stock/${c.id}`}
className="min-w-0 truncate text-primary underline-offset-2 hover:underline"
>
{c.name}
</Link>
<span className="shrink-0 font-mono text-muted-foreground">{c.sku}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
export function AiFillMarker({
item,
className,
}: {
item: Pick<QuoteItem, "id" | "ai_fill" | "pricing_source">;
className?: string;
}) {
const fill = item.ai_fill;
if (!fill) return null;
if (fill.status === "no_match" || fill.status === "no_candidates") {
return (
<Popover>
<PopoverTrigger
render={
<Badge
variant="secondary"
className={cn("cursor-pointer text-muted-foreground", className)}
/>
}
>
AI: no match
</PopoverTrigger>
<PopoverContent align="start">
<NoMatchNotes fill={fill} />
</PopoverContent>
</Popover>
);
}
if (fill.status !== "filled") return null;
const pending = isAiFillPending(item);
return (
<Popover>
<PopoverTrigger
aria-label={
pending ? "AI filled this line: view notes" : "Filled by AI (checked): view notes"
}
className={cn(
"inline-flex size-6 shrink-0 items-center justify-center rounded-full outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/40",
pending
? "border border-ai/30 bg-ai/10 text-ai hover:bg-ai/20"
: "border border-transparent bg-muted text-muted-foreground hover:bg-accent",
className,
)}
>
<Bot className="size-3.5" aria-hidden />
</PopoverTrigger>
<PopoverContent align="start">
<FilledNotes fill={fill} />
</PopoverContent>
</Popover>
);
}
/** "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 (
<Badge variant="outline" className="border border-ai/30 bg-ai/10 text-ai">
<Bot aria-hidden />
{`AI ${pending}/${total}`}
</Badge>
);
}

View file

@ -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<typeof vi.fn>;
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(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
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(<AiFillQuoteButton quoteId="q1" onDone={onDone} />);
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<Response>((r) => (resolve = r)));
render(<AiFillQuoteButton quoteId="q1" onDone={() => {}} />);
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());
});
});

View file

@ -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 (
<Button
type="button"
variant="outline"
size={size}
disabled={running}
onClick={run}
className="border-ai/30 text-ai hover:bg-ai/10 hover:text-ai"
>
{running ? <Loader2 className="animate-spin" aria-hidden /> : <Bot aria-hidden />}
{running ? "AI is filling…" : "Let AI fill this quote"}
</Button>
);
}