pelagia-portal/App/components/po/po-detail.tsx
Hardik 78afcb610b
Some checks failed
PR checks / checks (pull_request) Failing after 35s
PR checks / integration (pull_request) Successful in 33s
feat(po): TCS & Discount below GST (#133)
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>
2026-06-29 14:50:34 +05:30

633 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &amp; 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>
);
}