Merge pull request 'fix: PO details: show all attachments, grouped by type' (#27) from claude/issue-10 into master
Reviewed-on: #27
This commit is contained in:
commit
7713601be7
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