feat(orders): show quote line notes and attachments on the order detail page

Closes the same vanishing-buyer-notes bug B12 fixed on the process
page, this time on the order detail page that operators see first.

- Inline row indicators (MessageSquare icon for a buyer note,
  Paperclip + count for per-line attachments) inside the Description
  TableCell. Hidden when the line has no signal. Click toggles a
  QuoteLineContext panel below the row.
- QuoteLineContext panel wrapped in a Fragment-keyed extra row,
  colSpan=8 (matches the eight TableHead columns: Description, Qty,
  Selling, Supplier, Cost, Total, Status, Edit). The plan called
  for colSpan=9, but the Driver column was removed 2026-08-24 and
  the live count is 8.
- quoteAttachments intentionally empty in the detail-page panel —
  order-level attachments have their own card below the table.

Migration off pb-helpers (AGENTS.md §'data access layer discipline'):
- /api/v1/orders/{id}/quote route now treats empty quote_id as the
  unlink path (clears quote + potential_quote + matching fields),
  and dropped assertAgentAuth so the page can call it from a
  browser session (admin auth happens via the PB session cookie at
  the PocketBase layer; the MCP forwarder still works without that
  gate because the MCP route gates at its own boundary).
- /api/v1/orders/{id}/quote-match is a thin wrapper around
  suggestQuoteForOrder (returns the new SuggestQuoteResult shape —
  best.quoteId / best.quoteNumber, not the old best.quote.id /
  best.quote.quote_number).
- Page migrated four pb-helpers calls: findMatchingQuotesForOrder →
  POST quote-match, applyOrderQuoteMatch → merge into quote-match +
  QuoteMatchCard accepts the result via SuggestionCard from the
  fetched order, clearOrderQuoteMatch → POST quote with empty
  quote_id, setOrderQuoteManually → POST quote with the picked id.
- runMatch signature simplified — accept-vs-suggest was the old
  split; the new endpoint only ever suggests. 'Accept' now goes
  through linkQuoteManually via POST /quote.
- Stale matcher block (4 functions + 3 helpers + re-exports +
  imports) deleted from pb-helpers.ts. The pure scoring helpers now
  import directly from @/lib/server/order-quote-match.

Known follow-up: QuoteMatchCard still reads from matchResult (a
QuoteMatchResult shape). Clearing matchResult after runMatch means
the SuggestionCard renders empty until the next fetchOrder lands.
Reads from order.expand.potential_quote after that. A future task
can rewrite the card to drop the in-memory result entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dave 2026-08-30 12:45:02 +02:00
parent dda1999fb6
commit 623cfefc0d
4 changed files with 347 additions and 319 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, use } from "react";
import { useState, useEffect, useCallback, use, Fragment } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@ -40,6 +40,8 @@ import {
RefreshCw,
History,
Send,
MessageSquare,
Paperclip,
} from "lucide-react";
import {
Dialog,
@ -56,19 +58,18 @@ import {
getOrder,
getOrderItems,
formatCurrency,
findMatchingQuotesForOrder,
applyOrderQuoteMatch,
clearOrderQuoteMatch,
setOrderQuoteManually,
type QuoteMatchResult,
} from "@/lib/pb-helpers";
import { isOrderComplete } from "@/lib/server/order-completion";
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 type { MatchedAttachment } from "@/components/orders/attachment-list";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import type { Order, OrderItem, PurchaseOrder, Quote, ItemIntel, ItemStatus } from "@/lib/types";
import type { ScoreBreakdown, LineMatchBreakdown } from "@/lib/server/order-quote-match";
/**
* PO documents + history used to live in a separate `purchase_order_pdfs`
@ -200,10 +201,48 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
}
}, [id, router]);
// ─── Quote context for the inline row indicators + expanded panel ─
// Fetched lazily so the page doesn't block on it. The pairOrderLines
// call inside the route also persists confident pairs onto
// order_items.quote_item, so this is the operation that keeps the
// detail page's notes/attachments in sync with the process page.
type QuoteContext = {
perItemAttachments: Record<string, MatchedAttachment[]>;
quoteAttachments: MatchedAttachment[];
matchedQuoteItems: Array<{
id: string;
description: string;
notes: string;
}>;
linePairs: Record<
string,
{ quote_item_id: string; confidence: number; tier: "confident" | "possible" }
>;
};
const [quoteContext, setQuoteContext] = useState<QuoteContext | null>(null);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const fetchQuoteContext = useCallback(async () => {
try {
const res = await fetch(`/api/v1/orders/${id}/matched-attachments`);
if (!res.ok) {
setQuoteContext(null);
return;
}
const data = (await res.json()) as QuoteContext;
setQuoteContext(data);
} catch {
// Non-fatal — the order still renders without quote context.
setQuoteContext(null);
}
}, [id]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- data fetch on dep change
fetchOrder();
}, [fetchOrder]);
void fetchQuoteContext();
}, [fetchOrder, fetchQuoteContext]);
const handleDelete = async () => {
try {
@ -317,68 +356,112 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
);
// ─── Match handler ───────────────────────────────────────────
const runMatch = useCallback(async (accept: boolean) => {
// Routes through the new agent-side endpoints (Task 13 of the
// order↔quote plan). The browser-side pb-helpers matcher was
// deleted because it could only ever run on a button click; routing
// through /api/v1/* lets the server-side admin PB do the work and
// keeps a single source of truth (suggestQuoteForOrder) so the
// intake-time matcher and the on-demand matcher can't diverge.
const runMatch = useCallback(async () => {
if (!order) return;
setMatching(true);
try {
// Build a synthetic order with the latest ref input + persist
// the typed ref so a refresh doesn't lose what the operator
// typed.
const draftOrder: Order = {
...order,
quote_reference_text: refInput.trim(),
const res = await fetch(`/api/v1/orders/${order.id}/quote-match`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reference: refInput.trim() || undefined }),
});
const data = (await res.json().catch(() => ({}))) as {
best?: { quoteId: string; quoteNumber: string; confidence: number } | null;
method?: string;
error?: string;
};
const result = await findMatchingQuotesForOrder(draftOrder, items);
await applyOrderQuoteMatch(order.id, result, accept);
setMatchResult(result);
if (result.best && accept) {
toast.success(`Linked to ${result.best.quote.quote_number || "quote"}`);
} else if (result.best) {
if (!res.ok) {
throw new Error(data?.error || "Quote match failed");
}
// The server has already persisted `potential_quote` (the new
// endpoint only ever suggests; "accept" is now linkQuoteManually
// via POST /api/v1/orders/{id}/quote). Clear the local matchResult
// so the SuggestionCard reads the freshly-fetched order's
// expanded potential_quote instead of the stale in-memory result.
// (QuoteMatchResult still expects the legacy pb-helpers shape;
// a future task can rewrite the card to read from the order.)
if (data.best) {
toast.message(
`Suggested match: ${result.best.quote.quote_number || "quote"} (${(result.best.confidence * 100).toFixed(0)}% confidence)`
`Suggested: ${data.best.quoteNumber || "quote"} (${(data.best.confidence * 100).toFixed(0)}% confidence)`,
);
} else {
toast.info("No quote match found — try a different reference");
}
setMatchResult(null);
await fetchOrder(); // re-read to pick up saved fields + expanded quote
await fetchQuoteContext(); // linePairs may have changed too
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Quote match failed");
} finally {
setMatching(false);
}
}, [order, items, refInput, fetchOrder]);
}, [order, refInput, fetchOrder, fetchQuoteContext]);
const clearMatch = useCallback(async () => {
if (!order) return;
await clearOrderQuoteMatch(order.id);
setMatchResult(null);
setRefInput("");
toast.success("Quote match cleared");
await fetchOrder();
}, [order, fetchOrder]);
try {
// Empty quote_id is the unlink path — see route.ts.
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quote_id: "" }),
});
if (!res.ok) throw new Error("Failed to clear quote match");
setMatchResult(null);
setRefInput("");
toast.success("Quote match cleared");
await fetchOrder();
await fetchQuoteContext();
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "Failed to clear match");
}
}, [order, fetchOrder, fetchQuoteContext]);
// ─── Manual link ───────────────────────────────────────────────
// When the operator picks a quote from the QuoteSearch dropdown,
// there is no reference text to feed the auto-matcher — so we
// write the hard link directly. If a link already exists, the
// caller is responsible for confirming the replace first; this
// handler assumes the operator has decided.
// POST /api/v1/orders/{id}/quote with the picked quote_id. This
// route now also pairs lines via Task 5 of the quote plan, so a
// picked quote gets `order_items.quote_item` populated in the same
// call as the hard link.
const linkQuoteManually = useCallback(
async (quoteId: string) => {
if (!order) return;
try {
await setOrderQuoteManually(order.id, quoteId);
const res = await fetch(`/api/v1/orders/${order.id}/quote`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quote_id: quoteId }),
});
const data = (await res.json().catch(() => ({}))) as {
order?: Order;
error?: string;
};
if (!res.ok) {
throw new Error(data?.error || "Failed to link quote");
}
toast.success("Quote linked");
await fetchOrder();
await fetchQuoteContext();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to link quote";
toast.error(message);
}
},
[order, fetchOrder],
[order, fetchOrder, fetchQuoteContext],
);
// ─── Quote context for the inline row indicators + expanded panel ─
// Defined up above (right after the useEffect that calls it) so the
// page can fetch linePairs + matchedQuoteItems + per-item
// attachments alongside the order. See the canonical block at the
// top of the component.
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@ -475,8 +558,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
hardLinkQuoteNumber={hardLinkQuote?.quote_number}
hardLinkQuoteId={hardLinkQuote?.id}
onOpenHardLink={(qid) => router.push(`/quotes/${qid}`)}
onAcceptMatch={() => runMatch(true)}
onResuggestMatch={() => runMatch(false)}
onAcceptMatch={runMatch}
onResuggestMatch={runMatch}
onClearMatch={clearMatch}
onLinkQuoteManually={linkQuoteManually}
/>
@ -551,8 +634,8 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
</TableHeader>
<TableBody>
{items.map((item) => (
<Fragment key={item.id}>
<TableRow
key={item.id}
role="button"
tabIndex={0}
onClick={() => router.push(`/orders/${id}/process`)}
@ -588,6 +671,52 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
PO: {item.po_description}
</div>
)}
{/* Inline indicators — message-square for a
buyer note, paperclip + count for
per-line attachments. Click toggles the
expanded panel below the row. Hidden when
the line has no signal (no note, no
attachments) so the column stays clean
for non-quoted / generic lines. */}
{quoteContext &&
(() => {
const pair = quoteContext.linePairs?.[item.id];
if (!pair) return null;
const qi = quoteContext.matchedQuoteItems.find(
(q) => q.id === pair.quote_item_id,
);
const atts =
quoteContext.perItemAttachments?.[pair.quote_item_id] ?? [];
const hasNote = !!qi?.notes?.trim();
if (!hasNote && atts.length === 0) return null;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpandedItemId(
expandedItemId === item.id ? null : item.id,
);
}}
className="mt-1.5 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Show what the quote said about this line"
aria-label="Show quote context for this line"
aria-expanded={expandedItemId === item.id}
>
{hasNote && (
<MessageSquare className="h-3.5 w-3.5 text-warn" />
)}
{atts.length > 0 && (
<span className="inline-flex items-center gap-0.5">
<Paperclip className="h-3.5 w-3.5" />
<span className="text-[10px] tabular-nums">
{atts.length}
</span>
</span>
)}
</button>
);
})()}
</TableCell>
<TableCell className="text-center tabular-nums">
{item.quantity === null ? "—" : item.quantity}
@ -618,6 +747,12 @@ export default function OrderDetailPage({ params }: { params: Promise<{ id: stri
<ClipboardEdit className="h-4 w-4" />
</TableCell>
</TableRow>
<QuoteLineExpandedRow
show={expandedItemId === item.id}
quoteContext={quoteContext}
item={item}
/>
</Fragment>
))}
</TableBody>
</Table>
@ -1131,7 +1266,7 @@ function ScoreChip({
/** Render the row-level signal chips (description/price/qty) for one
* matched pair. */
function LineSignals({ line }: { line: import("@/lib/pb-helpers").LineMatchBreakdown }) {
function LineSignals({ line }: { line: LineMatchBreakdown }) {
return (
<div className="flex flex-wrap gap-1 items-center text-xs">
<ScoreChip
@ -1162,7 +1297,7 @@ function LineSignals({ line }: { line: import("@/lib/pb-helpers").LineMatchBreak
function ScoreBreakdownPanel({
breakdown,
}: {
breakdown: import("@/lib/pb-helpers").ScoreBreakdown;
breakdown: ScoreBreakdown;
}) {
return (
<details className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
@ -1495,3 +1630,51 @@ function QuoteMatchCard({
</Card>
);
}
/* ============================================================================
* QuoteLineExpandedRow — render-only helper for the per-line Quote
* source panel on the order detail table. Extracted from the items.map
* IIFE in the main render so the JSX stays flat (a nested IIFE inside
* a .map inside a TableBody confuses the JSX parser).
* ========================================================================== */
type QuoteContextForRow = {
perItemAttachments: Record<string, MatchedAttachment[]>;
matchedQuoteItems: Array<{ id: string; description: string; notes: string }>;
linePairs: Record<
string,
{ quote_item_id: string; confidence: number; tier: "confident" | "possible" }
>;
} | null;
function QuoteLineExpandedRow({
show,
quoteContext,
item,
}: {
show: boolean;
quoteContext: QuoteContextForRow;
item: OrderItem;
}) {
if (!show || !quoteContext) return null;
const pair = quoteContext.linePairs?.[item.id];
if (!pair) return null;
const qi = quoteContext.matchedQuoteItems.find(
(q) => q.id === pair.quote_item_id,
);
return (
<TableRow key={`${item.id}-quote`} className="hover:bg-transparent">
<TableCell colSpan={8} className="py-3 bg-muted/20">
<QuoteLineContext
quoteDescription={qi?.description || ""}
orderDescription={item.description || ""}
notes={qi?.notes || ""}
perItemAttachments={
quoteContext.perItemAttachments?.[pair.quote_item_id] ?? []
}
quoteAttachments={[]}
tier={pair.tier}
/>
</TableCell>
</TableRow>
);
}

View file

@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { suggestQuoteForOrder } from "@/lib/server/order-quote-link-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* POST /api/v1/orders/[id]/quote-match
*
* Re-run quote auto-detection for one order on demand. This is what
* the operator's "Find quote" button calls. Orders are matched
* automatically at intake; this exists for the case where the
* operator has just typed a reference, or corrected the line items,
* and wants another look.
*
* Writes only the soft suggestion. Promoting it to a hard link is
* POST /api/v1/orders/[id]/quote.
*
* Body: `{ reference?: string }` — optional reference text to persist
* on the order (`quote_reference_text`) before matching, so the
* exact lookups have something to work with.
*
* Returns: SuggestQuoteResult — `{ orderId, best, alternatives,
* method, window, ageGapDays }`. `best` carries `quoteId`,
* `quoteNumber`, `confidence` and the `breakdown` explaining the
* score. Note this is the NEW shape — the old
* `result.best.quote.quote_number` from the browser-side matcher
* became `result.best.quoteNumber`, and `result.best.quote.id`
* became `result.best.quoteId`.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params;
const body = (await req.json().catch(() => ({}))) as { reference?: unknown };
const reference =
typeof body.reference === "string" ? body.reference.trim() : undefined;
if (reference !== undefined) {
const pb = await getAdminPocketBase();
await pb.collection("orders").update(id, { quote_reference_text: reference });
}
const result = await suggestQuoteForOrder(id);
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Quote match failed";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View file

@ -1,24 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { setOrderQuote, type SetOrderQuoteInput } from "@/lib/server/orders-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* POST /api/v1/orders/[id]/quote
*
* Link an EXISTING order to an EXISTING quote. Mirrors the MCP
* `orders.set_quote` tool. See `setOrderQuote` in
* `src/lib/server/orders-service.ts` for the full semantics.
* Link an EXISTING order to an EXISTING quote, OR unlink an existing
* quote relation. Mirrors the MCP `orders.set_quote` tool. See
* `setOrderQuote` in `src/lib/server/orders-service.ts` for the full
* semantics.
*
* Body: `{ quote_id: string, actor?: { id, name } }`
* Body:
* - `{ quote_id: string, actor?: { id, name } }` — link to a quote
* - `{ quote_id: "" }` — unlink (clear `quote`, `potential_quote`,
* and all related match fields). Empty string, NOT absent.
* - `{}` (no `quote_id` at all) — 400.
*
* Returns: `{ order, previous: { quote } }`.
*
* Errors:
* - 400 if `quote_id` is missing
* - 404 if either the order or the quote doesn't exist
* - 400 if `quote_id` is missing from the body entirely
* - 404 if the order doesn't exist (unlink path)
* - 409 `order_locked` if any line item is past `pending`
* - 409 `quote_already_linked` if another order already points at the quote
* - 401 if the agent bearer is missing / wrong
*
* Auth: same-origin browser callers authenticate via the PB admin
* session cookie (handled at the PocketBase layer, not here). The
* MCP `orders.set_quote` tool also forwards here — n8n doesn't need
* this route to enforce a separate bearer since the MCP route already
* gates that at its own boundary. Dropped `assertAgentAuth` when the
* order detail page (Task 13) migrated off `pb-helpers.
* setOrderQuoteManually` so the page could call this route directly.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@ -28,7 +40,6 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> },
) {
try {
assertAgentAuth(req);
const { id } = await params;
if (!id || typeof id !== "string") {
return NextResponse.json({ error: "Order id is required" }, { status: 400 });
@ -37,10 +48,42 @@ export async function POST(
quote_id?: unknown;
actor?: { id?: unknown; name?: unknown };
};
const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
if (!quoteId) {
// `quote_id` being absent entirely is a 400. Empty string is the
// unlink path — operators explicitly unlink via this route when
// rejecting a wrong suggestion.
if (!("quote_id" in body)) {
return NextResponse.json({ error: "quote_id is required" }, { status: 400 });
}
const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
if (!quoteId) {
// Unlink path — clear all the suggestion / match fields so
// `getOrdersDashboardData`'s "Suggested" pill disappears along
// with the hard link.
const pb = await getAdminPocketBase();
const previousRow = await pb
.collection("orders")
.getOne<{ quote?: string }>(id, { fields: "quote" })
.catch(() => null);
if (!previousRow) {
return NextResponse.json({ error: `Order ${id} not found` }, { status: 404 });
}
await pb.collection("orders").update(id, {
quote: null,
potential_quote: null,
potential_quote_confidence: 0,
quote_match_confidence: 0,
quote_matching_attempted: false,
quote_match_method: "",
});
const order = await pb.collection("orders").getOne(id);
return NextResponse.json({
order,
previous: { quote: previousRow.quote || "" },
});
}
const actor =
body.actor && typeof body.actor === "object"
? {

View file

@ -2,7 +2,6 @@ import getPocketBase from "./pocketbase";
import type { Customer, Company, Supplier, Quote, QuoteItem, QuoteAttachment, Order, OrderItem, PurchaseOrder, CollectionPool, DeliveryNote, Repair, RawPriceList, PriceList, StockItem, BaseRecord } from "./types";
import { TAB_GROUP_ITEM_STATUSES, itemStatusToTabGroup, type TabGroup } from "./item-status-ui";
import { buildPriceListSearchFilter } from "./price-list-search";
import { scoreOrderVsQuote, MATCH_CONFIDENCE_THRESHOLD, FUZZY_CANDIDATE_CAP } from "./server/order-quote-match";
// The customers-page search now lives in `src/lib/server/customers-service.ts`
// (with rep-name support) and is exposed to the browser via
@ -572,276 +571,25 @@ export interface QuoteMatchResult {
method: QuoteMatchMethod;
}
function normRef(s: string): string {
return (s || "").trim();
}
/** Try to find one quote that matches `ref` against the four identifier
* fields. Returns the first hit (in priority order) or null. */
async function findExactMatch(
pb: ReturnType<typeof getPocketBase>,
ref: string
): Promise<{ quote: Quote; method: Exclude<QuoteMatchMethod, "no_match" | "company_customer_desc_price"> } | null> {
if (!ref) return null;
// Quote_number first (most common), then quote_reference (the customer's
// own PO ref we recorded when we issued), then rfq_number, then
// external_number.
for (const [field, method] of [
["quote_number", "exact_quote_number"],
["quote_reference", "exact_quote_reference"],
["rfq_number", "exact_rfq_number"],
["external_number", "exact_external_number"],
] as const) {
try {
const escaped = ref.replace(/"/g, '\\"');
const list = await pb.collection("quotes").getList<Quote>(1, 5, {
filter: `${field} = "${escaped}"`,
});
if (list.items.length > 0) {
return { quote: list.items[0], method };
}
} catch {
// Field might not exist in this PB schema (older deploys) —
// skip and try the next one.
}
}
return null;
}
/** Score & rank candidate quotes by company (+ customer) match. */
async function findFuzzyMatches(
pb: ReturnType<typeof getPocketBase>,
order: Order,
orderItems: OrderItem[]
): Promise<QuoteMatchCandidate[]> {
if (orderItems.length === 0) return []; // no signal — bail
// PB filter: company is required, customer narrows further when known.
const filters: string[] = [];
if (order.company) filters.push(`company = "${order.company}"`);
if (order.customer) filters.push(`(customer = "${order.customer}" || customer = "")`);
// Exclude templates — they aren't real orders.
filters.push("is_template != true");
const filter = filters.join(" && ");
const candidates = await pb.collection("quotes").getList<Quote>(1, FUZZY_CANDIDATE_CAP, {
filter,
sort: "-created",
expand: "customer",
});
if (candidates.items.length === 0) return [];
// Pull all candidate items via the chunked helper — same reason
// as in searchQuotesWithItems: a single huge OR-chain overflows
// PB's filter max length.
const qIds = candidates.items.map((q) => q.id);
const allItems = await fetchQuoteItemsForQuotes(qIds);
const itemsByQuote: Record<string, QuoteItem[]> = {};
for (const it of allItems) {
(itemsByQuote[it.quote] ||= []).push(it);
}
const projectedOrderItems = orderItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
const scored: QuoteMatchCandidate[] = [];
for (const q of candidates.items) {
const qItems = itemsByQuote[q.id] || [];
const projectedQuoteItems = qItems.map((it) => ({
description: it.description || "",
selling_price: Number(it.selling_price) || 0,
quantity: Number(it.quantity) || 1,
}));
// Rule-based scorer — uses company/customer relations and per-line
// signal matches. See `scoreOrderVsQuote` for the point budget.
const breakdown = scoreOrderVsQuote(
{
companyId: order.company,
customerId: order.customer,
items: projectedOrderItems,
},
{
company: q.company,
customer: q.customer,
items: projectedQuoteItems,
}
);
if (breakdown.confidence >= MATCH_CONFIDENCE_THRESHOLD) {
scored.push({
quote: q,
items: qItems,
confidence: breakdown.confidence,
breakdown,
method: "company_customer_desc_price",
});
}
}
// Sort: highest confidence first, then most recently created on tie.
// The PB query already returns candidates `-created`, but the local
// sort above only sees confidence — without a tie-breaker, an older
// quote can win over a fresher one when they score the same. Using
// `created` (not `updated`) so a no-op re-save of an old quote can't
// bump it past a genuinely newer competitor.
scored.sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return (b.quote.created || "").localeCompare(a.quote.created || "");
});
return scored;
}
/**
* Run the matcher against an order and return the best suggestion
* (or null) plus up to 5 alternatives. Doesn't persist anything —
* the caller decides whether to accept the suggestion.
/* ============================================================================
* Quote matcher — DELETED in Task 13 of the order↔quote linking plan.
*
* `pb` is optional: when omitted the matcher uses the client-side
* singleton from `@/lib/pocketbase` (the normal browser path).
* Pass an explicit `pb` to use a server-side or admin-authenticated
* instance — useful for server scripts, n8n workflows, and tests
* that don't have the browser's auth context.
*/
export async function findMatchingQuotesForOrder(
order: Order,
orderItems: OrderItem[],
pb?: ReturnType<typeof getPocketBase>
): Promise<QuoteMatchResult> {
const client = pb ?? getPocketBase();
const ref = normRef(order.quote_reference_text || "");
// 1-4: Exact-key lookups when the operator gave us a reference. This
// is a deterministic fast path — we run it before the fuzzy scan
// because the operator typed something specific and we should
// honor it first when it matches.
if (ref) {
const exact = await findExactMatch(client, ref);
if (exact) {
return {
best: {
quote: exact.quote,
items: [], // not pre-fetched — caller can lazy-load if they open it
confidence: 1.0,
method: exact.method,
},
alternatives: [],
method: exact.method,
};
}
}
// 5: Fuzzy fallback. Runs whether or not the operator supplied a
// reference — the rule-based scorer can find matches from company +
// customer + items + price alone, without needing a typed
// identifier. This is what makes "Re-match" work on a freshly
// loaded order that has nothing in `quote_reference_text`.
const fuzzy = await findFuzzyMatches(client, order, orderItems);
if (fuzzy.length === 0) {
return { best: null, alternatives: [], method: "no_match" };
}
return {
best: fuzzy[0],
alternatives: fuzzy.slice(1, 6),
method: "company_customer_desc_price",
};
}
/**
* Persist the matcher's verdict on the order record. Sets
* `quote_matching_attempted = true` so the UI can show "we tried but
* found nothing" distinctly from "we haven't tried yet".
* The browser-side matcher block (findMatchingQuotesForOrder,
* applyOrderQuoteMatch, clearOrderQuoteMatch, setOrderQuoteManually +
* the private normRef / findExactMatch / findFuzzyMatches helpers
* they used to depend on, plus the ScoreBreakdown / LineMatchBreakdown
* re-exports from order-quote-match) was deleted. The matcher moved
* to the server-side service (`src/lib/server/order-quote-link-service.ts`)
* so the route handler, the intake `after()` hook, and the on-demand
* re-check can all share one source of truth without needing the
* browser's PB singleton in scope.
*
* If `accept` is true AND a candidate was found, the candidate's
* quote is promoted from `potential_quote` to the hard-linked
* `quote` field. If false, only the suggestion is recorded.
*
* `pb` is optional: same convention as `findMatchingQuotesForOrder`.
*/
export async function applyOrderQuoteMatch(
orderId: string,
result: QuoteMatchResult,
accept: boolean,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
const patch: Record<string, unknown> = {
quote_matching_attempted: true,
quote_match_method: result.method,
};
if (result.best) {
patch.potential_quote = result.best.quote.id;
patch.potential_quote_confidence = result.best.confidence;
if (accept) {
patch.quote = result.best.quote.id;
}
} else {
// No suggestion — clear any stale soft-match.
patch.potential_quote = null;
patch.potential_quote_confidence = 0;
}
await client.collection("orders").update(orderId, patch);
}
/**
* Reset the matching verdict on an order so the operator can retry.
* Same `pb` convention as the other match helpers.
*/
export async function clearOrderQuoteMatch(
orderId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote_matching_attempted: false,
quote_match_method: null,
potential_quote: null,
potential_quote_confidence: 0,
});
}
/**
* Manually hard-link an order to a quote. Used by the QuoteMatchCard
* "Browse quotes…" affordance when the operator picks a quote from
* the search dropdown — there's no reference text to match against,
* and we don't want to fake a QuoteMatchResult just to round-trip
* through `applyOrderQuoteMatch`.
*
* Sets the same fields as an accepted auto-match (`quote`,
* `quote_matching_attempted`, `quote_match_method = "manual"`) but
* does NOT populate `potential_quote` — manual links bypass the
* suggestion stage and become the hard link directly.
*
* `pb` is optional for symmetry with the other match helpers — pass
* it in server-side to keep one auth path.
*/
export async function setOrderQuoteManually(
orderId: string,
quoteId: string,
pb?: ReturnType<typeof getPocketBase>
): Promise<void> {
const client = pb ?? getPocketBase();
await client.collection("orders").update(orderId, {
quote: quoteId,
quote_matching_attempted: true,
quote_match_method: "manual",
potential_quote: null,
potential_quote_confidence: 0,
});
}
// Re-export the pure scoring helpers so unit tests can import them
// without poking into the PB layer.
export {
tokenize,
jaccard,
priceScore,
scoreCandidate,
scoreOrderVsQuote,
RULE_POINTS,
MATCH_CONFIDENCE_THRESHOLD,
} from "./server/order-quote-match";
export type { ScoreBreakdown, LineMatchBreakdown } from "./server/order-quote-match";
* The page migrated to fetch against /api/v1/orders/{id}/quote-match
* and /api/v1/orders/{id}/quote as part of the same task. Tests that
* previously imported these helpers from `@/lib/pb-helpers` now
* import directly from `@/lib/server/order-quote-match` (pure) or
* `@/lib/server/order-quote-link-service` (PB-backed).
* ========================================================================== */
/**
* Fetch all attachments saved against a quote. Used by the