pelagia-portal/App/components/po/po-detail.tsx
Hardik cc7251e6b7 feat: Cost Centre covers vessels and sites, vessel codes, Accounting Code rename, vessel-site assignment
- Undo Vessel→Cost Centre rename in admin (admin shows "Vessel Management" again)
- Sidebar: "Cost Centres"→"Vessels", "Accounts"→"Accounting Codes"
- PO forms (new/edit/import/manager-edit) now show both Vessels (with code) and Sites in the
  Cost Centre dropdown, encoded as v:<id> / s:<id> via a costCentreRef field
- vesselId on PurchaseOrder is now nullable; siteId is set when a site is the cost centre
- History, approvals, dashboard, my-orders, payments display vessel.name ?? site.name as Cost Centre
- History and approvals cost centre filters use costCentreRef URL param supporting both types
- Admin vessel form: adds Site assignment dropdown
- Admin accounts: renamed to "Accounting Code" throughout (pages, forms, sidebar)
- PO detail and exports: "Account" label renamed to "Accounting Code"
- Site detail: "Assigned Vessels (Cost Centres)" heading; vessel detail breadcrumb fixed
- Create PO links from vessel/site detail use ?costCentreRef= param
- Export routes handle costCentreRef filter param (with legacy vesselId fallback)
- DB migration: ALTER TABLE PurchaseOrder ALTER COLUMN vesselId DROP NOT NULL
- CLAUDE.md updated with Cost Centre Model documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 03:04:29 +05:30

469 lines
22 KiB
TypeScript

