feat(quote-assistant): purple AI lines on process and quote pages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 19:56:02 +02:00
parent c8e977c61e
commit 40094be3f3
6 changed files with 354 additions and 25 deletions

View file

@ -103,6 +103,10 @@ import QuoteEmailCard from "@/components/quote-email-card";
import RewriteQuoteModal from "@/components/rewrite-quote-modal";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { AiFillMarker } from "@/components/ai-fill-marker";
import { AiFillQuoteButton } from "@/components/ai-fill-quote-button";
import { aiRowClass } from "@/lib/quote-assistant-review";
import { reviewerFromAuthRecord, withAiReview } from "@/lib/quote-assistant-save";
import type { Quote, QuoteItem, QuoteAttachment, ItemIntel } from "@/lib/types";
import { buildDefaultQuoteEmailBody } from "@/lib/server/quote-email-template";
@ -1699,11 +1703,25 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
setPriceUpdateSaving(true);
try {
const pb = getPocketBase();
await pb.collection("quote_items").update(itemId, {
cost_price: cost,
markup,
selling_price: selling,
});
const patch = { cost_price: cost, markup, selling_price: selling };
// A pending AI-filled line gets its review recorded (and is marked
// processed once priced); normal lines are saved exactly as before.
const current = items.find((i) => i.id === itemId);
const payload = current
? withAiReview(
current,
patch,
{
cost_price: cost ?? 0,
selling_price: selling ?? 0,
supplier: current.supplier || null,
},
reviewerFromAuthRecord(pb.authStore.record),
new Date(),
{ markProcessedWhenPriced: true },
)
: patch;
await pb.collection("quote_items").update(itemId, payload);
toast.success("Price updated");
setPriceUpdateItem(null);
fetchQuote();
@ -2382,6 +2400,11 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
Open Full Quote
</Button>
)}
{/* "Let AI fill this quote" — only while the quote is still
open for pricing. */}
{!quote.is_template && ["new", "follow_up", "claimed"].includes(quote.status) && (
<AiFillQuoteButton quoteId={id} onDone={fetchQuote} />
)}
{renderTemplateActions()}
<MoreActionsMenu
isTemplate={!!quote.is_template}
@ -2677,7 +2700,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
{items.map((item) => {
const pct = itemMarkupPercent(item);
return (
<TableRow key={item.id}>
<TableRow key={item.id} className={aiRowClass(item)}>
<TableCell className="text-sm whitespace-pre-wrap break-words align-top">
<div className="flex items-start gap-1.5">
<AiItemHistoryTrigger
@ -2689,6 +2712,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
className="mt-0.5"
/>
<span>{item.description}</span>
<AiFillMarker item={item} className="ml-1" />
</div>
</TableCell>
<TableCell className="text-center text-sm align-top">{item.quantity}</TableCell>
@ -2732,7 +2756,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
{items.map((item) => {
const pct = itemMarkupPercent(item);
return (
<Card key={item.id} className="p-4">
<Card key={item.id} className={`p-4 ${aiRowClass(item)}`}>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium flex-1 whitespace-pre-wrap break-words">
<AiItemHistoryTrigger
@ -2744,6 +2768,7 @@ export default function QuoteDetailPage({ params }: { params: Promise<{ id: stri
className="-ml-1 mr-1 inline-flex align-middle"
/>
{item.description}
<AiFillMarker item={item} className="ml-1 inline-flex align-middle" />
</p>
<Badge variant={item.is_processed ? "default" : "secondary"} className="text-xs shrink-0">
{item.is_processed ? "Yes" : "No"}

View file

@ -52,6 +52,10 @@ import { ProcessItemAttachments } from "@/components/quotes/process-item-attachm
import { ProcessItemCard } from "@/components/quotes/process-item-card";
import { QuoteNoteCard } from "@/components/quotes/process-quote-note-card";
import { AiItemHistoryTrigger } from "@/components/ai-item-history-trigger";
import { AiFillMarker } from "@/components/ai-fill-marker";
import { AiFillQuoteButton } from "@/components/ai-fill-quote-button";
import { aiRowClass, isAiFillPending } from "@/lib/quote-assistant-review";
import { reviewerFromAuthRecord, withAiReview } from "@/lib/quote-assistant-save";
import { QuotedBeforePanel } from "@/components/quoted-before-panel";
import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types";
import { cn } from "@/lib/utils";
@ -372,7 +376,9 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
sp > 0 && candidate.cost_price > 0
? Math.round(((sp / candidate.cost_price) - 1) * 100 * 100) / 100
: 0;
const persistedSnapshot = {
const persistedSnapshot = withAiReview(
item,
{
description: candidate.description || item.description || "",
quote_reference: (item as any).quote_reference || "",
quantity: item.quantity || 1,
@ -386,7 +392,12 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
supplier: supplierId,
matched_price_list_id: candidate.id,
pricing_notes: `Auto-applied from picker — ${candidate.why}`,
};
},
{ cost_price: candidate.cost_price, selling_price: sp, supplier: supplierId },
reviewerFromAuthRecord(pb.authStore.record),
new Date(),
{ markProcessedWhenPriced: false },
);
await pb.collection("quote_items").update(itemId, persistedSnapshot);
@ -614,7 +625,9 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
const markup = item.cost_price && item.selling_price
? Number((((item.selling_price - item.cost_price) / item.cost_price) * 100).toFixed(2))
: 0;
const persistedSnapshot = {
const persistedSnapshot = withAiReview(
item,
{
description: item.description,
quote_reference: (item as any).quote_reference || "",
quantity: item.quantity || 1,
@ -630,14 +643,26 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
// variant. "Catalogue" is reserved for matches driven by
// the Check-pricing button (and the future email-intake
// automation) — never flip back to it on a human save.
pricing_source:
item.pricing_source === "catalogue" ? "catalogue" : "manual",
// Human saves map via nextPricingSourceOnSave inside
// withAiReview: "catalogue" and "ai_pos" survive, anything
// else becomes "manual". A pending AI line also gets its
// review recorded (ai_fill.reviewed_at).
pricing_source: "manual",
is_processed: !!(item.cost_price && item.selling_price),
// Flips to true the first time a human saves the item — gates
// the AI rewriter (server refuses to rewrite items where
// user_edited is still false).
user_edited: true,
};
},
{
cost_price: item.cost_price || 0,
selling_price: item.selling_price || 0,
supplier: item.supplier || null,
},
reviewerFromAuthRecord(pb.authStore.record),
new Date(),
{ markProcessedWhenPriced: false },
);
// PB needs `null` for an unset relation; local state holds it
// as `""` (the QuoteItem type's `supplier: string`). Mirror
// them explicitly so the PATCH and the setState values track
@ -665,7 +690,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
supplier: supplierForLocal,
is_processed: !!persistedSnapshot.is_processed,
user_edited: true,
pricing_source: persistedSnapshot.pricing_source,
pricing_source: persistedSnapshot.pricing_source as string,
}
: it,
),
@ -1175,6 +1200,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
<span className="font-medium">{formatCurrency(grand_total)}</span>
</div>
)}
<AiFillQuoteButton quoteId={id} onDone={fetchData} />
{items.length > 0 && (
<Button
size="sm"
@ -1285,16 +1311,20 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
// rows. Dark-mode: deeper green-tinted dark
// background so the row still reads as done
// (without looking like a grey smear on top
// of near-black).
item.is_processed
? "bg-success/10 dark:bg-success/15"
: ""
// of near-black). Lines awaiting review of an
// AI fill are purple instead.
isAiFillPending(item)
? aiRowClass(item)
: item.is_processed
? "bg-success/10 dark:bg-success/15"
: ""
}
>
<TableCell className="text-sm font-medium">
<div className="flex items-center gap-1.5">
<span>{idx + 1}</span>
<AiItemHistoryTrigger itemId={item.id} collection="quote_items" onApplied={() => router.refresh()} />
<AiFillMarker item={item} />
{item.is_processed && <CheckCircle className="h-3 w-3 text-success" />}
</div>
</TableCell>
@ -1374,7 +1404,8 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
before (red = worst, amber = missing
supplier / pricing, green = matched,
info = tagged) — just on their own line. */}
{(item.pricing_source === "catalogue" ||
{(isAiFillPending(item) ||
item.pricing_source === "catalogue" ||
item.pricing_source === "manual" ||
!item.description?.trim() ||
!item.supplier ||
@ -1386,6 +1417,15 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str
className="flex flex-wrap items-center gap-1 mt-1"
data-testid={`row-badges-${item.id}`}
>
{isAiFillPending(item) && (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-ai/30 bg-ai/10 text-ai"
title="Filled by AI from the POS - review before sending"
>
AI
</Badge>
)}
{!item.description?.trim() && (
<Badge
variant="outline"

View file

@ -165,6 +165,42 @@ describe("ProcessItemCard", () => {
expect(screen.queryByTestId("card-row-badges-qi-1")).not.toBeInTheDocument();
});
it("shows a purple card and an AI badge for a pending AI-filled line, and not once reviewed", () => {
const fill = {
status: "filled" as const,
stock_item_id: "s-1",
stock_item_name: "Set screw",
stock_item_sku: "SS",
cost_price: 8.16,
selling_price: 10.61,
markup: 30,
supplier_id: "sup-1",
confidence: "high" as const,
reason: "",
doubts: [],
candidates: [],
description_snapshot: "set screw",
model: "m",
attempted_at: "2026-10-16T00:00:00Z",
};
const { unmount } = renderCard({
item: { ...baseItem, pricing_source: "ai_pos", ai_fill: fill },
});
expect(screen.getByTestId("process-item-card-qi-1")).toHaveClass("bg-ai/10");
expect(within(screen.getByTestId("card-row-badges-qi-1")).getByText("AI")).toBeInTheDocument();
unmount();
renderCard({
item: {
...baseItem,
pricing_source: "ai_pos",
ai_fill: { ...fill, reviewed_at: "2026-10-16T01:00:00Z" },
},
});
expect(screen.getByTestId("process-item-card-qi-1")).not.toHaveClass("bg-ai/10");
expect(screen.queryByTestId("card-row-badges-qi-1")).not.toBeInTheDocument();
});
it("calls onUpdateField with the new value when the description textarea changes", async () => {
const onUpdateField = vi.fn(noop);
renderCard({ onUpdateField });

View file

@ -9,7 +9,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ProcessItemAttachments } from "@/components/quotes/process-item-attachments";
import { AiFillMarker } from "@/components/ai-fill-marker";
import { aiRowClass, isAiFillPending } from "@/lib/quote-assistant-review";
import type { QuoteItem } from "@/lib/types";
import { cn } from "@/lib/utils";
/**
* Card-per-item layout for the quote-process page on small screens.
@ -98,7 +101,9 @@ export function ProcessItemCard({
*/
const [confirmingDelete, setConfirmingDelete] = useState(false);
const aiPending = isAiFillPending(item);
const hasBadges =
aiPending ||
item.pricing_source === "catalogue" ||
item.pricing_source === "manual" ||
!item.description?.trim() ||
@ -113,13 +118,15 @@ export function ProcessItemCard({
return (
<article
className={
className={cn(
// Each card is a single rounded block; bg-success tint flags
// saved rows on light + dark.
item.is_processed
? "rounded-lg border bg-success/10 dark:bg-success/15 p-3 space-y-2.5"
: "rounded-lg border p-3 space-y-2.5"
}
// saved rows on light + dark. Lines awaiting review of an AI
// fill are purple instead.
"rounded-lg border p-3 space-y-2.5",
aiPending
? aiRowClass(item)
: item.is_processed && "bg-success/10 dark:bg-success/15",
)}
data-testid={`process-item-card-${item.id}`}
>
{/* Row 1: index + title */}
@ -141,6 +148,7 @@ export function ProcessItemCard({
<span className="italic text-muted-foreground">No description</span>
)}
</h3>
<AiFillMarker item={item} />
</header>
{/* Row 2: status badges — own line so they never overflow into
@ -151,6 +159,15 @@ export function ProcessItemCard({
className="flex flex-wrap items-center gap-1"
data-testid={`card-row-badges-${item.id}`}
>
{aiPending ? (
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] border-ai/30 bg-ai/10 text-ai"
title="Filled by AI from the POS - review before sending"
>
AI
</Badge>
) : null}
{!item.description?.trim() ? (
<Badge
variant="outline"

View file

@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import type { QuoteItem, QuoteItemAiFill } from "@/lib/types";
import { reviewerFromAuthRecord, withAiReview } from "./quote-assistant-save";
const NOW = new Date("2026-10-16T12:00:00.000Z");
const reviewer = { id: "u1", name: "Rep Rita" };
function fill(over: Partial<QuoteItemAiFill> = {}): QuoteItemAiFill {
return {
status: "filled",
stock_item_id: "s-1",
stock_item_name: "Bolt",
stock_item_sku: "B",
cost_price: 10,
selling_price: 15,
markup: 50,
supplier_id: "sup1",
confidence: "high",
reason: "",
doubts: [],
candidates: [],
description_snapshot: "bolt",
model: "m",
attempted_at: NOW.toISOString(),
...over,
};
}
const aiItem = { ai_fill: fill(), pricing_source: "ai_pos" } as unknown as QuoteItem;
const plainItem = { ai_fill: null, pricing_source: "" } as unknown as QuoteItem;
const saved = { cost_price: 10, selling_price: 15, supplier: "sup1" };
describe("withAiReview", () => {
it("keeps ai_pos and records the review on a pending AI line", () => {
const out = withAiReview(
aiItem,
{ cost_price: 10, selling_price: 15, pricing_source: "manual" },
saved,
reviewer,
NOW,
{ markProcessedWhenPriced: false },
);
expect(out.pricing_source).toBe("ai_pos");
const f = out.ai_fill as QuoteItemAiFill;
expect(f.reviewed_at).toBe(NOW.toISOString());
expect(f.reviewed_by).toBe("Rep Rita");
expect(f.accepted_unchanged).toBe(true);
expect(out.is_processed).toBeUndefined();
});
it("adds is_processed on a pending AI line when priced and asked to", () => {
const out = withAiReview(aiItem, { cost_price: 10, selling_price: 15 }, saved, reviewer, NOW, {
markProcessedWhenPriced: true,
});
expect(out.is_processed).toBe(true);
});
it("does not add is_processed on a pending AI line with a zero price", () => {
const out = withAiReview(
aiItem,
{ cost_price: 10, selling_price: 0 },
{ ...saved, selling_price: 0 },
reviewer,
NOW,
{ markProcessedWhenPriced: true },
);
expect(out.is_processed).toBeUndefined();
});
it("never adds is_processed or ai_fill to a non-AI line", () => {
const patch = { cost_price: 10, selling_price: 15 };
const out = withAiReview(plainItem, patch, saved, reviewer, NOW, {
markProcessedWhenPriced: true,
});
expect(out).toEqual(patch);
expect("is_processed" in out).toBe(false);
expect("ai_fill" in out).toBe(false);
});
it("maps pricing_source through nextPricingSourceOnSave on a non-AI line", () => {
const out = withAiReview(
plainItem,
{ cost_price: 1, pricing_source: "manual" },
saved,
reviewer,
NOW,
{ markProcessedWhenPriced: false },
);
expect(out.pricing_source).toBe("manual");
const cat = withAiReview(
{ ...plainItem, pricing_source: "catalogue" } as QuoteItem,
{ pricing_source: "manual" },
saved,
reviewer,
NOW,
{ markProcessedWhenPriced: false },
);
expect(cat.pricing_source).toBe("catalogue");
});
it("does not add pricing_source when the patch has none and the line is not AI", () => {
const out = withAiReview(plainItem, { cost_price: 1 }, saved, reviewer, NOW, {
markProcessedWhenPriced: false,
});
expect("pricing_source" in out).toBe(false);
});
it("keeps an explicit catalogue source but still records the review", () => {
const out = withAiReview(
aiItem,
{ pricing_source: "catalogue", cost_price: 12 },
{ ...saved, cost_price: 12 },
reviewer,
NOW,
{ markProcessedWhenPriced: false },
);
expect(out.pricing_source).toBe("catalogue");
expect((out.ai_fill as QuoteItemAiFill).accepted_unchanged).toBe(false);
});
it("does not touch a reviewed AI line", () => {
const reviewed = {
ai_fill: fill({ reviewed_at: NOW.toISOString() }),
pricing_source: "ai_pos",
} as unknown as QuoteItem;
const out = withAiReview(reviewed, { cost_price: 1 }, saved, reviewer, NOW, {
markProcessedWhenPriced: true,
});
expect(out).toEqual({ cost_price: 1 });
});
it("never removes keys from the patch and does not mutate it", () => {
const patch = { a: 1, b: "x", pricing_source: "manual" };
const copy = { ...patch };
const out = withAiReview(aiItem, patch, saved, reviewer, NOW, {
markProcessedWhenPriced: true,
});
for (const k of Object.keys(copy)) expect(k in out).toBe(true);
expect(patch).toEqual(copy);
});
});
describe("reviewerFromAuthRecord", () => {
it("prefers name, falls back to email, then empty", () => {
expect(reviewerFromAuthRecord({ id: "u1", name: "Rita", email: "r@x.co" })).toEqual({
id: "u1",
name: "Rita",
});
expect(reviewerFromAuthRecord({ id: "u1", name: "", email: "r@x.co" }).name).toBe("r@x.co");
expect(reviewerFromAuthRecord(null)).toEqual({ id: undefined, name: "" });
});
});

View file

@ -0,0 +1,59 @@
/**
* quote-assistant-save — the save-time merge both quote pages use so a
* rep's save of an AI-filled line records the review.
*
* Client-safe (types + `quote-assistant-review` only). Kept out of the
* pages so it can be unit-tested.
*/
import {
buildAiReviewPatch,
isAiFillPending,
nextPricingSourceOnSave,
} from "@/lib/quote-assistant-review";
import type { QuoteItem } from "@/lib/types";
/**
* Returns `patch` plus the AI-review bookkeeping for `item`:
*
* - `pricing_source`: only touched when the patch already carries one.
* An explicit "catalogue" is kept; anything else maps through
* `nextPricingSourceOnSave(item.pricing_source)` (so "ai_pos" survives
* a human save and a stale page's save becomes "manual").
* - `ai_fill`: the reviewed copy, only for a pending AI line.
* - `is_processed: true`: only for a pending AI line, only when
* `markProcessedWhenPriced` and both prices are above 0. Normal lines
* never get it from here.
*
* Never removes keys from `patch` and never mutates it.
*/
export function withAiReview(
item: QuoteItem,
patch: Record<string, unknown>,
saved: { cost_price: number; selling_price: number; supplier: string | null },
reviewer: { id?: string; name: string },
now: Date,
opts: { markProcessedWhenPriced: boolean },
): Record<string, unknown> {
const out: Record<string, unknown> = { ...patch };
if ("pricing_source" in patch && patch.pricing_source !== "catalogue") {
out.pricing_source = nextPricingSourceOnSave(item.pricing_source);
}
if (!isAiFillPending(item)) return out;
Object.assign(out, buildAiReviewPatch(item, saved, reviewer, now));
if (opts.markProcessedWhenPriced && saved.cost_price > 0 && saved.selling_price > 0) {
out.is_processed = true;
}
return out;
}
/** `{ id, name }` for the signed-in PB user (`authStore.record`). */
export function reviewerFromAuthRecord(
record: { id?: string; name?: unknown; email?: unknown } | null | undefined,
): { id?: string; name: string } {
const name = typeof record?.name === "string" ? record.name : "";
const email = typeof record?.email === "string" ? record.email : "";
return { id: record?.id, name: name || email || "" };
}