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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-16 23:29:04 +02:00
parent a9160de1d6
commit 4e0f7a5c5c
3 changed files with 163 additions and 1 deletions

View file

@ -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<Quote | null>(null);
const [items, setItems] = useState<QuoteItem[]>([]);
// 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<QuoteItem[]>([]);
const itemsRef = useRef<QuoteItem[]>([]);
useEffect(() => {
itemsRef.current = items;
}, [items]);
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
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
<span className="font-medium">{formatCurrency(grand_total)}</span>
</div>
)}
<AiFillQuoteButton quoteId={id} onDone={fetchData} />
<AiFillQuoteButton quoteId={id} onDone={reloadItemsAfterAiRun} />
{items.length > 0 && (
<Button
size="sm"

View file

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { mergeItemsAfterAiRun } from "./quote-assistant-merge";
type Row = { id: string; description: string; cost_price: number; ai_fill?: unknown };
const row = (id: string, over: Partial<Row> = {}): Row => ({
id,
description: `item ${id}`,
cost_price: 0,
...over,
});
describe("mergeItemsAfterAiRun", () => {
it("takes the server row for lines the rep has not touched", () => {
const baseline = [row("a"), row("b")];
const local = [row("a"), row("b")];
const fresh = [row("a", { cost_price: 10, ai_fill: { status: "filled" } }), row("b")];
const out = mergeItemsAfterAiRun(local, baseline, fresh);
expect(out.items).toEqual(fresh);
expect(out.keptDirty).toEqual([]);
});
it("keeps a row with unsaved typing as the rep left it", () => {
const baseline = [row("a"), row("b")];
const local = [row("a", { description: "rep is typing" }), row("b")];
const fresh = [
row("a", { cost_price: 10, ai_fill: { status: "filled" } }),
row("b", { cost_price: 7, ai_fill: { status: "filled" } }),
];
const out = mergeItemsAfterAiRun(local, baseline, fresh);
expect(out.items[0]).toEqual(local[0]);
expect(out.items[1]).toEqual(fresh[1]);
expect(out.keptDirty).toEqual(["a"]);
});
it("follows the server's row set and order: new rows appear, deleted rows go", () => {
const baseline = [row("a"), row("b")];
const local = [row("a"), row("b")];
const fresh = [row("c"), row("a")];
const out = mergeItemsAfterAiRun(local, baseline, fresh);
expect(out.items.map((r) => r.id)).toEqual(["c", "a"]);
});
it("treats a local row with no baseline as dirty (added locally, never loaded)", () => {
const local = [row("a", { description: "x" })];
const fresh = [row("a")];
const out = mergeItemsAfterAiRun(local, [], fresh);
expect(out.items[0]).toEqual(local[0]);
expect(out.keptDirty).toEqual(["a"]);
});
it("compares values, not object identity or key order", () => {
const baseline = [{ id: "a", cost_price: 0, description: "d", ai_fill: { status: "no_match" } }];
const local = [{ description: "d", id: "a", ai_fill: { status: "no_match" }, cost_price: 0 }];
const fresh = [{ id: "a", cost_price: 5, description: "d", ai_fill: { status: "filled" } }];
const out = mergeItemsAfterAiRun(local, baseline, fresh);
expect(out.items).toEqual(fresh);
});
});

View file

@ -0,0 +1,63 @@
/**
* quote-assistant-merge — bring AI-filled lines into a page that may hold
* unsaved typing.
*
* The Process page edits quote lines in local state and saves them one row
* at a time. After "Let AI fill this quote" the server rows have changed,
* but a blanket reload would wipe whatever the rep typed and hasn't saved.
*
* A row is "dirty" when its local copy differs from the copy last loaded
* from the server (the baseline). Comparing against the baseline — rather
* than flagging every place the page mutates a row — means a missed edit
* path can only ever make a row look dirty (kept as the rep left it),
* never make unsaved typing look clean (and be overwritten). A row the
* rep saved but whose baseline is stale is kept too, which is harmless:
* the AI never writes a saved line.
*/
/** JSON with object keys sorted, so key order doesn't count as a change. */
function stableStringify(value: unknown): string {
return JSON.stringify(value, (_key, v: unknown) => {
if (v && typeof v === "object" && !Array.isArray(v)) {
return Object.fromEntries(
Object.keys(v as Record<string, unknown>)
.sort()
.map((k) => [k, (v as Record<string, unknown>)[k]]),
);
}
return v;
});
}
/**
* Merge freshly loaded server rows into the local rows.
*
* - The result follows `fresh` (server order; new rows appear, rows deleted
* on the server go).
* - A row whose local copy is unchanged from `baseline` takes the server
* copy; a dirty row keeps its local copy.
*
* `keptDirty` lists the ids kept as typed, for a hint to the user. The new
* baseline is simply `fresh`.
*/
export function mergeItemsAfterAiRun<T extends { id: string }>(
local: readonly T[],
baseline: readonly T[],
fresh: readonly T[],
): { items: T[]; keptDirty: string[] } {
const localById = new Map(local.map((r) => [r.id, r]));
const baseById = new Map(baseline.map((r) => [r.id, r]));
const keptDirty: string[] = [];
const items = fresh.map((serverRow) => {
const mine = localById.get(serverRow.id);
if (!mine) return serverRow;
const base = baseById.get(serverRow.id);
const dirty = !base || stableStringify(mine) !== stableStringify(base);
if (!dirty) return serverRow;
keptDirty.push(serverRow.id);
return mine;
});
return { items, keptDirty };
}