Compare commits
9 commits
claude/fla
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fdda87381 | |||
| 9f681ace89 | |||
| 7e313bb3f4 | |||
| e481eb0a15 | |||
| 65be4ef330 | |||
| ebb6230755 | |||
| fc68a84636 | |||
|
|
4dc10b834c | ||
|
|
3335977773 |
14 changed files with 536 additions and 104 deletions
|
|
@ -78,8 +78,9 @@ FORGEJO_TOKEN=
|
|||
# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History
|
||||
# page (read-only). Opt-in — on only when exactly "true".
|
||||
# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
|
||||
# Let a CLOSED PO's own submitter (plus Accounts/Manager/SuperUser) add attachments
|
||||
# to it — remediation for POs whose uploads were lost to the document-upload bug.
|
||||
# Let a PO's own submitter (plus Accounts/Manager/SuperUser) add attachments to it
|
||||
# in any state except rejected/cancelled — remediation for POs whose uploads were
|
||||
# lost to the document-upload bug, and the general "attach after the fact" affordance.
|
||||
# Opt-in — on only when exactly "true".
|
||||
# NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true
|
||||
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to
|
|||
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
|
||||
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
|
||||
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
|
||||
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED # Opt-in ("true"): a CLOSED PO's own submitter + Accounts/Manager/SuperUser may add attachments (remediation for the upload bug). Off by default.
|
||||
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED # Opt-in ("true"): a PO's own submitter + Accounts/Manager/SuperUser may add attachments in any state except rejected/cancelled (upload-bug remediation + general "attach after the fact"). Off by default.
|
||||
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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<LineItemInput[]>(
|
||||
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 [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [multiAccount, setMultiAccount] = useState(false);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
|
||||
const [multiAccount, setMultiAccount] = useState(initialMultiAccount ?? false);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState(initialAccountId ?? "");
|
||||
const [terms, setTerms] = useState<PoTerm[]>(
|
||||
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
|
|||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||
Title <span className="text-danger">*</span>
|
||||
</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>
|
||||
|
||||
{/* Cost Centre — vessels only */}
|
||||
|
|
@ -163,7 +177,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
|||
</div>
|
||||
<div>
|
||||
<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 && (
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<div>
|
||||
<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 && (
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
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 { 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||
import { canAddClosedPoAttachment } from "@/lib/permissions";
|
||||
import { canAddPoAttachment } from "@/lib/permissions";
|
||||
import { CLOSED_PO_ATTACHMENTS_ENABLED } from "@/lib/feature-flags";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// Matches the FileUploader hint ("up to 10 MB each") and
|
||||
|
|
@ -43,16 +44,25 @@ export async function uploadPoDocuments(
|
|||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
|
||||
// A CLOSED PO is otherwise immutable; attaching to one is only allowed via the
|
||||
// feature-flagged remediation path (canAddClosedPoAttachment). The normal create
|
||||
// and receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
|
||||
if (po.status === "CLOSED") {
|
||||
const allowed = canAddClosedPoAttachment(session.user.role, {
|
||||
// A voided PO never accepts attachments, regardless of the flag.
|
||||
if (po.status === "REJECTED" || po.status === "CANCELLED") {
|
||||
return { error: "Attachments can't be added to a rejected or cancelled purchase order." };
|
||||
}
|
||||
|
||||
if (CLOSED_PO_ATTACHMENTS_ENABLED) {
|
||||
// Feature on: only the PO's submitter + Accounts / Manager / SuperUser may
|
||||
// attach, in any non-voided state. The normal create / receipt flows are run
|
||||
// by exactly those actors, so they keep working.
|
||||
const allowed = canAddPoAttachment(session.user.role, po.status, {
|
||||
isSubmitter: po.submitterId === session.user.id,
|
||||
});
|
||||
if (!allowed) {
|
||||
return { error: "Adding attachments to a closed purchase order isn't allowed." };
|
||||
return { error: "Adding attachments to this purchase order isn't allowed." };
|
||||
}
|
||||
} else if (po.status === "CLOSED") {
|
||||
// Feature off: a closed PO stays immutable (legacy behaviour). The create /
|
||||
// receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
|
||||
return { error: "Adding attachments to a closed purchase order isn't allowed." };
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { FileUploader } from "@/components/po/file-uploader";
|
|||
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
|
||||
|
||||
/**
|
||||
* Feature-flagged uploader shown on a CLOSED PO's detail page so its submitter (or
|
||||
* Accounts / Manager / SuperUser) can attach documents that were lost to the upload
|
||||
* bug. Gating is decided server-side in po-detail.tsx; the server action re-checks
|
||||
* the permission, so this component is only the UI.
|
||||
* Feature-flagged uploader shown on a PO's detail page so its submitter (or
|
||||
* Accounts / Manager / SuperUser) can add documents after the fact — in any state
|
||||
* except rejected/cancelled. Gating is decided server-side in po-detail.tsx; the
|
||||
* server action re-checks the permission, so this component is only the UI.
|
||||
*/
|
||||
export function ClosedPoAttachmentUploader({ poId }: { poId: string }) {
|
||||
export function PoAttachmentUploader({ poId }: { poId: string }) {
|
||||
const router = useRouter();
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -36,7 +36,7 @@ export function ClosedPoAttachmentUploader({ poId }: { poId: string }) {
|
|||
<div className="mt-5 border-t border-neutral-100 pt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Add attachments</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-400">
|
||||
This purchase order is closed. Attach any documents that are missing.
|
||||
Attach any documents that are missing from this purchase order.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<FileUploader files={files} onChange={setFiles} disabled={busy} />
|
||||
|
|
@ -5,13 +5,14 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
|||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
||||
import { EmailVendorButton } from "@/components/po/email-vendor-button";
|
||||
import { ClosedPoAttachmentUploader } from "@/components/po/closed-po-attachment-uploader";
|
||||
import { PoAttachmentUploader } from "@/components/po/po-attachment-uploader";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||
import { generateDownloadUrl } from "@/lib/storage";
|
||||
import { groupAttachments } from "@/lib/attachments";
|
||||
import { canAddClosedPoAttachment } from "@/lib/permissions";
|
||||
import { canAddPoAttachment, hasPermission } from "@/lib/permissions";
|
||||
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";
|
||||
|
||||
|
|
@ -89,26 +90,6 @@ interface Props {
|
|||
vendorEmail?: string | null;
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
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,
|
||||
|
|
@ -173,12 +154,12 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
);
|
||||
const attachmentGroups = groupAttachments(docsWithUrls);
|
||||
|
||||
// Feature-flagged remediation: a closed PO's submitter (or Accounts / Manager /
|
||||
// SuperUser) may attach documents that the upload bug dropped. Never in readOnly.
|
||||
const canAddClosedAttachment =
|
||||
// Feature-flagged: the PO's submitter (or Accounts / Manager / SuperUser) may add
|
||||
// attachments after the fact, in any state except rejected/cancelled. Never in
|
||||
// readOnly. The server action re-checks this permission.
|
||||
const canAddAttachment =
|
||||
!readOnly &&
|
||||
po.status === "CLOSED" &&
|
||||
canAddClosedPoAttachment(currentRole, { isSubmitter: po.submitter.id === currentUserId });
|
||||
canAddPoAttachment(currentRole, po.status, { isSubmitter: po.submitter.id === currentUserId });
|
||||
|
||||
const canConfirmReceipt =
|
||||
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
|
||||
|
|
@ -225,6 +206,16 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
!readOnly && (
|
||||
<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) */}
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
|
||||
<a
|
||||
|
|
@ -507,7 +498,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
})()}
|
||||
|
||||
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||||
{(attachmentGroups.length > 0 || canAddClosedAttachment) && (
|
||||
{(attachmentGroups.length > 0 || canAddAttachment) && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||
{attachmentGroups.length === 0 && (
|
||||
|
|
@ -543,7 +534,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canAddClosedAttachment && <ClosedPoAttachmentUploader poId={po.id} />}
|
||||
{canAddAttachment && <PoAttachmentUploader poId={po.id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -590,7 +581,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
<div className="absolute -left-1.5 mt-1.5 h-3 w-3 rounded-full border-2 border-white bg-neutral-400" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{ACTION_LABELS[action.actionType] ?? action.actionType}
|
||||
{actionLabel(action, po.currency)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400">by {action.actor.name}</span>
|
||||
<span className="text-xs text-neutral-400 ml-auto">{formatDateTime(action.createdAt)}</span>
|
||||
|
|
|
|||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -16,11 +16,12 @@
|
|||
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
|
||||
* and wiki Crewing-Implementation-Spec.
|
||||
*
|
||||
* NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true → lets a CLOSED PO's own submitter, plus
|
||||
* Accounts / Manager / SuperUser, add attachments to it. Remediation path for the upload
|
||||
* bug where documents never persisted (no PODocument row): closed POs whose files were lost
|
||||
* can be fixed without reopening them. Opt-in (off unless "true") so production is unchanged
|
||||
* until enabled. See lib/permissions.ts (canAddClosedPoAttachment).
|
||||
* NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true → lets a PO's own submitter, plus
|
||||
* Accounts / Manager / SuperUser, add attachments to it in any state EXCEPT
|
||||
* rejected/cancelled. Remediation path for the upload bug where documents never persisted
|
||||
* (no PODocument row), and the general "attach a document after the fact" affordance.
|
||||
* Opt-in (off unless "true") so production is unchanged until enabled.
|
||||
* See lib/permissions.ts (canAddPoAttachment).
|
||||
*/
|
||||
|
||||
export const INVENTORY_ENABLED =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Role } from "@prisma/client";
|
||||
import type { Role, POStatus } from "@prisma/client";
|
||||
import { SUBMITTER_VIEW_ALL_ENABLED, CLOSED_PO_ATTACHMENTS_ENABLED } from "./feature-flags";
|
||||
|
||||
export type Permission =
|
||||
|
|
@ -279,23 +279,30 @@ export function canViewAllPos(role: Role): boolean {
|
|||
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
|
||||
}
|
||||
|
||||
// ── Closed-PO attachments (feature-flagged remediation) ───────────────────────
|
||||
// Roles that may attach to ANY closed PO (the PO's own submitter is allowed too,
|
||||
// regardless of role) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
|
||||
const CLOSED_PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
|
||||
// ── PO attachments (feature-flagged) ──────────────────────────────────────────
|
||||
// Roles that may attach to a PO (besides the PO's own submitter, who is always
|
||||
// allowed) when NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED=true.
|
||||
const PO_ATTACHMENT_ROLES: Role[] = ["ACCOUNTS", "MANAGER", "SUPERUSER"];
|
||||
|
||||
// A PO in one of these terminal/voided states never accepts new attachments.
|
||||
const NO_ATTACHMENT_STATUSES: POStatus[] = ["REJECTED", "CANCELLED"];
|
||||
|
||||
/**
|
||||
* Feature-flagged: whether the current user may add attachments to a CLOSED PO.
|
||||
* This is the remediation path for the upload bug where documents never persisted
|
||||
* — closed POs that lost their files can be re-attached without reopening them.
|
||||
* Feature-flagged: whether the current user may add attachments to a PO.
|
||||
*
|
||||
* Allowed (only when the flag is on) for the PO's own submitter, or for
|
||||
* Accounts / Manager / SuperUser. Off by default ⇒ closed POs stay immutable.
|
||||
* When `NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED` is on, the PO's own submitter
|
||||
* — plus Accounts / Manager / SuperUser — may attach documents to a PO in **any
|
||||
* state except `REJECTED` / `CANCELLED`** (a voided PO is never editable). This
|
||||
* is the remediation path for the upload bug where documents never persisted, and
|
||||
* the general "add a document after the fact" affordance. Off by default ⇒ no
|
||||
* post-hoc attachment UI and closed POs stay immutable.
|
||||
*/
|
||||
export function canAddClosedPoAttachment(
|
||||
export function canAddPoAttachment(
|
||||
role: Role,
|
||||
status: POStatus,
|
||||
opts: { isSubmitter: boolean }
|
||||
): boolean {
|
||||
if (!CLOSED_PO_ATTACHMENTS_ENABLED) return false;
|
||||
return opts.isSubmitter || CLOSED_PO_ATTACHMENT_ROLES.includes(role);
|
||||
if (NO_ATTACHMENT_STATUSES.includes(status)) return false;
|
||||
return opts.isSubmitter || PO_ATTACHMENT_ROLES.includes(role);
|
||||
}
|
||||
|
|
|
|||
40
App/lib/po-activity.ts
Normal file
40
App/lib/po-activity.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
/**
|
||||
* Integration test for the feature-flagged closed-PO attachment remediation
|
||||
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a CLOSED PO's own
|
||||
* submitter — plus Accounts / Manager / SuperUser — may attach documents; everyone
|
||||
* else is refused. (The flag-OFF case lives in po-document-upload.test.ts.)
|
||||
* Integration test for the feature-flagged PO-attachment permission
|
||||
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a PO's own
|
||||
* submitter — plus Accounts / Manager / SuperUser — may attach documents to a PO
|
||||
* in **any state except REJECTED / CANCELLED**; everyone else, and any voided PO,
|
||||
* is refused. (The flag-OFF behaviour lives in po-document-upload.test.ts.)
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ vi.mock("@/lib/storage", async (importOriginal) => {
|
|||
const actual = await importOriginal<typeof import("@/lib/storage")>();
|
||||
return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) };
|
||||
});
|
||||
// Flip ONLY the remediation flag on; everything else stays real.
|
||||
// Flip ONLY the attachment flag on; everything else stays real.
|
||||
vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/feature-flags")>();
|
||||
return { ...actual, CLOSED_PO_ATTACHMENTS_ENABLED: true };
|
||||
|
|
@ -21,13 +22,16 @@ vi.mock("@/lib/feature-flags", async (importOriginal) => {
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { uploadBuffer } from "@/lib/storage";
|
||||
import type { Role } from "@prisma/client";
|
||||
import type { Role, POStatus } from "@prisma/client";
|
||||
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
|
||||
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
|
||||
|
||||
const PREFIX = "INTTEST_CLOSEDPO_";
|
||||
let techId: string; // the PO's submitter
|
||||
const PREFIX = "INTTEST_POATTACH_";
|
||||
const VOID_ERROR = "Attachments can't be added to a rejected or cancelled purchase order.";
|
||||
const DENY_ERROR = "Adding attachments to this purchase order isn't allowed.";
|
||||
|
||||
let techId: string; // the PO's submitter
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
const userIds: Record<string, string> = {};
|
||||
|
|
@ -66,19 +70,21 @@ function pdf(name: string): File {
|
|||
return new File(["%PDF-1.4 hello"], name, { type: "application/pdf" });
|
||||
}
|
||||
|
||||
// A CLOSED PO submitted by the TECHNICAL user.
|
||||
async function makeClosedPo(title: string): Promise<string> {
|
||||
// A PO submitted by the TECHNICAL user, forced into `status`.
|
||||
async function makePo(title: string, status: POStatus): Promise<string> {
|
||||
as(techId, "TECHNICAL");
|
||||
const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" }));
|
||||
expect(result).not.toHaveProperty("error");
|
||||
const poId = (result as { id: string }).id;
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { status: "CLOSED" } });
|
||||
if (status !== "DRAFT") {
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { status } });
|
||||
}
|
||||
return poId;
|
||||
}
|
||||
|
||||
describe("closed-PO attachments (flag on)", () => {
|
||||
it("lets the PO's own submitter attach to their closed PO", async () => {
|
||||
const poId = await makeClosedPo(`${PREFIX}Submitter`);
|
||||
describe("PO attachment permissions (flag on)", () => {
|
||||
it("lets the PO's own submitter attach to their PO", async () => {
|
||||
const poId = await makePo(`${PREFIX}Submitter`, "CLOSED");
|
||||
as(techId, "TECHNICAL");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("missing-invoice.pdf")]);
|
||||
|
|
@ -91,7 +97,7 @@ describe("closed-PO attachments (flag on)", () => {
|
|||
["MANAGER", "MANAGER"],
|
||||
["SUPERUSER", "SUPERUSER"],
|
||||
])("lets %s attach to a closed PO they did not submit", async (key, role) => {
|
||||
const poId = await makeClosedPo(`${PREFIX}${key}`);
|
||||
const poId = await makePo(`${PREFIX}${key}`, "CLOSED");
|
||||
as(userIds[key], role);
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
|
|
@ -99,29 +105,62 @@ describe("closed-PO attachments (flag on)", () => {
|
|||
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
|
||||
});
|
||||
|
||||
// The headline of this change: not just CLOSED — any live state.
|
||||
it.each<POStatus>(["MGR_REVIEW", "MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "EDITS_REQUESTED"])(
|
||||
"lets Manager attach to a PO in %s",
|
||||
async (status) => {
|
||||
const poId = await makePo(`${PREFIX}${status}`, status);
|
||||
as(userIds.MANAGER, "MANAGER");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
expect(err).toBeNull();
|
||||
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
it.each<POStatus>(["REJECTED", "CANCELLED"])(
|
||||
"refuses attachments to a %s PO, even for Manager",
|
||||
async (status) => {
|
||||
const poId = await makePo(`${PREFIX}${status}`, status);
|
||||
as(userIds.MANAGER, "MANAGER");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
expect(err).toEqual({ error: VOID_ERROR });
|
||||
expect(uploadBuffer).not.toHaveBeenCalled();
|
||||
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it("refuses a voided PO even for its own submitter", async () => {
|
||||
const poId = await makePo(`${PREFIX}SubmitterRejected`, "REJECTED");
|
||||
as(techId, "TECHNICAL");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
expect(err).toEqual({ error: VOID_ERROR });
|
||||
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a submitter-role user who is not this PO's submitter", async () => {
|
||||
const poId = await makeClosedPo(`${PREFIX}OtherSubmitter`);
|
||||
const poId = await makePo(`${PREFIX}OtherSubmitter`, "MGR_APPROVED");
|
||||
as(userIds.MANNING, "MANNING"); // a submitter role, but not the PO's submitter
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
|
||||
expect(err).toEqual({ error: DENY_ERROR });
|
||||
expect(uploadBuffer).not.toHaveBeenCalled();
|
||||
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a role outside the allow-list (auditor)", async () => {
|
||||
const poId = await makeClosedPo(`${PREFIX}Auditor`);
|
||||
const poId = await makePo(`${PREFIX}Auditor`, "CLOSED");
|
||||
as(userIds.AUDITOR, "AUDITOR");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
|
||||
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
|
||||
expect(err).toEqual({ error: DENY_ERROR });
|
||||
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
|
||||
});
|
||||
|
||||
it("still allows uploads to a non-closed PO (normal flow unaffected)", async () => {
|
||||
as(techId, "TECHNICAL");
|
||||
const result = await createPo(makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" }));
|
||||
const poId = (result as { id: string }).id; // stays DRAFT
|
||||
it("still allows the normal create flow (DRAFT submitter)", async () => {
|
||||
const poId = await makePo(`${PREFIX}Draft`, "DRAFT");
|
||||
as(techId, "TECHNICAL");
|
||||
|
||||
const err = await uploadPoDocuments(poId, [pdf("draft-doc.pdf")]);
|
||||
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();
|
||||
});
|
||||
});
|
||||
54
App/tests/unit/po-activity-label.test.ts
Normal file
54
App/tests/unit/po-activity-label.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue