diff --git a/src/app/(app)/quotes/page.tsx b/src/app/(app)/quotes/page.tsx index 0e1b93c..6b50321 100644 --- a/src/app/(app)/quotes/page.tsx +++ b/src/app/(app)/quotes/page.tsx @@ -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 >({}); + // 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({}); // 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() { {item.quote_number} + {aiCounts[item.id] && } {isFollowUp(item) && } {hasCustomerReply(item) && } {isAged(item) && } diff --git a/src/lib/quote-assistant-counts-client.test.ts b/src/lib/quote-assistant-counts-client.test.ts new file mode 100644 index 0000000..ef7ebd9 --- /dev/null +++ b/src/lib/quote-assistant-counts-client.test.ts @@ -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({}); + }); +}); diff --git a/src/lib/quote-assistant-counts-client.ts b/src/lib/quote-assistant-counts-client.ts new file mode 100644 index 0000000..1f37579 --- /dev/null +++ b/src/lib/quote-assistant-counts-client.ts @@ -0,0 +1,38 @@ +import getPocketBase from "@/lib/pocketbase"; + +export type AiCounts = Record; + +// The route rejects more than 500 ids per call. +const MAX_IDS_PER_REQUEST = 500; + +async function fetchChunk(ids: string[], token: string): Promise { + 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 { + 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 {}; + } +}