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:
Dave 2026-10-24 20:52:59 +02:00
parent 37933ccdc0
commit 793c9ea215
3 changed files with 170 additions and 1 deletions

View file

@ -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

View 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();
});
});

View 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",
};