import Link from "next/link";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client";
type PoWithRelations = {
id: string;
poNumber: string;
title: string;
status: import("@prisma/client").POStatus;
totalAmount: import("@prisma/client").Prisma.Decimal;
currency: string;
projectCode: string | null;
dateRequired: Date | null;
managerNote: string | null;
paymentRef: string | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
requisitionNo?: string | null;
requisitionDate?: Date | null;
placeOfDelivery?: string | null;
tcDelivery?: string | null;
tcDispatch?: string | null;
tcInspection?: string | null;
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
createdAt: Date;
submittedAt: Date | null;
approvedAt: Date | null;
paidAt: Date | null;
closedAt: Date | null;
submitter: { id: string; name: string; email: string };
vessel: { id: string; name: string } | null;
site?: { id: string; name: string } | null;
account: { id: string; name: string; code: string };
vendor: {
id: string;
name: string;
vendorId: string | null;
address?: string | null;
gstin?: string | null;
contactName?: string | null;
contactMobile?: string | null;
contactEmail?: string | null;
} | null;
lineItems: {
id: string;
name: string;
description?: string | null;
quantity: import("@prisma/client").Prisma.Decimal;
unit: string;
size?: string | null;
unitPrice: import("@prisma/client").Prisma.Decimal;
totalPrice: import("@prisma/client").Prisma.Decimal;
gstRate?: import("@prisma/client").Prisma.Decimal | null;
sortOrder: number;
}[];
documents: { id: string; fileName: string; fileSize: number; storageKey: string; uploadedAt: Date }[];
actions: { id: string; actionType: string; note: string | null; metadata: import("@prisma/client").Prisma.JsonValue; createdAt: Date; actor: { name: string } }[];
};
interface Props {
po: PoWithRelations;
currentUserId: string;
currentRole: Role;
readOnly?: boolean;
}
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",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name,
description: li.description ?? undefined,
quantity: Number(li.quantity),
unit: li.unit,
size: li.size ?? undefined,
unitPrice: Number(li.unitPrice),
gstRate: li.gstRate != null ? Number(li.gstRate) : 0.18,
}));
const managerEditAction = [...po.actions]
.reverse()
.find((a) => a.actionType === "MANAGER_LINE_EDIT");
const noteAction = [...po.actions]
.reverse()
.find((a) =>
["EDITS_REQUESTED", "REJECTED", "APPROVED", "APPROVED_WITH_NOTE"].includes(a.actionType) &&
a.note
);
const managerNoteAuthor = noteAction?.actor.name ?? null;
// Resubmit snapshot: stored in the most recent SUBMITTED action's metadata
// when the submitter resubmits after EDITS_REQUESTED.
type ResubmitSnapshot = {
lineItems: LineItemInput[];
fields: {
title: string;
vessel: string | null; vesselId: string;
account: string; accountId: string;
vendor: string | null; vendorId: string | null;
projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
};
};
const resubmitAction = [...po.actions]
.reverse()
.find(
(a) =>
a.actionType === "SUBMITTED" &&
!!(a.metadata as { editSnapshot?: unknown } | null)?.editSnapshot
);
const resubmitSnapshot = resubmitAction
? (resubmitAction.metadata as { editSnapshot: ResubmitSnapshot }).editSnapshot
: null;
// Resubmit snapshot takes priority over manager line edit diff.
const originalLineItems: LineItemInput[] | undefined = resubmitSnapshot?.lineItems
?? (managerEditAction
? (managerEditAction.metadata as { original: typeof lineItemsForEditor } | null)?.original
: undefined);
const lineItemsDiffLabel = resubmitSnapshot
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
: "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
const downloadUrls = await Promise.all(
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
);
const canConfirmReceipt =
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
!readOnly;
// Find the approver from actions
const approvalAction = [...po.actions]
.reverse()
.find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-sm text-neutral-500">{po.poNumber}</span>
<PoStatusBadge status={po.status} />
</div>
<h2 className="text-xl font-semibold text-neutral-900">{po.title}</h2>
</div>
<div className="flex items-center gap-2 flex-wrap">
{["DRAFT", "EDITS_REQUESTED"].includes(po.status) &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
!readOnly && (
<Link
href={`/po/${po.id}/edit`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Edit
</Link>
)}
{po.status === "DRAFT" &&
(po.submitter.id === currentUserId || currentRole === "SUPERUSER") &&
!readOnly && (
<SubmitDraftButton poId={po.id} />
)}
{po.status === "DRAFT" &&
(po.submitter.id === currentUserId || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
!readOnly && (
<DiscardDraftButton poId={po.id} />
)}
{/* Export buttons — only available once the PO has been approved by a manager */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
<a
href={`/api/po/${po.id}/export?format=pdf`}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Export PDF
</a>
<a
href={`/api/po/${po.id}/export?format=xlsx`}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Export XLSX
</a>
</>)}
</div>
</div>
{/* Manager note banner */}
{po.managerNote && (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
<p className="text-sm font-medium text-warning-700 mb-0.5">
{managerNoteAuthor ? `Note from ${managerNoteAuthor}` : "Manager note"}
</p>
<p className="text-sm text-warning-700">{po.managerNote}</p>
</div>
)}
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
{resubmitSnapshot &&
po.status === "MGR_REVIEW" &&
(currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => {
const snap = resubmitSnapshot.fields;
const currentVessel = po.vessel?.name ?? po.site?.name ?? null;
const currentAccount = `${po.account.name} (${po.account.code})`;
const currentVendor = po.vendor?.name ?? null;
const currentDateRequired = po.dateRequired?.toISOString() ?? null;
const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
if (snap.title !== po.title)
fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
if (snap.vesselId !== (po.vessel?.id ?? po.site?.id ?? ""))
fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel });
if (snap.accountId !== po.account.id)
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
if (snap.vendorId !== (po.vendor?.id ?? null))
fieldChanges.push({ label: "Vendor", before: snap.vendor ?? "None", after: currentVendor ?? "None" });
if (snap.projectCode !== po.projectCode)
fieldChanges.push({ label: "Project Code", before: snap.projectCode ?? "—", after: po.projectCode ?? "—" });
if (snap.dateRequired !== currentDateRequired)
fieldChanges.push({
label: "Date Required",
before: snap.dateRequired ? formatDate(snap.dateRequired) : "—",
after: po.dateRequired ? formatDate(po.dateRequired) : "—",
});
if (snap.placeOfDelivery !== po.placeOfDelivery)
fieldChanges.push({ label: "Place of Delivery", before: snap.placeOfDelivery ?? "—", after: po.placeOfDelivery ?? "—" });
if (fieldChanges.length === 0) return null;
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p className="text-sm font-semibold text-amber-800 mb-2">
Submitter updated the following fields after edits were requested
</p>
<table className="w-full text-xs">
<thead>
<tr className="border-b border-amber-200">
<th className="pb-1.5 text-left font-medium text-amber-700 w-32">Field</th>
<th className="pb-1.5 text-left font-medium text-amber-700 pl-4">Before</th>
<th className="pb-1.5 text-left font-medium text-amber-700 pl-4">After</th>
</tr>
</thead>
<tbody className="divide-y divide-amber-100">
{fieldChanges.map(({ label, before, after }) => (
<tr key={label}>
<td className="py-1.5 font-medium text-amber-700">{label}</td>
<td className="py-1.5 pl-4 text-neutral-500 line-through">{before}</td>
<td className="py-1.5 pl-4 font-medium text-amber-900">{after}</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
{/* Order Details */}
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div><dt className="text-neutral-500">Cost Centre</dt><dd className="font-medium text-neutral-900">{po.vessel?.name ?? po.site?.name ?? "—"}</dd></div>
<div><dt className="text-neutral-500">Accounting Code</dt><dd className="font-medium text-neutral-900">{po.account.name} ({po.account.code})</dd></div>
<div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
{approvalAction && (
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
)}
{po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>}
{po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>}
{po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>}
{po.piQuotationDate && <div><dt className="text-neutral-500">PI / Quotation Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.piQuotationDate)}</dd></div>}
{po.requisitionNo && <div><dt className="text-neutral-500">Requisition No.</dt><dd className="font-medium text-neutral-900">{po.requisitionNo}</dd></div>}
{po.requisitionDate && <div><dt className="text-neutral-500">Requisition Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.requisitionDate)}</dd></div>}
{po.paymentRef && <div><dt className="text-neutral-500">Payment Ref</dt><dd className="font-mono text-sm text-neutral-900">{po.paymentRef}</dd></div>}
</dl>
{po.placeOfDelivery && (
<div className="mt-3 pt-3 border-t border-neutral-100 text-sm">
<dt className="text-neutral-500 mb-0.5">Place of Delivery</dt>
<dd className="font-medium text-neutral-900 whitespace-pre-wrap">{po.placeOfDelivery}</dd>
</div>
)}
</div>
{/* Vendor */}
{po.vendor ? (
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Vendor</h3>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><dt className="text-neutral-500">Name</dt><dd className="font-medium text-neutral-900">{po.vendor.name}</dd></div>
<div>
<dt className="text-neutral-500">Vendor ID</dt>
<dd className="font-medium text-neutral-900">
{po.vendor.vendorId ?? (
<span className="inline-flex items-center rounded-full bg-warning-50 px-2 py-0.5 text-xs font-medium text-warning-700 ring-1 ring-inset ring-warning-200">
Not assigned
</span>
)}
</dd>
</div>
{po.vendor.gstin && (
<div>
<dt className="text-neutral-500">GSTIN</dt>
<dd className="font-medium text-neutral-900 font-mono tracking-wide text-sm">{po.vendor.gstin}</dd>
</div>
)}
{po.vendor.address && <div className="col-span-2"><dt className="text-neutral-500">Address</dt><dd className="font-medium text-neutral-900 whitespace-pre-wrap">{po.vendor.address}</dd></div>}
{(po.vendor.contactName || po.vendor.contactMobile || po.vendor.contactEmail) && (
<div className="col-span-2">
<dt className="text-neutral-500">Contact</dt>
<dd className="font-medium text-neutral-900">
{[po.vendor.contactName, po.vendor.contactMobile, po.vendor.contactEmail].filter(Boolean).join(" · ")}
</dd>
</div>
)}
</dl>
</div>
) : (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
<p className="text-sm text-warning-700">No vendor assigned to this PO.</p>
</div>
)}
{/* Line Items */}
<div className="rounded-lg border border-neutral-200 bg-white p-3 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Line Items</h3>
<LineItemsEditor
items={lineItemsForEditor}
readOnly
originalItems={originalLineItems}
originalItemsLabel={lineItemsDiffLabel}
/>
</div>
{/* Terms & Conditions */}
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
<li className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">1.</span>
<span>{TC_FIXED_LINE}</span>
</li>
{([
{ n: 2, label: "DELIVERY", value: po.tcDelivery },
{ n: 3, label: "DISPATCH INSTRUCTIONS", value: po.tcDispatch },
{ n: 4, label: "INSPECTION", value: po.tcInspection },
{ n: 5, label: "TRANSIT INSURANCE", value: po.tcTransitInsurance },
{ n: 6, label: "PAYMENT TERMS", value: po.tcPaymentTerms },
{ n: 7, label: "OTHERS", value: po.tcOthers },
] as const).filter(({ value }) => value).map(({ n, label, value }) => (
<li key={n} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{n}.</span>
<span><span className="font-medium">{label}:</span> {value}</span>
</li>
))}
</ol>
</div>
)}
{/* Documents */}
{po.documents.length > 0 && (
<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>
<ul className="space-y-2">
{po.documents.map((doc, i) => (
<li key={doc.id} className="flex items-center gap-3 text-sm">
<a
href={downloadUrls[i]}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-primary-600 hover:underline"
>
{doc.fileName}
</a>
<span className="text-neutral-400 text-xs">
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
</span>
</li>
))}
</ul>
</div>
)}
{/* Confirm receipt CTA */}
{canConfirmReceipt && (
<div className={`rounded-lg border p-5 flex items-center justify-between flex-wrap gap-3 ${
po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID"
? "border-warning-100 bg-warning-50"
: "border-success-100 bg-success-50"
}`}>
<div>
<p className={`font-medium ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
{po.status === "PARTIALLY_CLOSED"
? "Partially received"
: po.status === "PARTIALLY_PAID"
? "Advance payment received"
: "Payment confirmed"}
</p>
<p className={`text-sm mt-0.5 ${po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "text-warning-700" : "text-success-700"}`}>
{po.status === "PARTIALLY_CLOSED"
? "Some items are still outstanding. Confirm remaining deliveries."
: po.status === "PARTIALLY_PAID"
? `Advance payment received (${formatCurrency(Number(po.paidAmount ?? 0), po.currency)} of ${formatCurrency(Number(po.totalAmount), po.currency)}). Items can be received now — PO closes when fully paid and delivered.`
: "Please confirm that you have received all items."}
</p>
</div>
<Link
href={`/po/${po.id}/receipt`}
className={`rounded-lg px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 ${
po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID" ? "bg-warning-600" : "bg-success"
}`}
>
{po.status === "PARTIALLY_CLOSED" ? "Confirm Remaining" : "Confirm Receipt"}
</Link>
</div>
)}
{/* Audit trail */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Activity</h3>
<ol className="relative border-l border-neutral-200 ml-2 space-y-4">
{po.actions.map((action) => (
<li key={action.id} className="pl-5">
<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}
</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>
</div>
{action.note && (
<p className="mt-1 text-sm text-neutral-600 italic">"{action.note}"</p>
)}
</li>
))}
</ol>
</div>
</div>
);
}