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:
parent
a9160de1d6
commit
4e0f7a5c5c
3 changed files with 163 additions and 1 deletions
|
|
@ -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"
|
||||
|
|
|
|||
66
src/lib/quote-assistant-merge.test.ts
Normal file
66
src/lib/quote-assistant-merge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
src/lib/quote-assistant-merge.ts
Normal file
63
src/lib/quote-assistant-merge.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue