From 4dc10b834c6c10a6abf119a70fa15fe0c6f51f9b Mon Sep 17 00:00:00 2001 From: "Claude (auto-fix)" Date: Sun, 28 Jun 2026 00:40:36 +0530 Subject: [PATCH] feat(po): add Duplicate PO button to prefill a new PO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anyone with create_po browsing a PO now sees a Duplicate action that opens the New Purchase Order form prefilled from the source PO. Like the existing cart→new-PO prefill, nothing is written until the user saves or submits — a duplicate is just a clean draft of the editable order fields. - po-detail.tsx: Duplicate link in the header, gated by hasPermission(currentRole, "create_po") + !readOnly, linking to /po/new?duplicate=. - po/new/page.tsx: when ?duplicate= is present, fetch the source PO and map it onto the form's initial props via the new pure helper. - new-po-form.tsx: accept initial-value props for title, accounting code (+ per-item toggle), project code, place of delivery, date required, quotation/requisition refs, terms — following the existing prop pattern. - lib/duplicate-po.ts: pure, unit-tested mapping (Decimals→numbers, dates →yyyy-MM-dd, saved-terms snapshot with legacy tc* fallback). Attachments, status/dates, payment data and audit history are intentionally not copied. Fixes #142 Co-Authored-By: Claude Opus 4.8 (1M context) --- App/app/(portal)/po/new/new-po-form.tsx | 38 +++++-- App/app/(portal)/po/new/page.tsx | 37 +++++- App/components/po/po-detail.tsx | 11 ++ App/lib/duplicate-po.ts | 105 +++++++++++++++++ App/tests/unit/duplicate-po.test.ts | 145 ++++++++++++++++++++++++ 5 files changed, 318 insertions(+), 18 deletions(-) create mode 100644 App/lib/duplicate-po.ts create mode 100644 App/tests/unit/duplicate-po.test.ts diff --git a/App/app/(portal)/po/new/new-po-form.tsx b/App/app/(portal)/po/new/new-po-form.tsx index 2896ec9..073180c 100644 --- a/App/app/(portal)/po/new/new-po-form.tsx +++ b/App/app/(portal)/po/new/new-po-form.tsx @@ -38,9 +38,21 @@ interface Props { initialVendorId?: string; initialVesselId?: string; initialCompanyId?: string; + // Duplicate-PO prefill (issue #142) — copy editable order fields onto a new draft. + initialTitle?: string; + initialAccountId?: string; + initialMultiAccount?: boolean; + initialProjectCode?: string | null; + initialPlaceOfDelivery?: string | null; + initialDateRequired?: string; + initialPiQuotationNo?: string; + initialPiQuotationDate?: string; + initialRequisitionNo?: string; + initialRequisitionDate?: string; + initialTerms?: PoTerm[]; } -export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) { +export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId, initialTitle, initialAccountId, initialMultiAccount, initialProjectCode, initialPlaceOfDelivery, initialDateRequired, initialPiQuotationNo, initialPiQuotationDate, initialRequisitionNo, initialRequisitionDate, initialTerms }: Props) { const router = useRouter(); const [lineItems, setLineItems] = useState( initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] @@ -48,9 +60,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio const [files, setFiles] = useState([]); const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null); const [error, setError] = useState(""); - const [multiAccount, setMultiAccount] = useState(false); - const [defaultAccountId, setDefaultAccountId] = useState(""); - const [terms, setTerms] = useState(defaultTerms); + const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false); + const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? ""); + const [terms, setTerms] = useState( + initialTerms && initialTerms.length > 0 ? initialTerms : defaultTerms + ); const [dirty, setDirty] = useState(false); const markDirty = () => setDirty(true); @@ -114,7 +128,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio - + {/* Cost Centre — vessels only */} @@ -163,7 +177,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
- + {projectCodeOptions.length === 0 && (

No project codes configured yet — a Manager can add them under Administration → Project Codes. @@ -172,7 +186,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio

- +
@@ -183,11 +197,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
- +
- +
@@ -198,11 +212,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
- +
- +
@@ -212,7 +226,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio

Delivery

- + {deliveryOptions.length === 0 && (

No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations. diff --git a/App/app/(portal)/po/new/page.tsx b/App/app/(portal)/po/new/page.tsx index 8d8c55d..0f1ada9 100644 --- a/App/app/(portal)/po/new/page.tsx +++ b/App/app/(portal)/po/new/page.tsx @@ -6,6 +6,7 @@ import { NewPoForm } from "./new-po-form"; import { buildAccountGroups } from "@/lib/cost-centre-groups"; import { formatDeliveryLocation } from "@/lib/delivery-location"; import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data"; +import { buildDuplicatePrefill, type DuplicatePrefill } from "@/lib/duplicate-po"; import type { Metadata } from "next"; import type { LineItemInput } from "@/lib/validations/po"; import type { CartItem } from "@/lib/cart"; @@ -13,7 +14,7 @@ import type { CartItem } from "@/lib/cart"; export const metadata: Metadata = { title: "New Purchase Order" }; interface Props { - searchParams: Promise<{ cart?: string; vesselId?: string }>; + searchParams: Promise<{ cart?: string; vesselId?: string; duplicate?: string }>; } export default async function NewPoPage({ searchParams }: Props) { @@ -22,11 +23,23 @@ export default async function NewPoPage({ searchParams }: Props) { if (!hasPermission(session.user.role, "create_po")) redirect("/dashboard"); - const { cart, vesselId: initialVesselId } = await searchParams; + const { cart, vesselId, duplicate } = await searchParams; + // Duplicate-PO prefill (issue #142): copy a source PO's editable order fields + // onto a fresh draft. Nothing is written until the user saves/submits — same + // shape as the cart→new-PO prefill below, just a richer field set. + let dup: DuplicatePrefill | null = null; let initialLineItems: LineItemInput[] | undefined; let initialVendorId: string | undefined; - if (cart) { + let initialVesselId: string | undefined = vesselId; + + if (duplicate) { + const source = await db.purchaseOrder.findUnique({ + where: { id: duplicate }, + include: { lineItems: { orderBy: { sortOrder: "asc" } } }, + }); + if (source) dup = buildDuplicatePrefill(source); + } else if (cart) { try { const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart)); if (Array.isArray(cartItems) && cartItems.length > 0) { @@ -83,9 +96,21 @@ export default async function NewPoPage({ searchParams }: Props) { projectCodeOptions={projectCodeOptions} termsCatalogue={termsCatalogue} defaultTerms={defaultTerms} - initialLineItems={initialLineItems} - initialVendorId={initialVendorId} - initialVesselId={initialVesselId} + initialLineItems={dup?.initialLineItems ?? initialLineItems} + initialVendorId={dup?.initialVendorId ?? initialVendorId} + initialVesselId={dup?.initialVesselId ?? initialVesselId} + initialCompanyId={dup?.initialCompanyId} + initialTitle={dup?.initialTitle} + initialAccountId={dup?.initialAccountId} + initialMultiAccount={dup?.initialMultiAccount} + initialProjectCode={dup?.initialProjectCode} + initialPlaceOfDelivery={dup?.initialPlaceOfDelivery} + initialDateRequired={dup?.initialDateRequired} + initialPiQuotationNo={dup?.initialPiQuotationNo} + initialPiQuotationDate={dup?.initialPiQuotationDate} + initialRequisitionNo={dup?.initialRequisitionNo} + initialRequisitionDate={dup?.initialRequisitionDate} + initialTerms={dup?.initialTerms} />

); diff --git a/App/components/po/po-detail.tsx b/App/components/po/po-detail.tsx index 0982e34..ceb5c48 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 { hasPermission } from "@/lib/permissions"; import type { LineItemInput } from "@/lib/validations/po"; import type { Role } from "@prisma/client"; @@ -216,6 +217,16 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals !readOnly && ( )} + {/* Duplicate — anyone who can create POs (issue #142). Opens a new PO + form prefilled from this PO; nothing is written until they save. */} + {!readOnly && hasPermission(currentRole, "create_po") && ( + + Duplicate + + )} {/* Export buttons — available once approved, and for cancelled POs (watermarked) */} {["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<> number } | number | null | undefined; + +const num = (v: DecimalLike, fallback = 0): number => + v == null ? fallback : typeof v === "number" ? v : v.toNumber(); + +export type DuplicateSourceLineItem = { + name: string; + description?: string | null; + quantity: DecimalLike; + unit: string; + size?: string | null; + unitPrice: DecimalLike; + gstRate?: DecimalLike; + productId?: string | null; + accountId?: string | null; +}; + +export type DuplicateSourcePo = { + title: string; + vesselId: string; + accountId: string; + companyId?: string | null; + vendorId?: string | null; + projectCode?: string | null; + placeOfDelivery?: string | null; + dateRequired?: Date | null; + piQuotationNo?: string | null; + piQuotationDate?: Date | null; + requisitionNo?: string | null; + requisitionDate?: Date | null; + terms?: unknown; + tcDelivery?: string | null; + tcDispatch?: string | null; + tcInspection?: string | null; + tcTransitInsurance?: string | null; + tcPaymentTerms?: string | null; + tcOthers?: string | null; + lineItems: DuplicateSourceLineItem[]; +}; + +export type DuplicatePrefill = { + initialLineItems: LineItemInput[]; + initialMultiAccount: boolean; + initialVendorId?: string; + initialVesselId: string; + initialCompanyId?: string; + initialTitle: string; + initialAccountId: string; + initialProjectCode: string | null; + initialPlaceOfDelivery: string | null; + initialDateRequired?: string; + initialPiQuotationNo?: string; + initialPiQuotationDate?: string; + initialRequisitionNo?: string; + initialRequisitionDate?: string; + initialTerms: PoTerm[]; +}; + +/** Format a Date to a `yyyy-MM-dd` value for a native date input. */ +export const toDateInputValue = (d: Date | null | undefined): string | undefined => + d ? new Date(d).toISOString().split("T")[0] : undefined; + +export function buildDuplicatePrefill(source: DuplicateSourcePo): DuplicatePrefill { + const savedTerms = parsePoTerms(source.terms); + return { + initialLineItems: source.lineItems.map((li) => ({ + name: li.name, + description: li.description ?? "", + quantity: num(li.quantity, 1), + unit: li.unit, + size: li.size ?? "", + unitPrice: num(li.unitPrice, 0), + gstRate: li.gstRate != null ? num(li.gstRate, 0.18) : 0.18, + productId: li.productId ?? undefined, + accountId: li.accountId ?? undefined, + })), + initialMultiAccount: source.lineItems.some((li) => !!li.accountId), + initialVendorId: source.vendorId ?? undefined, + initialVesselId: source.vesselId, + initialCompanyId: source.companyId ?? undefined, + initialTitle: source.title, + initialAccountId: source.accountId, + initialProjectCode: source.projectCode ?? null, + initialPlaceOfDelivery: source.placeOfDelivery ?? null, + initialDateRequired: toDateInputValue(source.dateRequired), + initialPiQuotationNo: source.piQuotationNo ?? undefined, + initialPiQuotationDate: toDateInputValue(source.piQuotationDate), + initialRequisitionNo: source.requisitionNo ?? undefined, + initialRequisitionDate: toDateInputValue(source.requisitionDate), + initialTerms: savedTerms.length > 0 ? savedTerms : legacyPoTerms(source), + }; +} diff --git a/App/tests/unit/duplicate-po.test.ts b/App/tests/unit/duplicate-po.test.ts new file mode 100644 index 0000000..9198183 --- /dev/null +++ b/App/tests/unit/duplicate-po.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from "vitest"; +import { buildDuplicatePrefill, toDateInputValue } from "@/lib/duplicate-po"; +import type { DuplicateSourcePo } from "@/lib/duplicate-po"; + +// A Prisma Decimal stand-in: just needs a toNumber(). +const dec = (n: number) => ({ toNumber: () => n }); + +function makeSource(overrides: Partial = {}): DuplicateSourcePo { + return { + title: "Spare parts for HNR1", + vesselId: "vsl_1", + accountId: "acc_1", + companyId: "co_1", + vendorId: "ven_1", + projectCode: "Haldia Reach", + placeOfDelivery: "Pelagia — Cochin yard", + dateRequired: new Date("2026-07-15T00:00:00.000Z"), + piQuotationNo: "INV-001", + piQuotationDate: new Date("2026-06-01T00:00:00.000Z"), + requisitionNo: "REQ-42", + requisitionDate: new Date("2026-05-20T00:00:00.000Z"), + terms: [{ category: "Delivery", text: "Within 4 to 5 days" }], + lineItems: [ + { + name: "Gasket", + description: "Rubber gasket", + quantity: dec(3), + unit: "pc", + size: "M", + unitPrice: dec(120.5), + gstRate: dec(0.18), + productId: "prod_1", + accountId: null, + }, + ], + ...overrides, + }; +} + +describe("buildDuplicatePrefill", () => { + it("copies the editable order fields onto the new draft", () => { + const r = buildDuplicatePrefill(makeSource()); + expect(r.initialTitle).toBe("Spare parts for HNR1"); + expect(r.initialVesselId).toBe("vsl_1"); + expect(r.initialAccountId).toBe("acc_1"); + expect(r.initialCompanyId).toBe("co_1"); + expect(r.initialVendorId).toBe("ven_1"); + expect(r.initialProjectCode).toBe("Haldia Reach"); + expect(r.initialPlaceOfDelivery).toBe("Pelagia — Cochin yard"); + expect(r.initialPiQuotationNo).toBe("INV-001"); + expect(r.initialRequisitionNo).toBe("REQ-42"); + }); + + it("formats dates as yyyy-MM-dd for native date inputs", () => { + const r = buildDuplicatePrefill(makeSource()); + expect(r.initialDateRequired).toBe("2026-07-15"); + expect(r.initialPiQuotationDate).toBe("2026-06-01"); + expect(r.initialRequisitionDate).toBe("2026-05-20"); + }); + + it("maps line items to the editor shape, converting Decimals to numbers", () => { + const r = buildDuplicatePrefill(makeSource()); + expect(r.initialLineItems).toEqual([ + { + name: "Gasket", + description: "Rubber gasket", + quantity: 3, + unit: "pc", + size: "M", + unitPrice: 120.5, + gstRate: 0.18, + productId: "prod_1", + accountId: undefined, + }, + ]); + }); + + it("enables per-item accounting codes only when a line item carries one", () => { + expect(buildDuplicatePrefill(makeSource()).initialMultiAccount).toBe(false); + const multi = makeSource({ + lineItems: [ + { name: "A", quantity: dec(1), unit: "pc", unitPrice: dec(10), accountId: "acc_2" }, + ], + }); + const r = buildDuplicatePrefill(multi); + expect(r.initialMultiAccount).toBe(true); + expect(r.initialLineItems[0].accountId).toBe("acc_2"); + }); + + it("defaults a missing gstRate to 0.18", () => { + const src = makeSource({ + lineItems: [{ name: "A", quantity: dec(2), unit: "pc", unitPrice: dec(5) }], + }); + expect(buildDuplicatePrefill(src).initialLineItems[0].gstRate).toBe(0.18); + }); + + it("uses the saved terms snapshot when present", () => { + const r = buildDuplicatePrefill(makeSource()); + expect(r.initialTerms).toEqual([{ category: "Delivery", text: "Within 4 to 5 days" }]); + }); + + it("falls back to legacy tc* terms when no JSON snapshot exists", () => { + const src = makeSource({ + terms: null, + tcDelivery: "Next day", + tcPaymentTerms: "Net 15", + }); + const r = buildDuplicatePrefill(src); + expect(r.initialTerms).toEqual( + expect.arrayContaining([ + { category: "Delivery", text: "Next day" }, + { category: "Payment Terms", text: "Net 15" }, + ]) + ); + }); + + it("normalises absent optional fields to undefined/null", () => { + const src = makeSource({ + companyId: null, + vendorId: null, + projectCode: null, + placeOfDelivery: null, + dateRequired: null, + piQuotationNo: null, + piQuotationDate: null, + requisitionNo: null, + requisitionDate: null, + }); + const r = buildDuplicatePrefill(src); + expect(r.initialCompanyId).toBeUndefined(); + expect(r.initialVendorId).toBeUndefined(); + expect(r.initialDateRequired).toBeUndefined(); + expect(r.initialPiQuotationNo).toBeUndefined(); + expect(r.initialProjectCode).toBeNull(); + expect(r.initialPlaceOfDelivery).toBeNull(); + }); +}); + +describe("toDateInputValue", () => { + it("returns yyyy-MM-dd for a date and undefined for null", () => { + expect(toDateInputValue(new Date("2026-01-09T12:00:00.000Z"))).toBe("2026-01-09"); + expect(toDateInputValue(null)).toBeUndefined(); + expect(toDateInputValue(undefined)).toBeUndefined(); + }); +});