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:
parent
c8e977c61e
commit
40094be3f3
6 changed files with 354 additions and 25 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
152
src/lib/quote-assistant-save.test.ts
Normal file
152
src/lib/quote-assistant-save.test.ts
Normal 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: "" });
|
||||
});
|
||||
});
|
||||
59
src/lib/quote-assistant-save.ts
Normal file
59
src/lib/quote-assistant-save.ts
Normal 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 || "" };
|
||||
}
|
||||
Loading…
Reference in a new issue