feat(orders): tint New-tab cards by the AI quote match
Purple when the order is linked to the quote the AI chose and every line was paired "sure"; yellow when the AI matched a quote but not every line for sure, or it is only a suggestion. New tab only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
37933ccdc0
commit
793c9ea215
3 changed files with 170 additions and 1 deletions
|
|
@ -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 (
|
||||
<Card className="overflow-hidden py-0 gap-0">
|
||||
<Card
|
||||
className={cn("overflow-hidden py-0 gap-0", aiTint && ORDER_AI_MATCH_TINT_CLASS[aiTint])}
|
||||
title={aiTint ? ORDER_AI_MATCH_TINT_TITLE[aiTint] : undefined}
|
||||
data-ai-match={aiTint ?? undefined}
|
||||
>
|
||||
{/*
|
||||
* Always-visible header. Two distinct click targets:
|
||||
* 1. The main header area (toggle button) — clicking expands
|
||||
|
|
|
|||
110
src/lib/order-ai-match-tint.test.ts
Normal file
110
src/lib/order-ai-match-tint.test.ts
Normal file
|
|
@ -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> = {}): 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> = {}): 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();
|
||||
});
|
||||
});
|
||||
46
src/lib/order-ai-match-tint.ts
Normal file
46
src/lib/order-ai-match-tint.ts
Normal file
|
|
@ -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<Order, "quote" | "quote_match_method" | "ai_quote_match">,
|
||||
): 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",
|
||||
};
|
||||
Loading…
Reference in a new issue