diff --git a/src/app/(app)/orders/[id]/process/page.tsx b/src/app/(app)/orders/[id]/process/page.tsx index bf8581e..28f86d1 100644 --- a/src/app/(app)/orders/[id]/process/page.tsx +++ b/src/app/(app)/orders/[id]/process/page.tsx @@ -58,6 +58,10 @@ import { FulfillFromStockCell } from "@/components/fulfill-from-stock-cell"; import { MarginApprovalDialog } from "@/components/margin-approval-dialog"; import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger"; import { AttachmentList, type MatchedAttachment } from "@/components/orders/attachment-list"; +import { + QuoteLineContext, + QuoteLineNoMatch, +} from "@/components/orders/quote-line-context"; import type { Order, OrderItem, Supplier, PurchaseOrder, QuoteItem, StockItem } from "@/lib/types"; /* ============================================================================ @@ -301,6 +305,22 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str const [perItemAttachments, setPerItemAttachments] = useState< Record >({}); + // Order-line → quote-line pairing, resolved server-side by + // pairOrderLines (see /api/v1/orders/[id]/matched-attachments). + // Keyed by `order_item_id`; `tier` distinguishes a saved pair + // ("confident") from a guess ("possible") so the UI can prompt the + // operator to confirm rather than silently rendering wrong-line + // notes. + const [linePairs, setLinePairs] = useState< + Record< + string, + { quote_item_id: string; confidence: number; tier: "confident" | "possible" } + > + >({}); + // Which order_item the operator is currently confirming a "possible" + // pair for. Used to disable the confirm button while the POST is + // in flight. + const [confirmingPairId, setConfirmingPairId] = useState(null); // Sheet state — single sheet, content switches by itemId. const [editingItemId, setEditingItemId] = useState(null); @@ -415,18 +435,29 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str const payload = (await attachRes.json()) as { quoteAttachments: MatchedAttachment[]; perItemAttachments: Record; + linePairs?: Record< + string, + { + quote_item_id: string; + confidence: number; + tier: "confident" | "possible"; + } + >; }; setQuoteAttachments(payload.quoteAttachments ?? []); setPerItemAttachments(payload.perItemAttachments ?? {}); + setLinePairs(payload.linePairs ?? {}); } else { setQuoteAttachments([]); setPerItemAttachments({}); + setLinePairs({}); } } catch { // Network error / route 500 — fall back to empty; the match // preview + use-this-cost button still work without attachments. setQuoteAttachments([]); setPerItemAttachments({}); + setLinePairs({}); } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "Failed to load order"); @@ -471,7 +502,14 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str const copyCostFromMatch = async (itemId: string) => { const edit = edits[itemId]; if (!edit) return; - const matched = findMatchedQuoteItem(edit.description, matchedQuoteItems); + // Use the server-resolved pairing (Task 10) rather than the + // description-match guess — the matcher persisted confident + // pairs onto order_items.quote_item and returns possible pairs + // here so the operator can confirm them. + const matched = + matchedQuoteItems.find( + (q) => q.id === linePairs[edit.id]?.quote_item_id, + ) ?? null; if (!matched) { toast.error("No matched quote item to copy from"); return; @@ -506,6 +544,43 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str await saveItem(itemId); }; + /** + * Confirm a "possible" order-line → quote-line pair. POSTs to the + * dedicated route from Task 10 (which writes the operator's + * decision onto order_items.quote_item) and then re-runs the + * fetchAll so the next render sees the pair as "confident". + * + * The confirm button is only rendered for tier === "possible" — + * confident pairs are already persisted, doubtful ones need this + * explicit confirmation so a buyer is never shown a note from the + * wrong line without the operator having said yes. + */ + const confirmPair = useCallback( + async (orderItemId: string, quoteItemId: string) => { + setConfirmingPairId(orderItemId); + try { + const res = await fetch(`/api/v1/orders/${id}/quote-line`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + order_item_id: orderItemId, + quote_item_id: quoteItemId, + }), + }); + if (!res.ok) throw new Error("Failed to confirm quote line"); + toast.success("Quote line linked"); + await fetchAll(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Failed to confirm quote line", + ); + } finally { + setConfirmingPairId(null); + } + }, + [id, fetchAll], + ); + const saveItem = async ( itemId: string, options: { closePriceEditorOnApproval?: boolean } = {}, @@ -1614,7 +1689,15 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str {sortedItems.map((item) => { const edit = edits[item.id]; if (!edit) return null; - const matched = findMatchedQuoteItem(edit.description, matchedQuoteItems); + // Server-resolved pairing (Task 10) — confident pairs + // were persisted onto order_items.quote_item, possible + // pairs come through linePairs for the operator to + // confirm. Replaces the exact/substring description guess + // that was the root cause of vanishing buyer notes. + const pair = linePairs[item.id]; + const matched = + matchedQuoteItems.find((q) => q.id === pair?.quote_item_id) ?? + null; // A remainder line already exists for this shortfall when a // sibling supplier-less line carries exactly the leftover qty // for the same sku/description. Used to hide the split button @@ -1651,11 +1734,23 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str suppliers={suppliers} stockItems={stockItems} matchedQuoteItem={matched} + // "confident" if the matcher persisted the pair, + // "possible" if it's still a guess awaiting the + // operator's confirm click. Default confident so + // legacy rows without a pair record still render + // the buyer note without the warning banner. + pairTier={linePairs[item.id]?.tier ?? "confident"} quoteAttachments={quoteAttachments} perItemAttachments={ matched ? (perItemAttachments[matched.id] ?? []) : [] } stripeColor={supplierStripeColor(edit.supplier)} + orderHasQuote={!!(order?.quote || order?.potential_quote)} + onConfirmPair={() => { + if (!matched) return; + void confirmPair(item.id, matched.id); + }} + confirmingPair={confirmingPairId === item.id} approvalLocked={item.item_status === "approval_needed"} onChange={(patch) => { if (item.item_status === "approval_needed") return; @@ -1776,7 +1871,11 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str q.id === linePairs[editingEdit.id]?.quote_item_id, + ) ?? null } open={!!editingItemId} onOpenChange={(open) => { @@ -1832,6 +1931,7 @@ function ItemCard({ suppliers, stockItems, matchedQuoteItem, + pairTier, quoteAttachments, perItemAttachments, stripeColor, @@ -1841,10 +1941,13 @@ function ItemCard({ onCopyCostFromMatch, onToggleInStock, onSplit, + onConfirmPair, + confirmingPair, splitting, alreadySplit, splitMarker, approvalLocked, + orderHasQuote, }: { item: OrderItem; edit: EditableItem; @@ -1852,6 +1955,10 @@ function ItemCard({ /** Stock items — fed into the "Fulfill from stock" toggle. */ stockItems: StockItem[]; matchedQuoteItem: QuoteItem | null; + /** "confident" if the matcher persisted the pair, "possible" if the + * operator still needs to confirm. Drives the warning banner and + * the Confirm button in the QuoteLineContext panel. */ + pairTier: "confident" | "possible"; /** Quote-level attachments (RFQ email, generated PDF, outbound uploads) — same for every card on this page. */ quoteAttachments: MatchedAttachment[]; /** Attachments attached to the matched quote_item only — different per card. */ @@ -1860,6 +1967,9 @@ function ItemCard({ stripeColor: string | null; /** True while the manager owns this pricing decision. */ approvalLocked: boolean; + /** Whether the parent order has any quote relation at all (linked or suggested). + * Used to suppress the "no matching line" panel when there is no quote to match against. */ + orderHasQuote: boolean; onChange: (patch: Partial) => void; onSave: () => void; onOpenPrice: () => void; @@ -1869,6 +1979,10 @@ function ItemCard({ onToggleInStock: (next: boolean) => void; /** Split a shorted line into its remainder (new supplier-less line). */ onSplit: () => void; + /** Operator confirms a "possible" pair — POSTs to /api/v1/orders/{id}/quote-line. */ + onConfirmPair: () => void; + /** Spinner flag for the confirm button (true while the POST is in flight). */ + confirmingPair: boolean; /** True while this item's split request is in flight (spinner on the button). */ splitting: boolean; /** True when a supplier-less remainder line for this shortfall already exists. */ @@ -2394,7 +2508,7 @@ function ItemCard({ there's something to show (attachments, a differing matched description, a per-line note, or any of the 4 pricing audit fields populated). */} - {matchedQuoteItem && + {(matchedQuoteItem && (quoteAttachments.length > 0 || perItemAttachments.length > 0 || (matchedQuoteItem.description && @@ -2404,7 +2518,12 @@ function ItemCard({ edit.sku || edit.pricing_source || edit.matched_price_list_id || - edit.pricing_notes) && ( + edit.pricing_notes)) || + // Show the panel even when there's no matched quote line — + // the operator should see "no matching line" rather than an + // empty card. orderHasQuote guards against showing it on + // orders with no quote at all. + (!matchedQuoteItem && orderHasQuote) ? (
)} - )} + ) : null} ); @@ -3014,23 +3110,3 @@ function SupplierCell({ ); } - -/* ============================================================================ - * findMatchedQuoteItem — case-insensitive description match. - * 1) exact match - * 2) either-side substring - * Returns null if no match (the inline preview then hides itself). - * ========================================================================== */ -function findMatchedQuoteItem( - description: string, - quoteItems: QuoteItem[] -): QuoteItem | null { - const desc = (description || "").trim().toLowerCase(); - if (!desc || quoteItems.length === 0) return null; - return ( - quoteItems.find((q) => (q.description || "").trim().toLowerCase() === desc) || - quoteItems.find((q) => (q.description || "").trim().toLowerCase().includes(desc)) || - quoteItems.find((q) => desc.includes((q.description || "").trim().toLowerCase())) || - null - ); -}