From 4e6175153dd3d29a61c4c3907d00d6d31a6fe768 Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Fri, 19 Jun 2026 04:43:44 +0530 Subject: [PATCH] fix(po): show all attachments grouped by type on PO details All PO attachments are stored as PODocument rows whose lifecycle stage (submission vs delivery) is encoded in the storageKey prefix. The PO details screen previously listed them in a single flat "Attachments" block, giving no indication of which were submission documents (invoice, quotation) versus delivery receipts. Add lib/attachments.ts to derive a user-facing group from the storageKey prefix (submission / payment / delivery / other) and render each non-empty group as a labelled subsection on the PO details screen, in lifecycle order. Unknown prefixes fall back to an "Other" group so nothing is ever hidden. Fixes #10 --- App/components/po/po-detail.tsx | 58 +++++++++++------- App/lib/attachments.ts | 96 ++++++++++++++++++++++++++++++ App/tests/unit/attachments.test.ts | 67 +++++++++++++++++++++ 3 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 App/lib/attachments.ts create mode 100644 App/tests/unit/attachments.test.ts diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 21288b0..9ed173b 100644 --- a/App/components/po/po-detail.tsx +++ b/App/components/po/po-detail.tsx @@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button"; import { SubmitDraftButton } from "@/components/po/submit-draft-button"; import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"; import { generateDownloadUrl } from "@/lib/storage"; +import { groupAttachments } from "@/lib/attachments"; import { TC_FIXED_LINE } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po"; import type { Role } from "@prisma/client"; @@ -149,9 +150,13 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals ? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough." : "Line items were amended by manager. Current values shown; original values shown with strikethrough."; - const downloadUrls = await Promise.all( - po.documents.map((doc) => generateDownloadUrl(doc.storageKey)) + const docsWithUrls = await Promise.all( + po.documents.map(async (doc) => ({ + ...doc, + url: await generateDownloadUrl(doc.storageKey), + })) ); + const attachmentGroups = groupAttachments(docsWithUrls); const canConfirmReceipt = (po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") && @@ -399,27 +404,40 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals )} - {/* Documents */} - {po.documents.length > 0 && ( + {/* Documents — grouped by lifecycle stage (submission / payment / delivery) */} + {attachmentGroups.length > 0 && (

Attachments

