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 && (
)}
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"]);
+ });
+});