Adds two PO-level charges shown below GST, per issue #133 ask 2. - Stored as ABSOLUTE rupee amounts on PurchaseOrder.tcsAmount / discountAmount (Decimal?, default 0; null/0 on historical & imported POs). Migration added. - Discount is applied post-GST. totalAmount folds the charges in (net payable = subtotal + GST + TCS − Discount), so payments / reports / advance all use the true amount due. lib/po-money.ts is the single source of truth. - Forms (create + edit) render a shared TcsDiscountFields with a % control bidirectionally linked to the rupee value (percentage is convenience only, taken against the GST-inclusive total; only the absolute amount is persisted). - createPo / updatePo store & compute; both manager-edit actions PRESERVE the PO's TCS/Discount when recomputing the total; import leaves them at 0. - PO detail shows TCS / Discount / Net payable below GST; PDF + XLSX export show the same breakdown and a corrected grand total. Tests: lib/po-money unit tests; po-tcs-discount integration test (create / edit / manager-line-edit preservation). Docs: CLAUDE.md GST section + wiki Purchase Orders (TCS/Discount + a full "what import sets vs. not" field-mapping table). Full unit (360) + integration (305) suites green; tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
633 lines
30 KiB
TypeScript
633 lines
30 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 { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
||
import { EmailVendorButton } from "@/components/po/email-vendor-button";
|
||
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 { 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";
|
||
|
||
type PoWithRelations = {
|
||
id: string;
|
||
poNumber: string;
|
||
title: string;
|
||
status: import("@prisma/client").POStatus;
|
||
totalAmount: import("@prisma/client").Prisma.Decimal;
|
||
tcsAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||
discountAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||
currency: string;
|
||
poDate: Date | null;
|
||
projectCode: string | null;
|
||
dateRequired: Date | null;
|
||
managerNote: string | null;
|
||
paymentRef: string | null;
|
||
paymentDate?: Date | null;
|
||
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||
suggestedAdvancePayment?: 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;
|
||
terms?: import("@prisma/client").Prisma.JsonValue;
|
||
createdAt: Date;
|
||
submittedAt: Date | null;
|
||
approvedAt: Date | null;
|
||
paidAt: Date | null;
|
||
closedAt: Date | null;
|
||
cancelledAt?: Date | null;
|
||
cancellationReason?: string | null;
|
||
supersededBy?: { id: string; poNumber: string } | null;
|
||
supersedes?: { id: string; poNumber: string }[];
|
||
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;
|
||
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
|
||
vendorEmail?: string | null;
|
||
}
|
||
|
||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: 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;
|
||
poDate: 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 docsWithUrls = await Promise.all(
|
||
po.documents.map(async (doc) => ({
|
||
...doc,
|
||
url: await generateDownloadUrl(doc.storageKey),
|
||
}))
|
||
);
|
||
const attachmentGroups = groupAttachments(docsWithUrls);
|
||
|
||
// PO-level charges shown below GST (#133). totalAmount already folds these in.
|
||
const tcsAmount = Number(po.tcsAmount ?? 0);
|
||
const discountAmount = Number(po.discountAmount ?? 0);
|
||
const hasCharges = tcsAmount > 0 || discountAmount > 0;
|
||
const itemsInclGst = po.lineItems.reduce(
|
||
(s, li) => s + Number(li.quantity) * Number(li.unitPrice) * (1 + Number(li.gstRate ?? 0.18)),
|
||
0
|
||
);
|
||
|
||
// 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 &&
|
||
canAddPoAttachment(currentRole, po.status, { isSubmitter: po.submitter.id === currentUserId });
|
||
|
||
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");
|
||
|
||
// PO date: submitter-set date → approved date → creation date
|
||
const poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt;
|
||
|
||
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} />
|
||
)}
|
||
{/* 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
|
||
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>
|
||
</>)}
|
||
{/* Email to vendor — approved (not cancelled) + vendor has a contact email (issue #14) */}
|
||
{!readOnly && vendorEmail &&
|
||
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (
|
||
<EmailVendorButton poId={po.id} />
|
||
)}
|
||
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
|
||
{po.status !== "CANCELLED" &&
|
||
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
||
!readOnly && (
|
||
<CancelPoButton poId={po.id} poNumber={po.poNumber} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
|
||
{po.status === "CANCELLED" && (
|
||
<div className="rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
|
||
<p className="text-sm font-semibold text-danger-700">
|
||
Cancelled{po.cancelledAt ? ` on ${formatDate(po.cancelledAt)}` : ""}
|
||
</p>
|
||
{po.cancellationReason && (
|
||
<p className="mt-0.5 text-sm text-danger-700">Reason: {po.cancellationReason}</p>
|
||
)}
|
||
<div className="mt-2 text-sm text-danger-700">
|
||
{po.supersededBy ? (
|
||
<p>
|
||
Superseded by{" "}
|
||
<Link href={`/po/${po.supersededBy.id}`} className="font-mono font-medium underline">
|
||
{po.supersededBy.poNumber}
|
||
</Link>
|
||
</p>
|
||
) : ["MANAGER", "SUPERUSER"].includes(currentRole) && !readOnly ? (
|
||
<div>
|
||
<p className="text-danger-700/80">Optionally link the PO that replaces this one:</p>
|
||
<SupersedeForm poId={po.id} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reciprocal "supersedes" link — shown on the replacement PO */}
|
||
{po.supersedes && po.supersedes.length > 0 && (
|
||
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3">
|
||
<p className="text-sm text-neutral-700">
|
||
Supersedes{" "}
|
||
{po.supersedes.map((s, i) => (
|
||
<span key={s.id}>
|
||
{i > 0 && ", "}
|
||
<Link href={`/po/${s.id}`} className="font-mono font-medium text-primary-600 underline">
|
||
{s.poNumber}
|
||
</Link>
|
||
</span>
|
||
))}
|
||
</p>
|
||
</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>
|
||
)}
|
||
|
||
{/* Manager's advance-payment decision (issue #92) — a partial advance set
|
||
at approval. Shown to Accounts/Manager from approval through payment. */}
|
||
{po.suggestedAdvancePayment != null &&
|
||
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) &&
|
||
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && (
|
||
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3">
|
||
<p className="text-sm font-medium text-primary-700 mb-0.5">Advance payment requested</p>
|
||
<p className="text-sm text-primary-700">
|
||
Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "}
|
||
{formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual
|
||
part-payment flow.
|
||
</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 ?? null;
|
||
const currentAccount = `${po.account.name} (${po.account.code})`;
|
||
const currentVendor = po.vendor?.name ?? null;
|
||
const currentDateRequired = po.dateRequired?.toISOString() ?? null;
|
||
|
||
const currentPoDate = po.poDate?.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)
|
||
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.poDate ?? null) !== currentPoDate)
|
||
fieldChanges.push({
|
||
label: "PO Date",
|
||
before: snap.poDate ? formatDate(snap.poDate) : "—",
|
||
after: po.poDate ? formatDate(po.poDate) : "—",
|
||
});
|
||
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 ?? "—"}</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>
|
||
)}
|
||
<div><dt className="text-neutral-500">PO Date</dt><dd className="font-medium text-neutral-900">{formatDate(poDisplayDate)}</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>}
|
||
{(po.paymentDate || po.paidAt) && <div><dt className="text-neutral-500">Payment Date</dt><dd className="font-medium text-neutral-900">{formatDate((po.paymentDate ?? po.paidAt)!)}</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}
|
||
/>
|
||
{hasCharges && (
|
||
<dl className="mt-4 ml-auto w-full max-w-xs space-y-1 text-sm">
|
||
<div className="flex justify-between">
|
||
<dt className="text-neutral-500">Total (incl. GST)</dt>
|
||
<dd className="text-neutral-700">{formatCurrency(itemsInclGst, po.currency)}</dd>
|
||
</div>
|
||
{tcsAmount > 0 && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-neutral-500">TCS</dt>
|
||
<dd className="text-neutral-700">+ {formatCurrency(tcsAmount, po.currency)}</dd>
|
||
</div>
|
||
)}
|
||
{discountAmount > 0 && (
|
||
<div className="flex justify-between">
|
||
<dt className="text-neutral-500">Discount</dt>
|
||
<dd className="text-neutral-700">− {formatCurrency(discountAmount, po.currency)}</dd>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between border-t border-neutral-100 pt-1 font-semibold text-neutral-900">
|
||
<dt>Net payable</dt>
|
||
<dd>{formatCurrency(Number(po.totalAmount), po.currency)}</dd>
|
||
</div>
|
||
</dl>
|
||
)}
|
||
</div>
|
||
|
||
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
|
||
{(() => {
|
||
const saved = parsePoTerms(po.terms);
|
||
const rows: { label: string; text: string }[] =
|
||
saved.length > 0
|
||
? saved.map((t) => ({ label: (t.category || "").toUpperCase(), text: t.text }))
|
||
: [
|
||
{ label: "", text: TC_FIXED_LINE },
|
||
...([
|
||
["DELIVERY", po.tcDelivery],
|
||
["DISPATCH INSTRUCTIONS", po.tcDispatch],
|
||
["INSPECTION", po.tcInspection],
|
||
["TRANSIT INSURANCE", po.tcTransitInsurance],
|
||
["PAYMENT TERMS", po.tcPaymentTerms],
|
||
["OTHERS", po.tcOthers],
|
||
] as const)
|
||
.filter(([, value]) => value)
|
||
.map(([label, value]) => ({ label, text: value as string })),
|
||
];
|
||
// Only the fixed line and nothing else → treat as "no T&C" (legacy empty PO).
|
||
if (saved.length === 0 && rows.length <= 1) return null;
|
||
return (
|
||
<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 }}>
|
||
{rows.map((r, i) => (
|
||
<li key={i} className="flex gap-2">
|
||
<span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span>
|
||
<span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||
{(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 && (
|
||
<p className="text-sm text-neutral-400">No attachments yet.</p>
|
||
)}
|
||
<div className="space-y-5">
|
||
{attachmentGroups.map((group) => (
|
||
<div key={group.meta.key}>
|
||
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||
{group.meta.label}
|
||
<span className="ml-1.5 font-normal text-neutral-400">({group.items.length})</span>
|
||
</h4>
|
||
{group.meta.description && (
|
||
<p className="mt-0.5 text-xs text-neutral-400">{group.meta.description}</p>
|
||
)}
|
||
<ul className="mt-2 space-y-2">
|
||
{group.items.map((doc) => (
|
||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||
<a
|
||
href={doc.url}
|
||
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>
|
||
))}
|
||
</div>
|
||
{canAddAttachment && <PoAttachmentUploader poId={po.id} />}
|
||
</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">
|
||
{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>
|
||
</div>
|
||
{action.note && (
|
||
<p className="mt-1 text-sm text-neutral-600 italic">"{action.note}"</p>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|