diff --git a/src/app/(app)/collection-confirm/view-model.test.ts b/src/app/(app)/collection-confirm/view-model.test.ts index d2c81a9..a6bbffb 100644 --- a/src/app/(app)/collection-confirm/view-model.test.ts +++ b/src/app/(app)/collection-confirm/view-model.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vitest"; import { appendConfirmingNote, appendNote, + appendCostNote, + dedupeAttachments, buildConfirmNote, buildConfirmPatch, buildReturnPatch, @@ -363,4 +365,82 @@ describe("buildReturnPatch", () => { "Old", ]); }); +}); + +describe("appendCostNote", () => { + it("writes a category=cost note with prev/new values and the actor", () => { + const result = JSON.parse( + appendCostNote("", { id: "u-1", name: "sj" }, 45, 50), + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + actorId: "u-1", + actorName: "sj", + category: "cost", + body: { prevCost: 45, newCost: 50 }, + }); + expect(result[0].ts).toEqual(expect.any(String)); + }); + + it("preserves existing notes when appending", () => { + const existing = JSON.stringify([ + { + ts: "2026-07-01T00:00:00Z", + actorId: "u-1", + actorName: "sj", + category: "confirm", + body: "Confirmed for invoicing — INV-1", + }, + ]); + const result = JSON.parse(appendCostNote(existing, { id: "u-2", name: "ab" }, 10, 12)); + expect(result).toHaveLength(2); + expect(result[0].category).toBe("confirm"); + expect(result[1].category).toBe("cost"); + }); + + it("returns the original JSON when prevCost === newCost (no-op)", () => { + const existing = JSON.stringify([ + { + ts: "2026-07-01T00:00:00Z", + actorId: "u-1", + actorName: "sj", + category: "note", + body: "hello", + }, + ]); + expect(appendCostNote(existing, { id: "u-1", name: "sj" }, 10, 10)).toBe(existing); + }); +}); + +describe("dedupeAttachments", () => { + it("returns one entry per distinct filename across rows", () => { + const rows = [ + { order_item_attachements: ["INV-001.pdf", "DN-7.pdf"] }, + { order_item_attachements: ["INV-002.pdf"] }, + ]; + const out = dedupeAttachments(rows); + expect(out).toEqual([ + { filename: "INV-001.pdf", itemIds: [] }, + { filename: "DN-7.pdf", itemIds: [] }, + { filename: "INV-002.pdf", itemIds: [] }, + ]); + }); + + it("groups duplicate filenames with the list of source item ids", () => { + const rows = [ + { id: "a", order_item_attachements: ["INV-001.pdf"] }, + { id: "b", order_item_attachements: ["INV-001.pdf", "DN-7.pdf"] }, + { id: "c", order_item_attachements: ["DN-7.pdf"] }, + ]; + const out = dedupeAttachments(rows); + expect(out).toEqual([ + { filename: "INV-001.pdf", itemIds: ["a", "b"] }, + { filename: "DN-7.pdf", itemIds: ["b", "c"] }, + ]); + }); + + it("returns an empty array when no rows have attachments", () => { + expect(dedupeAttachments([{ id: "a" }, { id: "b" }])).toEqual([]); + expect(dedupeAttachments([])).toEqual([]); + }); }); \ 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 a71972e..72092a9 100644 --- a/src/app/(app)/collection-confirm/view-model.ts +++ b/src/app/(app)/collection-confirm/view-model.ts @@ -1,4 +1,4 @@ -export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm"; +export type NoteCategory = "collection" | "note" | "problem" | "quantity" | "confirm" | "cost"; export type NoteEntry = { ts: string; @@ -58,7 +58,7 @@ export type PurchaseOrderGroup = { rows: T[]; }; -const NOTE_CATEGORIES: NoteCategory[] = ["collection", "note", "problem", "quantity", "confirm"]; +const NOTE_CATEGORIES: NoteCategory[] = ["collection", "note", "problem", "quantity", "confirm", "cost"]; function isNoteEntry(value: unknown): value is NoteEntry { if (!value || typeof value !== "object") return false; @@ -258,3 +258,39 @@ export function buildReturnPatch(input: ReturnPatchInput): ReturnPatch { notes, }; } + +export function appendCostNote( + rawNotes: string | null | undefined, + actor: ConfirmActor, + prevCost: number, + newCost: number, +): string { + if (prevCost === newCost) return rawNotes ?? ""; + const existing = parseStoredNotes(rawNotes ?? ""); + const entry = { + id: `cost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ts: new Date().toISOString(), + actorId: actor.id, + actorName: actor.name, + category: "cost" as const, + body: { prevCost, newCost }, + }; + return JSON.stringify([...existing, entry]); +} + +export type DedupedAttachment = { filename: string; itemIds: string[] }; + +export function dedupeAttachments( + rows: Array<{ id?: string; order_item_attachements?: string[] | null }>, +): DedupedAttachment[] { + const byFilename = new Map(); + for (const row of rows) { + const list = row.order_item_attachements ?? []; + for (const filename of list) { + const bucket = byFilename.get(filename) ?? []; + if (row.id) bucket.push(row.id); + byFilename.set(filename, bucket); + } + } + return Array.from(byFilename.entries()).map(([filename, itemIds]) => ({ filename, itemIds })); +}