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>
105 lines
3.7 KiB
TypeScript
105 lines
3.7 KiB
TypeScript
/**
|
|
* 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),
|
|
};
|
|
}
|