From 3335977773ed3bfc82951483a307da440bb02059 Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Sun, 28 Jun 2026 00:24:28 +0530 Subject: [PATCH] feat(activity): show partial payment amount in PO timeline The PO Activity timeline rendered every partial payment as the generic "Partial payment confirmed". markPaid() already persists the instalment amount on the PARTIAL_PAYMENT_CONFIRMED action's metadata (metadata.paymentAmount), so surface it: the row now reads "Partial payment of confirmed" using the PO's own currency. Falls back to the plain label when paymentAmount is missing or non-numeric (older audit rows) so historical POs never render NaN. Extracted ACTION_LABELS + the new actionLabel() helper into lib/po-activity.ts so the label logic is unit-testable without pulling the server-only PoDetail component (and its storage/auth imports) into jsdom. Fixes #140 --- App/components/po/po-detail.tsx | 23 +--------- App/lib/po-activity.ts | 40 ++++++++++++++++++ App/tests/unit/po-activity-label.test.ts | 54 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 21 deletions(-) create mode 100644 App/lib/po-activity.ts create mode 100644 App/tests/unit/po-activity-label.test.ts 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"); + }); +});