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>
This commit is contained in:
parent
85533245bf
commit
00d09b3b12
5 changed files with 506 additions and 8 deletions
|
|
@ -42,6 +42,7 @@ import {
|
||||||
Send,
|
Send,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Bot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -66,6 +67,10 @@ import { ItemStatusPill } from "@/components/item-status-pill";
|
||||||
import { QuoteSearch } from "@/components/ui/quote-search";
|
import { QuoteSearch } from "@/components/ui/quote-search";
|
||||||
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
|
import { SendPoModal } from "@/components/purchase-orders/send-po-modal";
|
||||||
import { QuoteLineContext } from "@/components/orders/quote-line-context";
|
import { QuoteLineContext } from "@/components/orders/quote-line-context";
|
||||||
|
import {
|
||||||
|
AiQuoteMatchPanel,
|
||||||
|
type AiMatchLine,
|
||||||
|
} from "@/components/orders/ai-quote-match-panel";
|
||||||
import type { MatchedAttachment } from "@/components/orders/attachment-list";
|
import type { MatchedAttachment } from "@/components/orders/attachment-list";
|
||||||
|
|
||||||
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
|
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
|
||||||
|
|
@ -141,6 +146,23 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
||||||
const [matching, setMatching] = useState(false);
|
const [matching, setMatching] = useState(false);
|
||||||
const [matchResult, setMatchResult] = useState<QuoteMatchResult | null>(null);
|
const [matchResult, setMatchResult] = useState<QuoteMatchResult | null>(null);
|
||||||
|
|
||||||
|
// ─── AI order assistant ──────────────────────────────────────
|
||||||
|
// `order.ai_quote_match` is written by the server-side assistant
|
||||||
|
// (src/lib/server/order-assistant-service.ts). The page only shows
|
||||||
|
// what it decided and offers a "Match with AI" button to run it now.
|
||||||
|
const [aiMatching, setAiMatching] = useState(false);
|
||||||
|
// The quote lines of the quote the assistant chose — fetched so the
|
||||||
|
// line-pair table can show descriptions instead of bare ids.
|
||||||
|
const [aiQuoteLines, setAiQuoteLines] = useState<AiMatchLine[]>([]);
|
||||||
|
// The assistant's own suggestion, kept apart from `matchResult` (the
|
||||||
|
// rule-based matcher). The assistant writes `potential_quote` too, but
|
||||||
|
// the card shows the AI's reasoning when the AI was the one that ran.
|
||||||
|
const [aiSuggestion, setAiSuggestion] = useState<{
|
||||||
|
quoteId: string;
|
||||||
|
quoteNumber: string;
|
||||||
|
confidence: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const fetchOrder = useCallback(async () => {
|
const fetchOrder = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -171,6 +193,19 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
||||||
// reload. (`best` is null when method === "no_match" — the
|
// reload. (`best` is null when method === "no_match" — the
|
||||||
// operator's intent is encoded in the saved fields, so we
|
// operator's intent is encoded in the saved fields, so we
|
||||||
// don't need to re-run the matcher to render the verdict.)
|
// don't need to re-run the matcher to render the verdict.)
|
||||||
|
setAiSuggestion(null);
|
||||||
|
if (data.quote_match_method === "ai_suggest" && data.ai_quote_match?.quote_id) {
|
||||||
|
// The AI order assistant suggested this quote. It has its own
|
||||||
|
// card state, so it is kept out of the rule-based matcher state.
|
||||||
|
setAiSuggestion({
|
||||||
|
quoteId: data.ai_quote_match.quote_id,
|
||||||
|
quoteNumber:
|
||||||
|
data.ai_quote_match.quote_number ||
|
||||||
|
data.expand?.potential_quote?.quote_number ||
|
||||||
|
"",
|
||||||
|
confidence: data.potential_quote_confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (data.quote_matching_attempted) {
|
if (data.quote_matching_attempted) {
|
||||||
const method = data.quote_match_method || "no_match";
|
const method = data.quote_match_method || "no_match";
|
||||||
const allowedMethods: ReadonlyArray<QuoteMatchResult["method"]> = [
|
const allowedMethods: ReadonlyArray<QuoteMatchResult["method"]> = [
|
||||||
|
|
@ -263,6 +298,36 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
||||||
void fetchQuoteContext();
|
void fetchQuoteContext();
|
||||||
}, [fetchOrder, fetchQuoteContext]);
|
}, [fetchOrder, fetchQuoteContext]);
|
||||||
|
|
||||||
|
// The quote lines of the quote the assistant chose — so the line-pair
|
||||||
|
// table can show what was matched instead of bare ids. Non-fatal: the
|
||||||
|
// panel still renders with ids when this fails.
|
||||||
|
const aiQuoteId = order?.ai_quote_match?.quote_id ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!aiQuoteId) {
|
||||||
|
setAiQuoteLines([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const pb = getPocketBase();
|
||||||
|
const res = await pb.collection("quote_items").getList<{ id: string; description?: string }>(
|
||||||
|
1,
|
||||||
|
200,
|
||||||
|
{ filter: `quote = "${aiQuoteId}"`, fields: "id,description" },
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
setAiQuoteLines(res.items.map((l) => ({ id: l.id, description: l.description || "" })));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAiQuoteLines([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [aiQuoteId]);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
const pb = getPocketBase();
|
const pb = getPocketBase();
|
||||||
|
|
@ -422,8 +487,53 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
||||||
}
|
}
|
||||||
}, [order, refInput, fetchOrder, fetchQuoteContext]);
|
}, [order, refInput, fetchOrder, fetchQuoteContext]);
|
||||||
|
|
||||||
const clearMatch = useCallback(async () => {
|
// ─── Match with AI ───────────────────────────────────────────
|
||||||
|
// Runs the server-side order assistant for this one order. The
|
||||||
|
// assistant decides; the route never changes a line's status and
|
||||||
|
// never sends anything.
|
||||||
|
const runAiMatch = useCallback(async () => {
|
||||||
if (!order) return;
|
if (!order) return;
|
||||||
|
setAiMatching(true);
|
||||||
|
try {
|
||||||
|
const token = getPocketBase().authStore.token;
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/orders/${encodeURIComponent(order.id)}/assistant/run`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: token } : {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
|
outcome?: string;
|
||||||
|
quoteId?: string | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (!res.ok) {
|
||||||
|
// 409 carries a message the operator needs to see verbatim
|
||||||
|
// ("AI is disabled in settings", "not set up yet…").
|
||||||
|
throw new Error(data?.error || "AI match failed");
|
||||||
|
}
|
||||||
|
if (data.outcome === "linked") {
|
||||||
|
toast.success("AI linked this order to a quote");
|
||||||
|
} else if (data.outcome === "suggested") {
|
||||||
|
toast.message("AI suggests a quote — review it below and accept or dismiss");
|
||||||
|
} else if (data.outcome === "no_match") {
|
||||||
|
toast.info("AI found no matching quote");
|
||||||
|
} else if (data.outcome === "skipped") {
|
||||||
|
toast.info("Nothing for the AI to match on this order");
|
||||||
|
} else {
|
||||||
|
toast.error(data?.error || "AI match failed");
|
||||||
|
}
|
||||||
|
await fetchOrder();
|
||||||
|
await fetchQuoteContext();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "AI match failed");
|
||||||
|
} finally {
|
||||||
|
setAiMatching(false);
|
||||||
|
}
|
||||||
|
}, [order, fetchOrder, fetchQuoteContext]);
|
||||||
|
|
||||||
|
const clearMatch = useCallback(async () => { if (!order) return;
|
||||||
try {
|
try {
|
||||||
// Empty quote_id is the unlink path — see route.ts.
|
// Empty quote_id is the unlink path — see route.ts.
|
||||||
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
|
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
|
||||||
|
|
@ -585,6 +695,11 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
|
||||||
onResuggestMatch={runMatch}
|
onResuggestMatch={runMatch}
|
||||||
onClearMatch={clearMatch}
|
onClearMatch={clearMatch}
|
||||||
onLinkQuoteManually={linkQuoteManually}
|
onLinkQuoteManually={linkQuoteManually}
|
||||||
|
onMatchWithAi={runAiMatch}
|
||||||
|
aiMatching={aiMatching}
|
||||||
|
aiQuoteLines={aiQuoteLines}
|
||||||
|
orderLines={items.map((l) => ({ id: l.id, description: l.description || "" }))}
|
||||||
|
aiSuggestion={aiSuggestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ─── Order items table ─────────────────────────────────── */}
|
{/* ─── Order items table ─────────────────────────────────── */}
|
||||||
|
|
@ -1248,15 +1363,21 @@ function CompletePill() {
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
* QuoteMatchCard
|
* QuoteMatchCard
|
||||||
*
|
*
|
||||||
* One dedicated card for the order ↔ quote relationship. Three states:
|
* One dedicated card for the order ↔ quote relationship. Four states:
|
||||||
*
|
*
|
||||||
* 1. HARD LINK present (order.quote is set) — show the linked quote
|
* 1. HARD LINK present (order.quote is set) — show the linked quote
|
||||||
* prominently. Show a "matched by …" line so the operator knows
|
* prominently. Show a "matched by …" line so the operator knows
|
||||||
* how the link was made. Subtle "Open link" link to view the
|
* how the link was made. Subtle "Open link" link to view the
|
||||||
* quote, plus a "Re-match" affordance for the rare case they
|
* quote, plus a "Re-match" affordance for the rare case they
|
||||||
* want to fix a wrong link.
|
* want to fix a wrong link. When the AI order assistant made the
|
||||||
|
* link, the AI panel shows its reasoning next to it.
|
||||||
*
|
*
|
||||||
* 2. SOFT SUGGESTION present (potential_quote, no hard link) —
|
* 2. THE AI ASSISTANT SUGGESTED A QUOTE (`ai_quote_match.status ===
|
||||||
|
* "suggested"`) — show the AI panel with its reasoning, doubts and
|
||||||
|
* line pairs, then Accept / Match with AI / Dismiss. The AI was
|
||||||
|
* not sure, so a person decides.
|
||||||
|
*
|
||||||
|
* 3. SOFT SUGGESTION present (potential_quote, no hard link) —
|
||||||
* show the suggested quote with confidence + method, with
|
* show the suggested quote with confidence + method, with
|
||||||
* Accept / Try again / Clear actions. This is the most common
|
* Accept / Try again / Clear actions. This is the most common
|
||||||
* state on freshly created orders from manual channels. When
|
* state on freshly created orders from manual channels. When
|
||||||
|
|
@ -1264,7 +1385,7 @@ function CompletePill() {
|
||||||
* score breakdown so the operator can audit each signal —
|
* score breakdown so the operator can audit each signal —
|
||||||
* that's the trust backbone for the rule-based system.
|
* that's the trust backbone for the rule-based system.
|
||||||
*
|
*
|
||||||
* 3. EMPTY (no hard link, no suggestion, not yet tried) — show the
|
* 4. EMPTY (no hard link, no suggestion, not yet tried) — show the
|
||||||
* Re-match button (runs the rule-based scorer against all of
|
* Re-match button (runs the rule-based scorer against all of
|
||||||
* the company's quotes) plus an optional reference input for
|
* the company's quotes) plus an optional reference input for
|
||||||
* the deterministic exact-key path. This is the friction fix:
|
* the deterministic exact-key path. This is the friction fix:
|
||||||
|
|
@ -1272,6 +1393,9 @@ function CompletePill() {
|
||||||
* Reference text is optional because the rule-based scorer
|
* Reference text is optional because the rule-based scorer
|
||||||
* works from company + customer + items alone.
|
* works from company + customer + items alone.
|
||||||
*
|
*
|
||||||
|
* Every state also carries a "Match with AI" button, so the operator
|
||||||
|
* can ask the AI to work it out at any time.
|
||||||
|
*
|
||||||
* Stays a single card so the operator's mental model is one place
|
* Stays a single card so the operator's mental model is one place
|
||||||
* to think about the order ↔ quote relationship, instead of three
|
* to think about the order ↔ quote relationship, instead of three
|
||||||
* scattered badges / inputs.
|
* scattered badges / inputs.
|
||||||
|
|
@ -1405,6 +1529,11 @@ function QuoteMatchCard({
|
||||||
onResuggestMatch,
|
onResuggestMatch,
|
||||||
onClearMatch,
|
onClearMatch,
|
||||||
onLinkQuoteManually,
|
onLinkQuoteManually,
|
||||||
|
onMatchWithAi,
|
||||||
|
aiMatching,
|
||||||
|
aiQuoteLines,
|
||||||
|
orderLines,
|
||||||
|
aiSuggestion,
|
||||||
}: {
|
}: {
|
||||||
order: Order;
|
order: Order;
|
||||||
refInput: string;
|
refInput: string;
|
||||||
|
|
@ -1418,12 +1547,52 @@ function QuoteMatchCard({
|
||||||
onResuggestMatch: () => void;
|
onResuggestMatch: () => void;
|
||||||
onClearMatch: () => void;
|
onClearMatch: () => void;
|
||||||
onLinkQuoteManually: (quoteId: string) => void;
|
onLinkQuoteManually: (quoteId: string) => void;
|
||||||
|
onMatchWithAi: () => void;
|
||||||
|
aiMatching: boolean;
|
||||||
|
aiQuoteLines: AiMatchLine[];
|
||||||
|
orderLines: AiMatchLine[];
|
||||||
|
aiSuggestion: { quoteId: string; quoteNumber: string; confidence: number } | null;
|
||||||
}) {
|
}) {
|
||||||
// Three rendering modes:
|
// Three rendering modes:
|
||||||
const hasSuggestion = !!(matchResult && matchResult.best);
|
const hasSuggestion = !!(matchResult && matchResult.best);
|
||||||
const lastMethod = matchResult?.method ?? null;
|
const lastMethod = matchResult?.method ?? null;
|
||||||
const lastAttempted = !!order.quote_matching_attempted;
|
const lastAttempted = !!order.quote_matching_attempted;
|
||||||
|
|
||||||
|
// The AI order assistant's verdict on this order. Shown when the
|
||||||
|
// assistant produced one — "ai_auto" (it linked the order) or
|
||||||
|
// "ai_suggest" (it is suggesting a quote).
|
||||||
|
const aiMatch = order.ai_quote_match ?? null;
|
||||||
|
const showAiPanel =
|
||||||
|
!!aiMatch &&
|
||||||
|
(order.quote_match_method === "ai_auto" || order.quote_match_method === "ai_suggest");
|
||||||
|
|
||||||
|
const aiPanel = showAiPanel ? (
|
||||||
|
<AiQuoteMatchPanel
|
||||||
|
match={aiMatch}
|
||||||
|
orderLines={orderLines}
|
||||||
|
quoteLines={aiQuoteLines}
|
||||||
|
linkedQuoteId={hardLinkQuoteId ?? null}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Every state gets the same affordance: ask the AI to work it out.
|
||||||
|
const matchWithAiButton = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onMatchWithAi}
|
||||||
|
disabled={aiMatching || matching}
|
||||||
|
data-testid="match-with-ai-button"
|
||||||
|
>
|
||||||
|
{aiMatching ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Match with AI
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
// Pending quote pick from the "Browse quotes…" dropdown. When the
|
// Pending quote pick from the "Browse quotes…" dropdown. When the
|
||||||
// operator picks one and there's already a link (state 1) or
|
// operator picks one and there's already a link (state 1) or
|
||||||
// suggestion (state 2), we want to confirm before overwriting.
|
// suggestion (state 2), we want to confirm before overwriting.
|
||||||
|
|
@ -1499,6 +1668,7 @@ function QuoteMatchCard({
|
||||||
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Sparkles className="mr-2 h-3.5 w-3.5" />}
|
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Sparkles className="mr-2 h-3.5 w-3.5" />}
|
||||||
Re-match
|
Re-match
|
||||||
</Button>
|
</Button>
|
||||||
|
{matchWithAiButton}
|
||||||
{searchDropdown}
|
{searchDropdown}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -1511,6 +1681,7 @@ function QuoteMatchCard({
|
||||||
Unlink
|
Unlink
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{aiPanel}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!pendingPick}
|
open={!!pendingPick}
|
||||||
|
|
@ -1541,7 +1712,64 @@ function QuoteMatchCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State 2: soft suggestion present ──────────────────────
|
// ─── State 2: the AI assistant suggested a quote ────────────
|
||||||
|
// The assistant is not sure, so a person decides. The AI panel
|
||||||
|
// carries the reasoning, the doubts and the line pairs; the Accept
|
||||||
|
// button links the quote the assistant chose.
|
||||||
|
if (aiSuggestion) {
|
||||||
|
const aiNum = aiSuggestion.quoteNumber || "(quote)";
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Bot className="h-4 w-4 text-ai" />
|
||||||
|
AI Suggests a Quote
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
The AI matched this order to a quote but is not sure enough to link it
|
||||||
|
itself. Review its reasoning, then accept or dismiss.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{aiPanel}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-auto py-2 px-3 font-mono text-sm"
|
||||||
|
onClick={() => onOpenHardLink(aiSuggestion.quoteId)}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 h-3.5 w-3.5" />
|
||||||
|
{aiNum}
|
||||||
|
</Button>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{(aiSuggestion.confidence * 100).toFixed(0)}% confidence
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onLinkQuoteManually(aiSuggestion.quoteId)}
|
||||||
|
disabled={matching}
|
||||||
|
>
|
||||||
|
{matching ? (
|
||||||
|
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
{matchWithAiButton}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClearMatch} disabled={matching} className="text-muted-foreground">
|
||||||
|
<X className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State 3: soft suggestion present ──────────────────────
|
||||||
if (hasSuggestion && matchResult?.best) {
|
if (hasSuggestion && matchResult?.best) {
|
||||||
const cand = matchResult.best;
|
const cand = matchResult.best;
|
||||||
const qnum = cand.quote.quote_number || "(quote)";
|
const qnum = cand.quote.quote_number || "(quote)";
|
||||||
|
|
@ -1575,6 +1803,7 @@ function QuoteMatchCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{cand.breakdown && <ScoreBreakdownPanel breakdown={cand.breakdown} />}
|
{cand.breakdown && <ScoreBreakdownPanel breakdown={cand.breakdown} />}
|
||||||
|
{aiPanel}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Button size="sm" onClick={onAcceptMatch} disabled={matching}>
|
<Button size="sm" onClick={onAcceptMatch} disabled={matching}>
|
||||||
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
{matching ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
|
@ -1583,6 +1812,7 @@ function QuoteMatchCard({
|
||||||
<Button variant="ghost" size="sm" onClick={onResuggestMatch} disabled={matching}>
|
<Button variant="ghost" size="sm" onClick={onResuggestMatch} disabled={matching}>
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
|
{matchWithAiButton}
|
||||||
{searchDropdown}
|
{searchDropdown}
|
||||||
<Button variant="ghost" size="sm" onClick={onClearMatch} disabled={matching} className="text-muted-foreground">
|
<Button variant="ghost" size="sm" onClick={onClearMatch} disabled={matching} className="text-muted-foreground">
|
||||||
<X className="mr-2 h-3.5 w-3.5" />
|
<X className="mr-2 h-3.5 w-3.5" />
|
||||||
|
|
@ -1594,7 +1824,7 @@ function QuoteMatchCard({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── State 3: empty / tried-but-no-match ───────────────────
|
// ─── State 4: empty / tried-but-no-match ───────────────────
|
||||||
// The primary CTA is **Re-match** — runs the rule-based scorer
|
// The primary CTA is **Re-match** — runs the rule-based scorer
|
||||||
// against every quote in the company. No reference text required:
|
// against every quote in the company. No reference text required:
|
||||||
// the matcher uses company + customer + items + price alone. The
|
// the matcher uses company + customer + items + price alone. The
|
||||||
|
|
@ -1624,6 +1854,7 @@ function QuoteMatchCard({
|
||||||
{matching ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
{matching ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||||
Re-match
|
Re-match
|
||||||
</Button>
|
</Button>
|
||||||
|
{matchWithAiButton}
|
||||||
{lastAttempted && (
|
{lastAttempted && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
105
src/components/orders/ai-quote-match-panel.test.tsx
Normal file
105
src/components/orders/ai-quote-match-panel.test.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { OrderAiQuoteMatch } from "@/lib/types";
|
||||||
|
import { AiQuoteMatchPanel } from "./ai-quote-match-panel";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function match(over: Partial<OrderAiQuoteMatch> = {}): OrderAiQuoteMatch {
|
||||||
|
return {
|
||||||
|
status: "suggested",
|
||||||
|
quote_id: "q1",
|
||||||
|
quote_number: "Q-2026-10-014",
|
||||||
|
confidence: "high",
|
||||||
|
reason: "Same items, slightly different quantities.",
|
||||||
|
doubts: ["Quote price was R3.40, order shows R3.50"],
|
||||||
|
lines: [
|
||||||
|
{ order_line_id: "ol1", quote_line_id: "ql1", quote_id: "q1", same_item: "sure", reason: "same" },
|
||||||
|
{ order_line_id: "ol2", quote_line_id: null, quote_id: null, same_item: null, reason: "" },
|
||||||
|
],
|
||||||
|
auto_linked: false,
|
||||||
|
lines_hash: "h",
|
||||||
|
model: "m",
|
||||||
|
run_at: "2026-10-24T10:00:00.000Z",
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_LINES = [
|
||||||
|
{ id: "ol1", description: "M16 bolt 60mm" },
|
||||||
|
{ id: "ol2", description: "M16 nut" },
|
||||||
|
];
|
||||||
|
const QUOTE_LINES = [{ id: "ql1", description: "M16 x 60 bolt" }];
|
||||||
|
|
||||||
|
describe("AiQuoteMatchPanel", () => {
|
||||||
|
it("shows the suggestion headline with the quote number", () => {
|
||||||
|
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
|
||||||
|
expect(screen.getByText("AI suggests Q-2026-10-014")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Linked by AI' when the assistant made the link", () => {
|
||||||
|
render(
|
||||||
|
<AiQuoteMatchPanel
|
||||||
|
match={match({ status: "matched", auto_linked: true })}
|
||||||
|
orderLines={ORDER_LINES}
|
||||||
|
quoteLines={QUOTE_LINES}
|
||||||
|
linkedQuoteId="q1"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Linked by AI")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim a link when the order points at another quote", () => {
|
||||||
|
render(
|
||||||
|
<AiQuoteMatchPanel
|
||||||
|
match={match({ status: "matched", auto_linked: true })}
|
||||||
|
orderLines={ORDER_LINES}
|
||||||
|
quoteLines={QUOTE_LINES}
|
||||||
|
linkedQuoteId="q-other"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("AI suggests Q-2026-10-014")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Linked by AI")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the reason", () => {
|
||||||
|
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
|
||||||
|
expect(screen.getByText("Same items, slightly different quantities.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the doubts", () => {
|
||||||
|
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
|
||||||
|
expect(screen.getByText("Quote price was R3.40, order shows R3.50")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the line pairs with the order and quote descriptions", () => {
|
||||||
|
render(<AiQuoteMatchPanel match={match()} orderLines={ORDER_LINES} quoteLines={QUOTE_LINES} />);
|
||||||
|
expect(screen.getByText("M16 bolt 60mm")).toBeTruthy();
|
||||||
|
expect(screen.getByText("M16 x 60 bolt")).toBeTruthy();
|
||||||
|
expect(screen.getByText("sure")).toBeTruthy();
|
||||||
|
expect(screen.getByText("— no quote line —")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when no quote was found", () => {
|
||||||
|
render(
|
||||||
|
<AiQuoteMatchPanel
|
||||||
|
match={match({ status: "no_match", quote_id: null, quote_number: "", confidence: null })}
|
||||||
|
orderLines={ORDER_LINES}
|
||||||
|
quoteLines={QUOTE_LINES}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("AI found no matching quote")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the recorded error", () => {
|
||||||
|
render(
|
||||||
|
<AiQuoteMatchPanel
|
||||||
|
match={match({ status: "error", error: "AI exploded" })}
|
||||||
|
orderLines={ORDER_LINES}
|
||||||
|
quoteLines={QUOTE_LINES}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("AI run failed: AI exploded")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
137
src/components/orders/ai-quote-match-panel.tsx
Normal file
137
src/components/orders/ai-quote-match-panel.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -285,9 +285,27 @@ describe("matchOrder", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("suggests (and does not link) on medium confidence", async () => {
|
it("marks the order as attempted so the sweep leaves the suggestion alone", async () => {
|
||||||
const pb = makePb();
|
const pb = makePb();
|
||||||
mockAdminPb(pb);
|
mockAdminPb(pb);
|
||||||
|
|
||||||
|
await matchOrder("ord1", {
|
||||||
|
deps: {
|
||||||
|
link: vi.fn(),
|
||||||
|
match: vi.fn().mockResolvedValue(aiDecision({ confidence: "medium" })),
|
||||||
|
suggest: vi.fn().mockResolvedValue(SUGGESTION),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderUpdate = pb.collection(ORDERS).update as ReturnType<typeof vi.fn>;
|
||||||
|
expect(orderUpdate).toHaveBeenCalledWith(
|
||||||
|
"ord1",
|
||||||
|
expect.objectContaining({ quote_matching_attempted: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests (and does not link) on medium confidence", async () => { const pb = makePb();
|
||||||
|
mockAdminPb(pb);
|
||||||
const link = vi.fn();
|
const link = vi.fn();
|
||||||
|
|
||||||
const res = await matchOrder("ord1", {
|
const res = await matchOrder("ord1", {
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,7 @@ export async function matchOrder(
|
||||||
|
|
||||||
if (shortlist.length === 0) {
|
if (shortlist.length === 0) {
|
||||||
await pb.collection("orders").update(orderId, {
|
await pb.collection("orders").update(orderId, {
|
||||||
|
quote_matching_attempted: true,
|
||||||
quote_match_method: "ai_no_match",
|
quote_match_method: "ai_no_match",
|
||||||
potential_quote: "",
|
potential_quote: "",
|
||||||
potential_quote_confidence: 0,
|
potential_quote_confidence: 0,
|
||||||
|
|
@ -481,6 +482,7 @@ export async function matchOrder(
|
||||||
await writeAiPairs(pb, orderId, aiLines);
|
await writeAiPairs(pb, orderId, aiLines);
|
||||||
|
|
||||||
await pb.collection("orders").update(orderId, {
|
await pb.collection("orders").update(orderId, {
|
||||||
|
quote_matching_attempted: true,
|
||||||
quote_match_method: "ai_auto",
|
quote_match_method: "ai_auto",
|
||||||
potential_quote: "",
|
potential_quote: "",
|
||||||
potential_quote_confidence: 0,
|
potential_quote_confidence: 0,
|
||||||
|
|
@ -500,6 +502,7 @@ export async function matchOrder(
|
||||||
|
|
||||||
// No plausible quote.
|
// No plausible quote.
|
||||||
await pb.collection("orders").update(orderId, {
|
await pb.collection("orders").update(orderId, {
|
||||||
|
quote_matching_attempted: true,
|
||||||
quote_match_method: "ai_no_match",
|
quote_match_method: "ai_no_match",
|
||||||
potential_quote: "",
|
potential_quote: "",
|
||||||
potential_quote_confidence: 0,
|
potential_quote_confidence: 0,
|
||||||
|
|
@ -518,6 +521,10 @@ async function writeSuggestion(
|
||||||
quoteId: string,
|
quoteId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await pb.collection("orders").update(orderId, {
|
await pb.collection("orders").update(orderId, {
|
||||||
|
// The deterministic sweep only touches orders that were never
|
||||||
|
// attempted. Marking this one attempted stops the sweep from
|
||||||
|
// overwriting the AI's suggestion with a rule-based one.
|
||||||
|
quote_matching_attempted: true,
|
||||||
potential_quote: quoteId,
|
potential_quote: quoteId,
|
||||||
potential_quote_confidence: confidenceToScore(decision.confidence),
|
potential_quote_confidence: confidenceToScore(decision.confidence),
|
||||||
quote_match_method: "ai_suggest",
|
quote_match_method: "ai_suggest",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue