- 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>
78 lines
2.6 KiB
TypeScript
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" : "";
|
|
}
|