fix: Add Duplicate PO button #145
5 changed files with 318 additions and 18 deletions
|
|
@ -38,9 +38,21 @@ interface Props {
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
initialVesselId?: string;
|
initialVesselId?: string;
|
||||||
initialCompanyId?: 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 router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
|
|
@ -48,9 +60,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [multiAccount, setMultiAccount] = useState(false);
|
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? "");
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
const [terms, setTerms] = useState<PoTerm[]>(
|
||||||
|
initialTerms && initialTerms.length > 0 ? initialTerms : defaultTerms
|
||||||
|
);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const markDirty = () => setDirty(true);
|
const markDirty = () => setDirty(true);
|
||||||
|
|
||||||
|
|
@ -114,7 +128,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Title <span className="text-danger">*</span>
|
Title <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
|
<input name="title" required defaultValue={initialTitle ?? ""} className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Centre — vessels only */}
|
{/* Cost Centre — vessels only */}
|
||||||
|
|
@ -163,7 +177,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
|
<ProjectCodeField options={projectCodeOptions} current={initialProjectCode} className={INPUT_CLS} />
|
||||||
{projectCodeOptions.length === 0 && (
|
{projectCodeOptions.length === 0 && (
|
||||||
<p className="mt-1.5 text-xs text-neutral-500">
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
No project codes configured yet — a Manager can add them under Administration → Project Codes.
|
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
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
<input name="dateRequired" type="date" className={INPUT_CLS} />
|
<input name="dateRequired" type="date" defaultValue={initialDateRequired ?? ""} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -183,11 +197,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation No.</label>
|
||||||
<input name="piQuotationNo" className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
|
<input name="piQuotationNo" defaultValue={initialPiQuotationNo ?? ""} className={INPUT_CLS} placeholder='e.g. Verbal, INV-001' />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">PI / Quotation Date</label>
|
||||||
<input name="piQuotationDate" type="date" className={INPUT_CLS} />
|
<input name="piQuotationDate" type="date" defaultValue={initialPiQuotationDate ?? ""} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -198,11 +212,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Cost Centre / Office Requisition No.</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Cost Centre / Office Requisition No.</label>
|
||||||
<input name="requisitionNo" className={INPUT_CLS} placeholder="Optional" />
|
<input name="requisitionNo" defaultValue={initialRequisitionNo ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Requisition Date</label>
|
||||||
<input name="requisitionDate" type="date" className={INPUT_CLS} />
|
<input name="requisitionDate" type="date" defaultValue={initialRequisitionDate ?? ""} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -212,7 +226,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
<DeliveryLocationField options={deliveryOptions} current={initialPlaceOfDelivery} className={INPUT_CLS} />
|
||||||
{deliveryOptions.length === 0 && (
|
{deliveryOptions.length === 0 && (
|
||||||
<p className="mt-1.5 text-xs text-neutral-500">
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { NewPoForm } from "./new-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||||
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
|
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
|
||||||
|
import { buildDuplicatePrefill, type DuplicatePrefill } from "@/lib/duplicate-po";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { CartItem } from "@/lib/cart";
|
import type { CartItem } from "@/lib/cart";
|
||||||
|
|
@ -13,7 +14,7 @@ import type { CartItem } from "@/lib/cart";
|
||||||
export const metadata: Metadata = { title: "New Purchase Order" };
|
export const metadata: Metadata = { title: "New Purchase Order" };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{ cart?: string; vesselId?: string }>;
|
searchParams: Promise<{ cart?: string; vesselId?: string; duplicate?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function NewPoPage({ searchParams }: Props) {
|
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");
|
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 initialLineItems: LineItemInput[] | undefined;
|
||||||
let initialVendorId: string | 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 {
|
try {
|
||||||
const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart));
|
const cartItems: CartItem[] = JSON.parse(decodeURIComponent(cart));
|
||||||
if (Array.isArray(cartItems) && cartItems.length > 0) {
|
if (Array.isArray(cartItems) && cartItems.length > 0) {
|
||||||
|
|
@ -83,9 +96,21 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
projectCodeOptions={projectCodeOptions}
|
projectCodeOptions={projectCodeOptions}
|
||||||
termsCatalogue={termsCatalogue}
|
termsCatalogue={termsCatalogue}
|
||||||
defaultTerms={defaultTerms}
|
defaultTerms={defaultTerms}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={dup?.initialLineItems ?? initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={dup?.initialVendorId ?? initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
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}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { groupAttachments } from "@/lib/attachments";
|
import { groupAttachments } from "@/lib/attachments";
|
||||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
import { parsePoTerms } from "@/lib/terms";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -216,6 +217,16 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
!readOnly && (
|
!readOnly && (
|
||||||
<DiscardDraftButton poId={po.id} />
|
<DiscardDraftButton poId={po.id} />
|
||||||
)}
|
)}
|
||||||
|
{/* 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") && (
|
||||||
|
<Link
|
||||||
|
href={`/po/new?duplicate=${po.id}`}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
|
{/* 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) && (<>
|
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
105
App/lib/duplicate-po.ts
Normal file
105
App/lib/duplicate-po.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Duplicate-PO prefill (issue #142) — map a source PurchaseOrder onto the
|
||||||
|
* initial-value props the New PO form consumes. Pure (no DB / no I/O) so the
|
||||||
|
* mapping is unit-testable; the page just fetches the PO and hands it here.
|
||||||
|
*
|
||||||
|
* Nothing is written: a duplicate is only a prefilled draft. Attachments,
|
||||||
|
* status/dates, payment data and audit history are intentionally NOT copied —
|
||||||
|
* a duplicate starts as a clean draft of the editable order fields.
|
||||||
|
*/
|
||||||
|
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
|
||||||
|
import type { PoTerm } from "@/lib/terms";
|
||||||
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
|
||||||
|
type DecimalLike = { toNumber: () => 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
145
App/tests/unit/duplicate-po.test.ts
Normal file
145
App/tests/unit/duplicate-po.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue