fix(collection-confirm): widen NoteEntry.body and dedup attachments within row

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-02 23:52:21 +02:00
parent 7e1f22f363
commit 529e08eb2a
3 changed files with 49 additions and 5 deletions

View file

@ -22,6 +22,7 @@ import {
buildConfirmPatch,
buildReturnPatch,
canConfirmRow,
formatNoteBody,
groupRowsByPurchaseOrder,
parseNotesFeed,
readInvoiceDetails,
@ -1412,7 +1413,7 @@ function ConfirmCard({
<span>·</span>
<span className="font-semibold uppercase tracking-wide">{categoryLabel(entry.category)}</span>
</div>
<p className="mt-1 text-foreground">{entry.body}</p>
<p className="mt-1 text-foreground">{formatNoteBody(entry.body)}</p>
</li>
))}
</ul>

View file

@ -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"] }]);
});
});

View file

@ -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<string, unknown>;
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<string, string[]>();
for (const row of rows) {
const list = row.order_item_attachements ?? [];
for (const filename of list) {
const seen = new Set<string>();
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 `<p>` 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 "";
}