diff --git a/src/app/(app)/orders/[id]/page.tsx b/src/app/(app)/orders/[id]/page.tsx index 31540d8..2427f27 100644 --- a/src/app/(app)/orders/[id]/page.tsx +++ b/src/app/(app)/orders/[id]/page.tsx @@ -42,6 +42,7 @@ import { Send, MessageSquare, Paperclip, + Bot, } from "lucide-react"; import { Dialog, @@ -66,6 +67,10 @@ import { ItemStatusPill } from "@/components/item-status-pill"; import { QuoteSearch } from "@/components/ui/quote-search"; import { SendPoModal } from "@/components/purchase-orders/send-po-modal"; 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 { 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 [matchResult, setMatchResult] = useState(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([]); + // 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 () => { setLoading(true); try { @@ -171,6 +193,19 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri // reload. (`best` is null when method === "no_match" — the // operator's intent is encoded in the saved fields, so we // 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) { const method = data.quote_match_method || "no_match"; const allowedMethods: ReadonlyArray = [ @@ -263,6 +298,36 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri void 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 () => { try { const pb = getPocketBase(); @@ -422,8 +487,53 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri } }, [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; + 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 { // Empty quote_id is the unlink path — see route.ts. 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} onClearMatch={clearMatch} onLinkQuoteManually={linkQuoteManually} + onMatchWithAi={runAiMatch} + aiMatching={aiMatching} + aiQuoteLines={aiQuoteLines} + orderLines={items.map((l) => ({ id: l.id, description: l.description || "" }))} + aiSuggestion={aiSuggestion} /> {/* ─── Order items table ─────────────────────────────────── */} @@ -1248,15 +1363,21 @@ function CompletePill() { /* ============================================================================ * 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 * prominently. Show a "matched by …" line so the operator knows * how the link was made. Subtle "Open link" link to view the * 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 * Accept / Try again / Clear actions. This is the most common * state on freshly created orders from manual channels. When @@ -1264,7 +1385,7 @@ function CompletePill() { * score breakdown so the operator can audit each signal — * 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 * the company's quotes) plus an optional reference input for * 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 * 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 * to think about the order ↔ quote relationship, instead of three * scattered badges / inputs. @@ -1405,6 +1529,11 @@ function QuoteMatchCard({ onResuggestMatch, onClearMatch, onLinkQuoteManually, + onMatchWithAi, + aiMatching, + aiQuoteLines, + orderLines, + aiSuggestion, }: { order: Order; refInput: string; @@ -1418,12 +1547,52 @@ function QuoteMatchCard({ onResuggestMatch: () => void; onClearMatch: () => void; onLinkQuoteManually: (quoteId: string) => void; + onMatchWithAi: () => void; + aiMatching: boolean; + aiQuoteLines: AiMatchLine[]; + orderLines: AiMatchLine[]; + aiSuggestion: { quoteId: string; quoteNumber: string; confidence: number } | null; }) { // Three rendering modes: const hasSuggestion = !!(matchResult && matchResult.best); const lastMethod = matchResult?.method ?? null; 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 ? ( + + ) : null; + + // Every state gets the same affordance: ask the AI to work it out. + const matchWithAiButton = ( + + ); + // Pending quote pick from the "Browse quotes…" dropdown. When the // operator picks one and there's already a link (state 1) or // suggestion (state 2), we want to confirm before overwriting. @@ -1499,6 +1668,7 @@ function QuoteMatchCard({ {matching ? : } Re-match + {matchWithAiButton} {searchDropdown} + + {(aiSuggestion.confidence * 100).toFixed(0)}% confidence + + +
+ + {matchWithAiButton} + +
+ + + ); + } + + // ─── State 3: soft suggestion present ────────────────────── if (hasSuggestion && matchResult?.best) { const cand = matchResult.best; const qnum = cand.quote.quote_number || "(quote)"; @@ -1575,6 +1803,7 @@ function QuoteMatchCard({ {cand.breakdown && } + {aiPanel}
+ {matchWithAiButton} {searchDropdown} + {matchWithAiButton} {lastAttempted && (