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:
parent
40094be3f3
commit
fa854060c4
3 changed files with 121 additions and 0 deletions
|
|
@ -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} />}
|
||||
|
|
|
|||
70
src/lib/quote-assistant-counts-client.test.ts
Normal file
70
src/lib/quote-assistant-counts-client.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
38
src/lib/quote-assistant-counts-client.ts
Normal file
38
src/lib/quote-assistant-counts-client.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue