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
This commit is contained in:
parent
3e711a171c
commit
4e6175153d
3 changed files with 201 additions and 20 deletions
|
|
@ -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
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents */}
|
||||
{po.documents.length > 0 && (
|
||||
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||||
{attachmentGroups.length > 0 && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||
<ul className="space-y-2">
|
||||
{po.documents.map((doc, i) => (
|
||||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||
<a
|
||||
href={downloadUrls[i]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{doc.fileName}
|
||||
</a>
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||
</span>
|
||||
</li>
|
||||
<div className="space-y-5">
|
||||
{attachmentGroups.map((group) => (
|
||||
<div key={group.meta.key}>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{group.meta.label}
|
||||
<span className="ml-1.5 font-normal text-neutral-400">({group.items.length})</span>
|
||||
</h4>
|
||||
{group.meta.description && (
|
||||
<p className="mt-0.5 text-xs text-neutral-400">{group.meta.description}</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-2">
|
||||
{group.items.map((doc) => (
|
||||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{doc.fileName}
|
||||
</a>
|
||||
<span className="text-neutral-400 text-xs">
|
||||
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
96
App/lib/attachments.ts
Normal file
96
App/lib/attachments.ts
Normal file
|
|
@ -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/<poId>/...`
|
||||
* or `receipt/<poId>/...`. 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<AttachmentGroupKey, AttachmentGroupMeta> = {
|
||||
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<T> {
|
||||
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<T extends { storageKey: string }>(
|
||||
documents: T[]
|
||||
): AttachmentGroup<T>[] {
|
||||
const buckets = new Map<AttachmentGroupKey, T[]>();
|
||||
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 }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
67
App/tests/unit/attachments.test.ts
Normal file
67
App/tests/unit/attachments.test.ts
Normal file
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue