From 4e0f7a5c5c2a92e6aed794cb7f26e0a7858addc4 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 16 Oct 2026 23:29:04 +0200 Subject: [PATCH] fix(quote-assistant): AI fill on Process page keeps unsaved typing After "Let AI fill this quote" the page re-pulls the quote's lines and merges them with mergeItemsAfterAiRun: rows unchanged since the last load take the server copy (so fills show), rows with unsaved changes stay as typed, with a toast saying how many were kept. Previously onDone ran fetchData, which replaced every row. Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/quotes/[id]/process/page.tsx | 35 +++++++++++- src/lib/quote-assistant-merge.test.ts | 66 ++++++++++++++++++++++ src/lib/quote-assistant-merge.ts | 63 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/lib/quote-assistant-merge.test.ts create mode 100644 src/lib/quote-assistant-merge.ts diff --git a/src/app/(app)/quotes/[id]/process/page.tsx b/src/app/(app)/quotes/[id]/process/page.tsx index d3de572..c46d0f1 100644 --- a/src/app/(app)/quotes/[id]/process/page.tsx +++ b/src/app/(app)/quotes/[id]/process/page.tsx @@ -56,6 +56,7 @@ 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 { mergeItemsAfterAiRun } from "@/lib/quote-assistant-merge"; import { QuotedBeforePanel } from "@/components/quoted-before-panel"; import type { Quote, QuoteItem, QuoteAttachment, Supplier, Customer } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -66,6 +67,14 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str const router = useRouter(); const [quote, setQuote] = useState(null); const [items, setItems] = useState([]); + // Lines as last loaded from PB, and a mirror of the live local rows, so + // an AI run can refresh lines without wiping unsaved typing (see + // reloadItemsAfterAiRun). + const itemsBaselineRef = useRef([]); + const itemsRef = useRef([]); + useEffect(() => { + itemsRef.current = items; + }, [items]); const [suppliers, setSuppliers] = useState([]); const [customers, setCustomers] = useState([]); const [loading, setLoading] = useState(true); @@ -553,6 +562,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str ); } setQuote(q); + itemsBaselineRef.current = i; setItems(i); setSuppliers(s); setCustomers(c); @@ -568,6 +578,29 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str useEffect(() => { fetchData(); }, [fetchData]); + /** + * After "Let AI fill this quote": re-pull this quote's lines but keep + * every row the rep has unsaved changes in exactly as typed (a full + * fetchData() would wipe them). Rows untouched since the last load take + * the server copy, so the AI's fills show up. + */ + const reloadItemsAfterAiRun = useCallback(async () => { + try { + const fresh = await getQuoteItems(id); + const baseline = itemsBaselineRef.current; + itemsBaselineRef.current = fresh; + const kept = mergeItemsAfterAiRun(itemsRef.current, baseline, fresh).keptDirty.length; + setItems((prev) => mergeItemsAfterAiRun(prev, baseline, fresh).items); + if (kept > 0) { + toast.info( + `${kept} line${kept === 1 ? "" : "s"} with unsaved changes left as you typed. Save, then refresh to see any AI result there.`, + ); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to reload lines"); + } + }, [id]); + /** * Sync the `isNoQuote` toggle once per loaded quote id. The local * state is otherwise sealed at mount time, when `quote` is still @@ -1200,7 +1233,7 @@ export default function QuoteProcessPage({ params }: { params: Promise<{ id: str {formatCurrency(grand_total)} )} - + {items.length > 0 && (