Task 8 of the AI order assistant plan.
- new AiQuoteMatchPanel: "Linked by AI" / "AI suggests Q…", the
reason, the doubts and the line-pair table (order line -> quote line
with same_item)
- the quote match card gains a "Match with AI" button in every state,
posts to /api/v1/orders/{id}/assistant/run with the PB token, toasts
the 409 message verbatim, then reloads the order
- new card state: the AI suggested a quote -> the AI panel + Accept
(links the AI's quote) / Match with AI / Dismiss
- the AI suggestion marks the order as quote_matching_attempted so the
deterministic sweep cannot overwrite it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
137 lines
4.9 KiB
TypeScript
137 lines
4.9 KiB
TypeScript
import { Bot, CircleCheck, CircleHelp, TriangleAlert } from "lucide-react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import type { OrderAiQuoteMatch } from "@/lib/types";
|
|
|
|
/**
|
|
* The AI order assistant's verdict on this order.
|
|
*
|
|
* Two states matter to an operator:
|
|
* - "Linked by AI" — the assistant linked the order itself, because it
|
|
* was sure. The Unlink button next to it is the way back.
|
|
* - "AI suggests Q…" — the assistant is not sure, so a person decides.
|
|
*
|
|
* The line-pair table is the audit trail: which order line went to which
|
|
* quote line, and how sure the AI was about each one.
|
|
*/
|
|
|
|
export type AiMatchLine = { id: string; description?: string };
|
|
|
|
const SAME_ITEM_LABEL: Record<string, string> = {
|
|
sure: "sure",
|
|
likely: "likely",
|
|
unsure: "unsure",
|
|
};
|
|
|
|
const SAME_ITEM_CLASS: Record<string, string> = {
|
|
sure: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
|
likely: "border-amber-200 bg-amber-50 text-amber-700",
|
|
unsure: "border-zinc-200 bg-zinc-50 text-zinc-700",
|
|
};
|
|
|
|
export function AiQuoteMatchPanel({
|
|
match,
|
|
orderLines = [],
|
|
quoteLines = [],
|
|
linkedQuoteId,
|
|
}: {
|
|
match: OrderAiQuoteMatch;
|
|
orderLines?: AiMatchLine[];
|
|
quoteLines?: AiMatchLine[];
|
|
/** The order's hard-linked quote id, when it has one. */
|
|
linkedQuoteId?: string | null;
|
|
}) {
|
|
const orderById = new Map(orderLines.map((l) => [l.id, l.description || ""]));
|
|
const quoteById = new Map(quoteLines.map((l) => [l.id, l.description || ""]));
|
|
|
|
const linkedByAi = !!match.auto_linked && !!linkedQuoteId && linkedQuoteId === match.quote_id;
|
|
const quoteLabel = match.quote_number || "a quote";
|
|
|
|
const headline = linkedByAi
|
|
? "Linked by AI"
|
|
: match.status === "no_match"
|
|
? "AI found no matching quote"
|
|
: `AI suggests ${quoteLabel}`;
|
|
|
|
const HeadlineIcon = linkedByAi ? CircleCheck : match.status === "no_match" ? CircleHelp : Bot;
|
|
|
|
return (
|
|
<div
|
|
data-testid="ai-quote-match-panel"
|
|
className="rounded-md border border-ai/30 bg-ai/10 px-3 py-2.5 space-y-2"
|
|
>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<HeadlineIcon className="h-4 w-4 text-ai" aria-hidden />
|
|
<span className="text-sm font-semibold text-ai">{headline}</span>
|
|
{match.confidence && (
|
|
<Badge variant="outline" className="text-xs border-ai/30 bg-background text-ai">
|
|
{match.confidence} confidence
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{match.reason ? <p className="text-xs">{match.reason}</p> : null}
|
|
|
|
{match.doubts.length > 0 ? (
|
|
<div className="rounded-md border border-warn/30 bg-warn/10 px-2 py-1.5">
|
|
<span className="text-xs font-medium text-warn flex items-center gap-1">
|
|
<TriangleAlert className="h-3 w-3" aria-hidden />
|
|
Doubts
|
|
</span>
|
|
<ul className="list-disc pl-4 text-xs text-warn">
|
|
{match.doubts.map((d, i) => (
|
|
<li key={`${i}-${d}`}>{d}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
{match.lines.length > 0 ? (
|
|
<div className="rounded-md border bg-background/60 px-2 py-1.5">
|
|
<span className="text-xs font-medium">Line pairs</span>
|
|
<table className="mt-1 w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-muted-foreground">
|
|
<th className="font-normal py-0.5 pr-2">Order line</th>
|
|
<th className="font-normal py-0.5 pr-2">Quote line</th>
|
|
<th className="font-normal py-0.5">Match</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{match.lines.map((line) => (
|
|
<tr key={line.order_line_id} className="align-top">
|
|
<td className="py-0.5 pr-2">
|
|
{orderById.get(line.order_line_id) || line.order_line_id}
|
|
</td>
|
|
<td className="py-0.5 pr-2">
|
|
{line.quote_line_id
|
|
? quoteById.get(line.quote_line_id) || line.quote_line_id
|
|
: "— no quote line —"}
|
|
</td>
|
|
<td className="py-0.5">
|
|
{line.same_item ? (
|
|
<span
|
|
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium ${
|
|
SAME_ITEM_CLASS[line.same_item] ??
|
|
"border-zinc-200 bg-zinc-50 text-zinc-700"
|
|
}`}
|
|
>
|
|
{SAME_ITEM_LABEL[line.same_item] ?? line.same_item}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : null}
|
|
|
|
{match.error ? (
|
|
<p className="text-xs text-destructive">AI run failed: {match.error}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|