diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 0982e34..af6ada0 100644 --- a/App/components/po/po-detail.tsx +++ b/App/components/po/po-detail.tsx @@ -10,6 +10,7 @@ import { generateDownloadUrl } from "@/lib/storage"; import { groupAttachments } from "@/lib/attachments"; import { TC_FIXED_LINE } from "@/lib/validations/po"; import { parsePoTerms } from "@/lib/terms"; +import { actionLabel } from "@/lib/po-activity"; import type { LineItemInput } from "@/lib/validations/po"; import type { Role } from "@prisma/client"; @@ -87,26 +88,6 @@ interface Props { vendorEmail?: string | null; } -const ACTION_LABELS: Record = { - CREATED: "Created", - SUBMITTED: "Submitted for review", - APPROVED: "Approved", - APPROVED_WITH_NOTE: "Approved with note", - REJECTED: "Rejected", - EDITS_REQUESTED: "Edits requested", - VENDOR_ID_REQUESTED: "Vendor ID requested", - VENDOR_ID_PROVIDED: "Vendor ID provided", - PAYMENT_SENT: "Payment confirmed", - PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed", - RECEIPT_CONFIRMED: "Receipt confirmed", - PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed", - CLOSED: "Closed", - MANAGER_LINE_EDIT: "Manager amended line items", - PRODUCT_PRICE_UPDATED: "Product prices updated", - CANCELLED: "Cancelled", - SUPERSEDED: "Superseded", -}; - export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) { const lineItemsForEditor = po.lineItems.map((li) => ({ name: li.name, @@ -577,7 +558,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
- {ACTION_LABELS[action.actionType] ?? action.actionType} + {actionLabel(action, po.currency)} by {action.actor.name} {formatDateTime(action.createdAt)} diff --git a/App/lib/po-activity.ts b/App/lib/po-activity.ts new file mode 100644 index 0000000..490d274 --- /dev/null +++ b/App/lib/po-activity.ts @@ -0,0 +1,40 @@ +import type { Prisma } from "@prisma/client"; +import { formatCurrency } from "@/lib/utils"; + +// Human-readable labels for each POAction type, shown in the PO Activity timeline. +export const ACTION_LABELS: Record = { + CREATED: "Created", + SUBMITTED: "Submitted for review", + APPROVED: "Approved", + APPROVED_WITH_NOTE: "Approved with note", + REJECTED: "Rejected", + EDITS_REQUESTED: "Edits requested", + VENDOR_ID_REQUESTED: "Vendor ID requested", + VENDOR_ID_PROVIDED: "Vendor ID provided", + PAYMENT_SENT: "Payment confirmed", + PARTIAL_PAYMENT_CONFIRMED: "Partial payment confirmed", + RECEIPT_CONFIRMED: "Receipt confirmed", + PARTIAL_RECEIPT_CONFIRMED: "Partial receipt confirmed", + CLOSED: "Closed", + MANAGER_LINE_EDIT: "Manager amended line items", + PRODUCT_PRICE_UPDATED: "Product prices updated", + CANCELLED: "Cancelled", + SUPERSEDED: "Superseded", +}; + +// Produce the Activity-timeline label for an action. Most actions use the static +// ACTION_LABELS map; PARTIAL_PAYMENT_CONFIRMED interpolates the instalment amount +// from the action's metadata (already persisted by markPaid) — issue #140. +export function actionLabel( + action: { actionType: string; metadata: Prisma.JsonValue }, + currency: string, +): string { + const fallback = ACTION_LABELS[action.actionType] ?? action.actionType; + if (action.actionType === "PARTIAL_PAYMENT_CONFIRMED") { + const amount = (action.metadata as { paymentAmount?: unknown } | null)?.paymentAmount; + if (typeof amount === "number" && Number.isFinite(amount)) { + return `Partial payment of ${formatCurrency(amount, currency)} confirmed`; + } + } + return fallback; +} diff --git a/App/tests/unit/po-activity-label.test.ts b/App/tests/unit/po-activity-label.test.ts new file mode 100644 index 0000000..ca8e19a --- /dev/null +++ b/App/tests/unit/po-activity-label.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { actionLabel } from "@/lib/po-activity"; +import { formatCurrency } from "@/lib/utils"; + +describe("actionLabel (Activity timeline)", () => { + it("interpolates the instalment amount for a partial payment (issue #140)", () => { + const label = actionLabel( + { actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 5000 } }, + "INR" + ); + expect(label).toBe(`Partial payment of ${formatCurrency(5000, "INR")} confirmed`); + expect(label).toContain("5,000"); + }); + + it("respects the PO currency", () => { + const label = actionLabel( + { actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: 1200 } }, + "USD" + ); + expect(label).toBe(`Partial payment of ${formatCurrency(1200, "USD")} confirmed`); + }); + + it("falls back to the plain label when paymentAmount is missing (older audit rows)", () => { + expect( + actionLabel({ actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: null }, "INR") + ).toBe("Partial payment confirmed"); + expect( + actionLabel( + { actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentRef: "TXN-1" } }, + "INR" + ) + ).toBe("Partial payment confirmed"); + }); + + it("falls back when paymentAmount is non-numeric, never rendering NaN", () => { + const label = actionLabel( + { actionType: "PARTIAL_PAYMENT_CONFIRMED", metadata: { paymentAmount: "5000" } }, + "INR" + ); + expect(label).toBe("Partial payment confirmed"); + expect(label).not.toContain("NaN"); + }); + + it("leaves other action labels unchanged", () => { + expect( + actionLabel({ actionType: "PAYMENT_SENT", metadata: { paymentAmount: 5000 } }, "INR") + ).toBe("Payment confirmed"); + expect(actionLabel({ actionType: "APPROVED", metadata: null }, "INR")).toBe("Approved"); + }); + + it("falls back to the raw action type for unknown actions", () => { + expect(actionLabel({ actionType: "MYSTERY", metadata: null }, "INR")).toBe("MYSTERY"); + }); +});