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=<id>. - po/new/page.tsx: when ?duplicate=<id> 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) <noreply@anthropic.com>
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
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> = {}): 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();
|
|
});
|
|
});
|