diff --git a/src/app/(app)/orders/page.tsx b/src/app/(app)/orders/page.tsx index fd57d06..ebc666d 100644 --- a/src/app/(app)/orders/page.tsx +++ b/src/app/(app)/orders/page.tsx @@ -37,6 +37,11 @@ import type { } from "@/lib/server/orders-service"; import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; import { AiBoardBadge } from "@/components/ai-fill-marker"; +import { + orderAiMatchTint, + ORDER_AI_MATCH_TINT_CLASS, + ORDER_AI_MATCH_TINT_TITLE, +} from "@/lib/order-ai-match-tint"; import { ItemStatusPill } from "@/components/item-status-pill"; import { ShortDeliveredPill } from "@/components/orders/short-delivered-pill"; import { @@ -687,8 +692,16 @@ function OrderCard({ .filter(([, n]) => n > 0) .sort(([a], [b]) => TAB_GROUP_ORDER.indexOf(a) - TAB_GROUP_ORDER.indexOf(b)); + // New tab only: purple when the AI matched every line to the linked + // quote, yellow when it matched a quote but not every line for sure. + const aiTint = activeTab === "new" ? orderAiMatchTint(order) : null; + return ( - + {/* * Always-visible header. Two distinct click targets: * 1. The main header area (toggle button) — clicking expands diff --git a/src/lib/order-ai-match-tint.test.ts b/src/lib/order-ai-match-tint.test.ts new file mode 100644 index 0000000..9d69fdc --- /dev/null +++ b/src/lib/order-ai-match-tint.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { orderAiMatchTint } from "@/lib/order-ai-match-tint"; +import type { OrderAiLinePair, OrderAiQuoteMatch } from "@/lib/types"; + +function pair(over: Partial = {}): OrderAiLinePair { + return { + order_line_id: "ol1", + quote_line_id: "ql1", + quote_id: "q1", + same_item: "sure", + reason: "", + ...over, + }; +} + +function match(lines: OrderAiLinePair[], over: Partial = {}): OrderAiQuoteMatch { + return { + status: "matched", + quote_id: "q1", + quote_number: "Q1", + confidence: "high", + reason: "", + doubts: [], + lines, + auto_linked: true, + lines_hash: "h", + model: "m", + run_at: "2026-10-24T18:00:00.000Z", + ...over, + }; +} + +describe("orderAiMatchTint", () => { + it("is full (purple) when linked to the AI's quote and every line is sure", () => { + expect( + orderAiMatchTint({ + quote: "q1", + quote_match_method: "ai_auto", + ai_quote_match: match([pair(), pair({ order_line_id: "ol2", quote_line_id: "ql2" })]), + }), + ).toBe("full"); + }); + + it("is full when a person accepted an all-sure AI suggestion", () => { + expect( + orderAiMatchTint({ + quote: "q1", + quote_match_method: "ai_suggest", + ai_quote_match: match([pair()], { status: "suggested", auto_linked: false }), + }), + ).toBe("full"); + }); + + it("is partial (yellow) when linked but one line is only likely", () => { + expect( + orderAiMatchTint({ + quote: "q1", + quote_match_method: "ai_suggest", + ai_quote_match: match([pair(), pair({ order_line_id: "ol2", same_item: "likely" })]), + }), + ).toBe("partial"); + }); + + it("is partial when linked but a line has no quote line", () => { + expect( + orderAiMatchTint({ + quote: "q1", + quote_match_method: "ai_suggest", + ai_quote_match: match([pair(), pair({ order_line_id: "ol2", quote_line_id: null, quote_id: null, same_item: null })]), + }), + ).toBe("partial"); + }); + + it("is partial for an open AI suggestion", () => { + expect( + orderAiMatchTint({ + quote: "", + quote_match_method: "ai_suggest", + ai_quote_match: match([pair()], { status: "suggested", auto_linked: false }), + }), + ).toBe("partial"); + }); + + it("is none when a person linked a different quote", () => { + expect( + orderAiMatchTint({ + quote: "q9", + quote_match_method: "ai_auto", + ai_quote_match: match([pair()]), + }), + ).toBeNull(); + }); + + it("is none after a person unlinked the AI's quote", () => { + expect( + orderAiMatchTint({ quote: "", quote_match_method: "", ai_quote_match: match([pair()]) }), + ).toBeNull(); + }); + + it("is none when the AI found nothing or never ran", () => { + expect( + orderAiMatchTint({ + quote: "", + quote_match_method: "ai_no_match", + ai_quote_match: match([], { status: "no_match", quote_id: null }), + }), + ).toBeNull(); + expect(orderAiMatchTint({ quote: "", quote_match_method: "", ai_quote_match: null })).toBeNull(); + }); +}); diff --git a/src/lib/order-ai-match-tint.ts b/src/lib/order-ai-match-tint.ts new file mode 100644 index 0000000..fa55466 --- /dev/null +++ b/src/lib/order-ai-match-tint.ts @@ -0,0 +1,46 @@ +/** + * order-ai-match-tint — how the Orders board tints a New-tab card from + * the AI order assistant's verdict. Client-safe (types only). + * + * "full" (purple) — the order is linked to the quote the AI chose + * and the AI paired every line "sure" to it. + * "partial" (yellow) — the AI matched a quote but not every line is + * "sure", or it is only a suggestion so far. + * null — no AI verdict, the AI found nothing, or someone + * linked the order to a different quote / unlinked it. + */ +import type { Order } from "@/lib/types"; + +export type OrderAiMatchTint = "full" | "partial" | null; + +export function orderAiMatchTint( + order: Pick, +): OrderAiMatchTint { + const match = order.ai_quote_match; + const quoteId = match?.quote_id; + if (!match || !quoteId) return null; + + const linkedToAiQuote = !!order.quote && order.quote === quoteId; + if (linkedToAiQuote) { + const lines = match.lines ?? []; + const allSure = + lines.length > 0 && + lines.every((l) => l.same_item === "sure" && !!l.quote_line_id && l.quote_id === quoteId); + return allSure ? "full" : "partial"; + } + + // Not linked: only an open AI suggestion counts. + if (!order.quote && order.quote_match_method === "ai_suggest") return "partial"; + return null; +} + +/** Card classes for each tint (Orders board, New tab). */ +export const ORDER_AI_MATCH_TINT_CLASS: Record<"full" | "partial", string> = { + full: "bg-ai/10 ring-1 ring-ai/40", + partial: "bg-warn/10 ring-1 ring-warn/40", +}; + +export const ORDER_AI_MATCH_TINT_TITLE: Record<"full" | "partial", string> = { + full: "AI matched every line to the linked quote", + partial: "AI matched a quote, but not every line for sure", +};