WFDASH-WEBAPP/src/lib/quote-assistant-merge.ts
Dave 4e0f7a5c5c 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>
2026-10-16 23:29:04 +02:00

63 lines
2.3 KiB
TypeScript

/**
* 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 };
}