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:
parent
33fc549e10
commit
dda1999fb6
1 changed files with 156 additions and 80 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue