WFDASH-WEBAPP/src/lib/quote-assistant-review.ts
Dave 6604e8b837 fix(quote-assistant): review minors (templates, forced order, bounds, UI)
- M1: fillQuote and the tick refuse templates ("quote is not open
  (template)"); both quote pages gate the fill button with
  canAiFillQuote (new/follow_up/claimed, not a template).
- M4: run summary keeps <= 20 errors of <= 300 chars (boundRunErrors).
- M5: forced runs offer never-tried lines before tried ones.
- M6: counts route 400s on ids that aren't PB record ids.
- M7: a fill never replaces a supplier already on the line.
- M8: warn dot on the AI marker when a pending fill has doubts.
- M9: MCP hint states the real eligibility rule.
- M10: detail-page price dialog re-reads the line before recording an
  AI review.
- M11: exported QUOTE_NOT_OPEN_ERROR_PREFIX / AI_DISABLED_ERROR used by
  the run route.
- M14: prompt textarea maxLength 50000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:41:28 +02:00

78 lines
2.6 KiB
TypeScript

/**
* quote-assistant-review — client-safe helpers for AI-filled quote lines.
*
* Imports only types, so Process-page components can use it directly.
*
* A line is "pending review" only when the AI filled it, nobody has
* reviewed it, AND `pricing_source` is still "ai_pos". A rep who saves a
* line from a stale page flips `pricing_source` to "manual"
* (`nextPricingSourceOnSave`), so their values win and the line stops
* being highlighted.
*/
import type { QuoteItem, QuoteItemAiFill } from "@/lib/types";
type AiLine = Pick<QuoteItem, "ai_fill" | "pricing_source">;
/**
* Statuses the assistant may fill (mirrors ELIGIBLE_QUOTE_STATUSES in the
* server-only quote-assistant-core).
*/
const AI_FILLABLE_STATUSES: readonly string[] = ["new", "follow_up", "claimed"];
/** Show "Let AI fill this quote"? Open status and not a template. */
export function canAiFillQuote(
quote: { status?: string; is_template?: boolean } | null | undefined,
): boolean {
return !!quote && !quote.is_template && AI_FILLABLE_STATUSES.includes(quote.status ?? "");
}
export function isAiFillPending(item: AiLine): boolean {
const fill = item.ai_fill;
return (
!!fill &&
fill.status === "filled" &&
!fill.reviewed_at &&
item.pricing_source === "ai_pos"
);
}
/** What `pricing_source` a human save should write, given the loaded value. */
export function nextPricingSourceOnSave(
current: string | undefined,
): "catalogue" | "ai_pos" | "manual" {
if (current === "catalogue" || current === "ai_pos") return current;
return "manual";
}
const sameMoney = (a: number, b: number) =>
Math.round((Number(a) || 0) * 100) === Math.round((Number(b) || 0) * 100);
/**
* The `ai_fill` update recording that a person reviewed a pending AI fill.
* Returns `{}` for a line that isn't pending (nothing to record).
*/
export function buildAiReviewPatch(
item: AiLine,
saved: { cost_price: number; selling_price: number; supplier: string | null },
reviewer: { id?: string; name: string },
now: Date,
): { ai_fill?: QuoteItemAiFill } {
if (!isAiFillPending(item) || !item.ai_fill) return {};
const fill = item.ai_fill;
return {
ai_fill: {
...fill,
reviewed_at: now.toISOString(),
reviewed_by: reviewer.name,
accepted_unchanged:
sameMoney(saved.cost_price, fill.cost_price) &&
sameMoney(saved.selling_price, fill.selling_price),
supplier_changed: (saved.supplier ?? "") !== (fill.supplier_id ?? ""),
},
};
}
/** Row highlight for lines awaiting human review of an AI fill. */
export function aiRowClass(item: AiLine): string {
return isAiFillPending(item) ? "bg-ai/10" : "";
}