pelagia-portal/App/components/po/po-detail.tsx
Claude (auto-fix) 4e6175153d fix(po): show all attachments grouped by type on PO details
All PO attachments are stored as PODocument rows whose lifecycle stage
(submission vs delivery) is encoded in the storageKey prefix. The PO
details screen previously listed them in a single flat "Attachments"
block, giving no indication of which were submission documents (invoice,
quotation) versus delivery receipts.

Add lib/attachments.ts to derive a user-facing group from the storageKey
prefix (submission / payment / delivery / other) and render each
non-empty group as a labelled subsection on the PO details screen, in
lifecycle order. Unknown prefixes fall back to an "Other" group so
nothing is ever hidden.

Fixes #10
2026-06-19 04:43:44 +05:30

501 lines
24 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 { groupAttachments } from "@/lib/attachments";
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;
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;
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",
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;
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);
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} />
)}
{/* 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 ?? 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}
/>
</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 — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.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>
<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>
</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>
);
}