- +
)} diff --git a/App/lib/attachments.ts b/App/lib/attachments.ts new file mode 100644 index 0000000..b553ce5 --- /dev/null +++ b/App/lib/attachments.ts @@ -0,0 +1,96 @@ +/** + * Attachment grouping. + * + * All PO attachments are stored as `PODocument` rows. The lifecycle stage an + * attachment belongs to is encoded in the leading segment of its `storageKey` + * (see `buildStorageKey` in `lib/storage.ts`), e.g. `po-document//...` + * or `receipt//...`. This module derives a user-facing grouping from + * that prefix so the PO details screen can show every attachment grouped by + * type (submission, payment, delivery). + */ + +export type AttachmentGroupKey = "submission" | "payment" | "delivery" | "other"; + +export interface AttachmentGroupMeta { + key: AttachmentGroupKey; + label: string; + description: string; +} + +/** Display order for attachment groups (lifecycle order). */ +export const ATTACHMENT_GROUP_ORDER: AttachmentGroupKey[] = [ + "submission", + "payment", + "delivery", + "other", +]; + +export const ATTACHMENT_GROUP_META: Record = { + submission: { + key: "submission", + label: "Submission documents", + description: "Uploaded with the purchase order (e.g. invoice, quotation).", + }, + payment: { + key: "payment", + label: "Payment documents", + description: "Uploaded at payment (e.g. payment proof).", + }, + delivery: { + key: "delivery", + label: "Delivery receipts", + description: "Uploaded at delivery confirmation (e.g. delivery receipt).", + }, + other: { + key: "other", + label: "Other attachments", + description: "", + }, +}; + +/** + * Derive the lifecycle group of an attachment from its storage key prefix. + * Unknown prefixes fall back to "other" so nothing is ever hidden. + */ +export function categorizeAttachment(storageKey: string): AttachmentGroupKey { + const prefix = storageKey.split("/")[0]; + switch (prefix) { + case "po-document": + return "submission"; + case "payment-document": + case "payment": + return "payment"; + case "receipt": + return "delivery"; + default: + return "other"; + } +} + +export interface AttachmentGroup { + meta: AttachmentGroupMeta; + items: T[]; +} + +/** + * Group attachments by lifecycle stage, returning only non-empty groups in + * canonical lifecycle order. Item order within each group is preserved. + */ +export function groupAttachments( + documents: T[] +): AttachmentGroup[] { + const buckets = new Map(); + for (const doc of documents) { + const key = categorizeAttachment(doc.storageKey); + const bucket = buckets.get(key); + if (bucket) bucket.push(doc); + else buckets.set(key, [doc]); + } + + return ATTACHMENT_GROUP_ORDER.flatMap((key) => { + const items = buckets.get(key); + return items && items.length > 0 + ? [{ meta: ATTACHMENT_GROUP_META[key], items }] + : []; + }); +} diff --git a/App/tests/unit/attachments.test.ts b/App/tests/unit/attachments.test.ts new file mode 100644 index 0000000..b5a4deb --- /dev/null +++ b/App/tests/unit/attachments.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + categorizeAttachment, + groupAttachments, +} from "@/lib/attachments"; + +describe("categorizeAttachment", () => { + it("maps po-document keys to the submission group", () => { + expect(categorizeAttachment("po-document/po123/1700-invoice.pdf")).toBe("submission"); + }); + + it("maps receipt keys to the delivery group", () => { + expect(categorizeAttachment("receipt/po123/1700-delivery.pdf")).toBe("delivery"); + }); + + it("maps payment keys to the payment group", () => { + expect(categorizeAttachment("payment-document/po123/proof.pdf")).toBe("payment"); + expect(categorizeAttachment("payment/po123/proof.pdf")).toBe("payment"); + }); + + it("falls back to other for unknown prefixes", () => { + expect(categorizeAttachment("something-else/x.pdf")).toBe("other"); + expect(categorizeAttachment("no-slash")).toBe("other"); + }); +}); + +describe("groupAttachments", () => { + const doc = (id: string, storageKey: string) => ({ id, storageKey }); + + it("groups documents by lifecycle stage in canonical order", () => { + const groups = groupAttachments([ + doc("a", "receipt/po1/delivery.pdf"), + doc("b", "po-document/po1/invoice.pdf"), + doc("c", "po-document/po1/quote.pdf"), + ]); + + expect(groups.map((g) => g.meta.key)).toEqual(["submission", "delivery"]); + expect(groups[0].items.map((d) => d.id)).toEqual(["b", "c"]); + expect(groups[1].items.map((d) => d.id)).toEqual(["a"]); + }); + + it("omits empty groups", () => { + const groups = groupAttachments([doc("a", "po-document/po1/invoice.pdf")]); + expect(groups).toHaveLength(1); + expect(groups[0].meta.key).toBe("submission"); + }); + + it("returns an empty array when there are no documents", () => { + expect(groupAttachments([])).toEqual([]); + }); + + it("preserves input order within a group", () => { + const groups = groupAttachments([ + doc("first", "receipt/po1/a.pdf"), + doc("second", "receipt/po1/b.pdf"), + ]); + expect(groups[0].items.map((d) => d.id)).toEqual(["first", "second"]); + }); + + it("collects unknown prefixes into the other group last", () => { + const groups = groupAttachments([ + doc("x", "mystery/po1/file.pdf"), + doc("y", "po-document/po1/invoice.pdf"), + ]); + expect(groups.map((g) => g.meta.key)).toEqual(["submission", "other"]); + }); +});