Cost Centre on PO forms now shows only Vessels (plain vesselId field). Sites are a separate concept and not selectable as cost centres. - PurchaseOrder.vesselId is required again (NOT NULL restored) - Vessel.siteId and vessel->site relation removed from schema - DB migration: drops Vessel.siteId column, restores PO.vesselId NOT NULL - All PO forms (new/edit/import/manager-edit): plain vessel <select> with code-prefixed labels (e.g. "HNR1 — HNR 1") - History, approvals, dashboard, my-orders, payments: back to vesselId filter params and po.vessel.name display - Admin vessels: removed Site column and site-assignment dropdown - Admin sites detail page: removed "Assigned Vessels" section - Sites table: removed Vessels count column (no longer linked) - seed-prod.ts and seed.ts: vessels created without siteId - SearchableSelect accounting code picker retained from previous commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
110 lines
5 KiB
TypeScript
110 lines
5 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
|
import type { Metadata } from "next";
|
|
|
|
interface Props { params: Promise<{ id: string }> }
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
|
|
return { title: v?.name ?? "Vessel Detail" };
|
|
}
|
|
|
|
export default async function VesselDetailPage({ params }: Props) {
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
if (!hasPermission(session.user.role, "manage_sites") && !hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
|
|
|
const { id } = await params;
|
|
|
|
const vessel = await db.vessel.findUnique({
|
|
where: { id },
|
|
include: {
|
|
purchaseOrders: {
|
|
select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 10,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!vessel) notFound();
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
|
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
|
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
|
};
|
|
|
|
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
|
.reduce((s, p) => s + Number(p.totalAmount), 0);
|
|
|
|
return (
|
|
<div className="max-w-5xl space-y-6">
|
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
|
<Link href="/admin/vessels" className="hover:text-neutral-700">Vessels</Link>
|
|
<span>/</span>
|
|
<span className="text-neutral-900 font-medium">{vessel.name}</span>
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${vessel.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
|
|
{vessel.isActive ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-neutral-900">{vessel.name}</h1>
|
|
</div>
|
|
<Link href={`/po/new?vesselId=${vessel.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
|
+ Create PO
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
|
<p className="text-xs text-neutral-500 mb-1">Total POs</p>
|
|
<p className="text-2xl font-semibold text-neutral-900">{vessel.purchaseOrders.length}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
|
<p className="text-xs text-neutral-500 mb-1">Total Spend (closed)</p>
|
|
<p className="text-2xl font-semibold text-neutral-900">{formatCurrency(totalSpend)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Purchase Orders</h2>
|
|
{vessel.purchaseOrders.length === 0 ? (
|
|
<p className="text-sm text-neutral-400 italic">No POs yet.</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-neutral-200">
|
|
<th className="pb-2 text-left font-medium text-neutral-600">PO</th>
|
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Vendor</th>
|
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Status</th>
|
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Amount</th>
|
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-neutral-100">
|
|
{vessel.purchaseOrders.map((po) => (
|
|
<tr key={po.id}>
|
|
<td className="py-2 pr-4"><Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">{po.poNumber}</Link></td>
|
|
<td className="py-2 pl-4 text-neutral-600">{po.vendor?.name ?? <span className="italic text-neutral-400">—</span>}</td>
|
|
<td className="py-2 pl-4 text-neutral-600">{STATUS_LABELS[po.status] ?? po.status}</td>
|
|
<td className="py-2 pl-4 text-right">{formatCurrency(Number(po.totalAmount))}</td>
|
|
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(po.createdAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|