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>
63 lines
2.3 KiB
TypeScript
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 };
|
|
}
|