feat(quote-assistant): ai colour token, marker, fill button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d34be52a66
commit
ef6193367d
5 changed files with 471 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
132
src/components/ai-fill-marker.test.tsx
Normal file
132
src/components/ai-fill-marker.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
175
src/components/ai-fill-marker.tsx
Normal file
175
src/components/ai-fill-marker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
src/components/ai-fill-quote-button.test.tsx
Normal file
71
src/components/ai-fill-quote-button.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
79
src/components/ai-fill-quote-button.tsx
Normal file
79
src/components/ai-fill-quote-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue