diff --git a/src/app/(app)/collection-confirm/page.tsx b/src/app/(app)/collection-confirm/page.tsx index 92fd52b..69f4b54 100644 --- a/src/app/(app)/collection-confirm/page.tsx +++ b/src/app/(app)/collection-confirm/page.tsx @@ -22,6 +22,7 @@ import { buildConfirmPatch, buildReturnPatch, canConfirmRow, + formatNoteBody, groupRowsByPurchaseOrder, parseNotesFeed, readInvoiceDetails, @@ -1412,7 +1413,7 @@ function ConfirmCard({ · {categoryLabel(entry.category)} -

{entry.body}

+

{formatNoteBody(entry.body)}

))} diff --git a/src/app/(app)/collection-confirm/view-model.test.ts b/src/app/(app)/collection-confirm/view-model.test.ts index a6bbffb..014afd4 100644 --- a/src/app/(app)/collection-confirm/view-model.test.ts +++ b/src/app/(app)/collection-confirm/view-model.test.ts @@ -410,6 +410,16 @@ describe("appendCostNote", () => { ]); expect(appendCostNote(existing, { id: "u-1", name: "sj" }, 10, 10)).toBe(existing); }); + + it("survives a parseNotesFeed round-trip with the prev/new object body intact", () => { + const stored = appendCostNote("", { id: "u-1", name: "sj" }, 45, 50); + const parsed = parseNotesFeed(stored, { id: "row-1", drv_collected_at: null }); + expect(parsed).toHaveLength(1); + expect(parsed[0]).toMatchObject({ + category: "cost", + body: { prevCost: 45, newCost: 50 }, + }); + }); }); describe("dedupeAttachments", () => { @@ -443,4 +453,9 @@ describe("dedupeAttachments", () => { expect(dedupeAttachments([{ id: "a" }, { id: "b" }])).toEqual([]); expect(dedupeAttachments([])).toEqual([]); }); + + it("does not push the same item id twice when a row lists the same filename twice", () => { + const out = dedupeAttachments([{ id: "a", order_item_attachements: ["INV.pdf", "INV.pdf"] }]); + expect(out).toEqual([{ filename: "INV.pdf", itemIds: ["a"] }]); + }); }); \ No newline at end of file diff --git a/src/app/(app)/collection-confirm/view-model.ts b/src/app/(app)/collection-confirm/view-model.ts index 72092a9..e2376fc 100644 --- a/src/app/(app)/collection-confirm/view-model.ts +++ b/src/app/(app)/collection-confirm/view-model.ts @@ -1,11 +1,13 @@ export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm" | "cost"; +export type NoteBody = string | { prevCost?: number; newCost?: number; [k: string]: unknown }; + export type NoteEntry = { ts: string; actorId: string; actorName: string; category: NoteCategory; - body: string; + body: NoteBody; }; export type ConfirmActor = { @@ -63,11 +65,14 @@ const NOTE_CATEGORIES: NoteCategory[] = ["collection", "note", "problem", "quant function isNoteEntry(value: unknown): value is NoteEntry { if (!value || typeof value !== "object") return false; const candidate = value as Record; + const bodyOk = + typeof candidate.body === "string" || + (candidate.body !== null && typeof candidate.body === "object"); return ( typeof candidate.ts === "string" && typeof candidate.actorId === "string" && typeof candidate.actorName === "string" && - typeof candidate.body === "string" && + bodyOk && typeof candidate.category === "string" && NOTE_CATEGORIES.includes(candidate.category as NoteCategory) ); @@ -285,8 +290,10 @@ export function dedupeAttachments( ): DedupedAttachment[] { const byFilename = new Map(); for (const row of rows) { - const list = row.order_item_attachements ?? []; - for (const filename of list) { + const seen = new Set(); + for (const filename of row.order_item_attachements ?? []) { + if (seen.has(filename)) continue; + seen.add(filename); const bucket = byFilename.get(filename) ?? []; if (row.id) bucket.push(row.id); byFilename.set(filename, bucket); @@ -294,3 +301,24 @@ export function dedupeAttachments( } return Array.from(byFilename.entries()).map(([filename, itemIds]) => ({ filename, itemIds })); } + +/** + * Render a `NoteBody` as a human-readable string. Most categories + * (`collection`, `note`, `problem`, `quantity`, `confirm`) carry a + * plain string body; `cost` carries `{ prevCost, newCost }`. The UI + * just needs a string it can drop into a `

` tag. + */ +export function formatNoteBody(body: NoteBody): string { + if (typeof body === "string") return body; + if (body && typeof body === "object") { + if ("prevCost" in body || "newCost" in body) { + const prev = (body as { prevCost?: number }).prevCost; + const next = (body as { newCost?: number }).newCost; + if (typeof prev === "number" && typeof next === "number") { + return `Cost changed: ${prev} → ${next}`; + } + } + return JSON.stringify(body); + } + return ""; +}