fix(orders): resolve quote line pairing server-side on the process page

This is the actual fix for the vanishing-buyer-notes bug the matcher
service exists to prevent. Before this commit, the process page did
an exact/substring description match on every render — and silently
hid the buyer note whenever the PO's wording differed from the
quote's, even after the matcher had resolved a confident pair.

- Server-resolved linePairs now drives the per-item lookup in three
  places (inline quote preview row, the price-edit sheet's matched
  prop, and copyCostFromMatch).
- findMatchedQuoteItem — the local exact/substring guesser that was
  the root cause — is deleted. No other callers exist.
- QuoteLineContext renders description (when different), buyer note,
  per-line and quote-level attachments, plus the 'possible match'
  banner + confirm button. Replaces a hand-rolled block that had
  drifted from the orders detail page.
- QuoteLineNoMatch renders when the order has a quote relation but
  this specific order line didn't pair to any quote line —
  previously this case rendered nothing, which was indistinguishable
  from 'the quote line had no note'.
- onConfirmPair POSTs to /api/v1/orders/{id}/quote-line (Task 10)
  and re-runs fetchAll so the next render sees the pair as
  'confident'. Only exposed for tier === 'possible'.
- The Pricing source grid stays inline — it's order-side data
  (SKU / pricing_source / matched_price_list_id / pricing_notes
  round-trip from quote_items onto order_items), not part of the
  quote context, and is editable on the order.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:31:24 +02:00
parent 33fc549e10
commit dda1999fb6

View file

@ -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<string, MatchedAttachment[]>
>({});
// 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<string | null>(null);
// Sheet state — single sheet, content switches by itemId.
const [editingItemId, setEditingItemId] = useState<string | null>(null);
@ -415,18 +435,29 @@ export default function ProcessOrderPage({ params }: { params: Promise<{ id: str
const payload = (await attachRes.json()) as {
quoteAttachments: MatchedAttachment[];
perItemAttachments: Record<string, MatchedAttachment[]>;
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
<PriceEditSheet
edit={editingEdit}
matchedQuoteItem={
findMatchedQuoteItem(editingEdit.description, matchedQuoteItems)
// Server-resolved pairing (Task 10) — replaces the
// description-match guess the sheet used to do itself.
matchedQuoteItems.find(
(q) => 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<EditableItem>) => 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) ? (
<div className="mt-2 sm:mt-2.5 border-t pt-2 sm:pt-2.5">
<button
type="button"
@ -2429,44 +2548,37 @@ function ItemCard({
</button>
{quoteSourceExpanded && (
<div className="mt-2 space-y-2.5 text-xs">
{/* Matched quote_item description — the source of truth
for "is this the right SKU?". Shown only when it
differs from the order description, since equal
descriptions are noise. */}
{matchedQuoteItem.description &&
matchedQuoteItem.description !== edit.description && (
<div className="rounded border bg-muted/30 px-2.5 py-1.5">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground mb-0.5">
Quote line says
</div>
<div className="whitespace-pre-wrap break-words">
{matchedQuoteItem.description}
</div>
</div>
)}
{/* Buyer note from the original quote_item — free text
the buyer typed when they raised the RFQ. Surfaced
here so the operator reads the *reason* the buyer
wanted this line (e.g. "M20 not M16, got mixed up
last time") before pressing Send PO. */}
{matchedQuoteItem.notes &&
matchedQuoteItem.notes.trim() !== "" && (
<div className="rounded border border-warn/30 bg-warn/5 dark:bg-warn/10 px-2.5 py-1.5">
<div className="text-[10px] uppercase tracking-wide text-warn-foreground dark:text-warn mb-0.5 inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
Buyer note
</div>
<div className="whitespace-pre-wrap break-words text-foreground/90">
{matchedQuoteItem.notes}
</div>
</div>
)}
{/* Pricing audit-trail grid — the 4 fields that
round-trip from quote_items onto order_items when
the operator copies cost from a match. Visible
here so the buyer can confirm "yes, this is the
right SKU" at a glance without opening the price
sheet. */}
{matchedQuoteItem ? (
<QuoteLineContext
quoteDescription={matchedQuoteItem.description || ""}
orderDescription={edit.description}
notes={matchedQuoteItem.notes || ""}
perItemAttachments={perItemAttachments}
quoteAttachments={quoteAttachments}
tier={pairTier}
// Only show the confirm button for doubtful
// pairs. Confident pairs were already persisted
// server-side — re-confirming is meaningless.
onConfirm={
pairTier === "possible"
? onConfirmPair
: undefined
}
confirming={confirmingPair}
/>
) : (
// Order has a quote relation but this order line
// didn't pair to any quote line. Previously this
// rendered nothing — operators couldn't tell
// "no note" from "no match".
<QuoteLineNoMatch />
)}
{/* Pricing audit-trail grid — order-side data (the
4 fields that round-trip from quote_items onto
order_items when the operator copies cost from
a match). Kept here rather than inside
QuoteLineContext because it's editable on the
order, not the quote. */}
{(edit.sku ||
edit.pricing_source ||
edit.matched_price_list_id ||
@ -2528,26 +2640,10 @@ function ItemCard({
</dl>
</div>
)}
{/* Per-line attachments — proof photos, spec sheets
attached to the matched quote_item specifically. */}
{perItemAttachments.length > 0 && (
<AttachmentList
label="Attached to this line"
attachments={perItemAttachments}
/>
)}
{/* Quote-level attachments — RFQ email, generated PDF,
operator-uploaded outbound attachments. */}
{quoteAttachments.length > 0 && (
<AttachmentList
label="Quote attachments"
attachments={quoteAttachments}
/>
)}
</div>
)}
</div>
)}
) : null}
</CardContent>
</Card>
);
@ -3014,23 +3110,3 @@ function SupplierCell({
</Popover>
);
}
/* ============================================================================
* 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
);
}