feat(quote-assistant): AI x/y badge on quotes board

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 20:01:43 +02:00
parent 40094be3f3
commit fa854060c4
3 changed files with 121 additions and 0 deletions

View file

@ -33,6 +33,8 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import getPocketBase from "@/lib/pocketbase";
import { useAuth } from "@/components/auth-provider";
import { AiBoardBadge } from "@/components/ai-fill-marker";
import { fetchAiCounts, type AiCounts } from "@/lib/quote-assistant-counts-client";
import { isRepUser } from "@/lib/rep-visibility";
import {
getQuoteItems,
@ -177,6 +179,10 @@ export default function QuotesPage() {
const [itemCounts, setItemCounts] = useState<
Record<string, { total: number; processed: number }>
>({});
// Per-quote AI-filled lines awaiting rep review ("AI 2/5" badge).
// Best-effort: fetched alongside (never blocking) itemCounts, and a
// failure leaves it empty so the badge just stays hidden.
const [aiCounts, setAiCounts] = useState<AiCounts>({});
// Expand-on-row state. We keep:
// - expandedIds: which quote rows are currently open
@ -290,6 +296,9 @@ export default function QuotesPage() {
};
}
setItemCounts(counts);
void fetchAiCounts(matches.map((m) => m.quote.id)).then((c) => {
if (fetchAbortRef.current === ac) setAiCounts(c);
});
// Per-tab hit counts from the server. The page's tab badges
// can switch to "showing hits in: New (2), Completed (1)"
// instead of "New (134) · Completed (7,849)" while the
@ -344,6 +353,9 @@ export default function QuotesPage() {
// dashboard fetch above to keep the page-load budget
// at one extra round-trip.
const ids = body.items.map((q) => q.id);
void fetchAiCounts(ids).then((c) => {
if (fetchAbortRef.current === ac) setAiCounts(c);
});
const nextCounts =
ids.length > 0 ? await fetchQuoteItemCounts(ids) : {};
setItemCounts(nextCounts);
@ -661,6 +673,7 @@ export default function QuotesPage() {
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{item.quote_number}</span>
<ProcessedBadge counts={itemCounts[item.id]} />
{aiCounts[item.id] && <AiBoardBadge {...aiCounts[item.id]} />}
{isFollowUp(item) && <FollowUpChip due={item.follow_up_due} />}
{hasCustomerReply(item) && <RepliedChip repliedAt={item.last_reply_at} />}
{isAged(item) && <AgedBadge created={item.created} />}

View file

@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/pocketbase", () => ({
default: () => ({ authStore: { token: "tok-123" } }),
}));
import { fetchAiCounts } from "./quote-assistant-counts-client";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("fetchAiCounts", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns {} for no ids without calling fetch", async () => {
expect(await fetchAiCounts([])).toEqual({});
expect(fetchMock).not.toHaveBeenCalled();
});
it("calls the counts route with the ids and a Bearer token", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ a: { pending: 2, total: 5 } }));
const out = await fetchAiCounts(["a", "b"]);
expect(out).toEqual({ a: { pending: 2, total: 5 } });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/v1/quotes/assistant/counts?ids=a%2Cb");
expect(init.headers.Authorization).toBe("Bearer tok-123");
});
it("splits more than 500 ids into chunks and merges the results", async () => {
const ids = Array.from({ length: 1001 }, (_, i) => `q${i}`);
fetchMock.mockImplementation(async (url: string) => {
const list = (new URL(url, "http://x").searchParams.get("ids") ?? "").split(",");
return jsonResponse({ [list[0]]: { pending: 1, total: list.length } });
});
const out = await fetchAiCounts(ids);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(out).toEqual({
q0: { pending: 1, total: 500 },
q500: { pending: 1, total: 500 },
q1000: { pending: 1, total: 1 },
});
});
it("returns {} on a non-ok response", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "nope" }, 500));
expect(await fetchAiCounts(["a"])).toEqual({});
});
it("returns {} when fetch throws", async () => {
fetchMock.mockRejectedValueOnce(new Error("network"));
expect(await fetchAiCounts(["a"])).toEqual({});
});
it("returns {} when the body is not an object", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse(null));
expect(await fetchAiCounts(["a"])).toEqual({});
});
});

View file

@ -0,0 +1,38 @@
import getPocketBase from "@/lib/pocketbase";
export type AiCounts = Record<string, { pending: number; total: number }>;
// The route rejects more than 500 ids per call.
const MAX_IDS_PER_REQUEST = 500;
async function fetchChunk(ids: string[], token: string): Promise<AiCounts> {
const resp = await fetch(
`/api/v1/quotes/assistant/counts?ids=${encodeURIComponent(ids.join(","))}`,
{ headers: { Authorization: `Bearer ${token}` }, cache: "no-store" },
);
if (!resp.ok) return {};
const body: unknown = await resp.json();
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body as AiCounts;
}
/**
* Per-quote "AI lines awaiting review" counts for the quotes board badge.
* Best-effort: any failure yields `{}` so the badge simply stays hidden and
* the board is never affected.
*/
export async function fetchAiCounts(ids: string[]): Promise<AiCounts> {
if (ids.length === 0) return {};
try {
const token = getPocketBase().authStore.token;
if (!token) return {};
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += MAX_IDS_PER_REQUEST) {
chunks.push(ids.slice(i, i + MAX_IDS_PER_REQUEST));
}
const results = await Promise.all(chunks.map((c) => fetchChunk(c, token)));
return Object.assign({}, ...results) as AiCounts;
} catch {
return {};
}
}