- Add POLineItem.name column; migrate existing description→name; description is now optional - NameCell component: name input with fuzzy product search, description input stacked below - Read-only view shows name prominently, description in subdued text below - All server actions (create, edit, manager edit, import) updated to read/write name - ParsedImportLine.description renamed to .name throughout import parser and form - Seed data updated; CLAUDE.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
333 lines
15 KiB
TypeScript
333 lines
15 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 { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
|
import { generateDownloadUrl } from "@/lib/storage";
|
|
import { TC_FIXED_LINE } 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;
|
|
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 };
|
|
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",
|
|
RECEIPT_CONFIRMED: "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 originalLineItems = managerEditAction
|
|
? (managerEditAction.metadata as { original: typeof lineItemsForEditor } | null)?.original
|
|
: undefined;
|
|
|
|
const downloadUrls = await Promise.all(
|
|
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
|
);
|
|
|
|
const canConfirmReceipt =
|
|
po.status === "PAID_DELIVERED" &&
|
|
(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 || ["MANAGER", "SUPERUSER"].includes(currentRole)) &&
|
|
!readOnly && (
|
|
<DiscardDraftButton poId={po.id} />
|
|
)}
|
|
<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">Manager note</p>
|
|
<p className="text-sm text-warning-700">{po.managerNote}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Order Details */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
|
<div><dt className="text-neutral-500">Vessel</dt><dd className="font-medium text-neutral-900">{po.vessel.name}</dd></div>
|
|
<div><dt className="text-neutral-500">Account / Budget Head</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-6">
|
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Vendor</h3>
|
|
<dl className="grid 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-6">
|
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Line Items</h3>
|
|
<LineItemsEditor items={lineItemsForEditor} readOnly originalItems={originalLineItems} />
|
|
</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 & 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 border-success-100 bg-success-50 p-5 flex items-center justify-between">
|
|
<div>
|
|
<p className="font-medium text-success-700">Payment confirmed</p>
|
|
<p className="text-sm text-success-700 mt-0.5">Please confirm that you have received all items.</p>
|
|
</div>
|
|
<Link
|
|
href={`/po/${po.id}/receipt`}
|
|
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90"
|
|
>
|
|
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>
|
|
);
|
|
}
|