WFDASH-WEBAPP/src/components/orders/ai-quote-match-panel.tsx
Dave 00d09b3b12 feat(orders): show AI quote match on the order page
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>
2026-10-24 13:13:28 +02:00

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