- Undo Vessel→Cost Centre rename in admin (admin shows "Vessel Management" again) - Sidebar: "Cost Centres"→"Vessels", "Accounts"→"Accounting Codes" - PO forms (new/edit/import/manager-edit) now show both Vessels (with code) and Sites in the Cost Centre dropdown, encoded as v:<id> / s:<id> via a costCentreRef field - vesselId on PurchaseOrder is now nullable; siteId is set when a site is the cost centre - History, approvals, dashboard, my-orders, payments display vessel.name ?? site.name as Cost Centre - History and approvals cost centre filters use costCentreRef URL param supporting both types - Admin vessel form: adds Site assignment dropdown - Admin accounts: renamed to "Accounting Code" throughout (pages, forms, sidebar) - PO detail and exports: "Account" label renamed to "Accounting Code" - Site detail: "Assigned Vessels (Cost Centres)" heading; vessel detail breadcrumb fixed - Create PO links from vessel/site detail use ?costCentreRef= param - Export routes handle costCentreRef filter param (with legacy vesselId fallback) - DB migration: ALTER TABLE PurchaseOrder ALTER COLUMN vesselId DROP NOT NULL - CLAUDE.md updated with Cost Centre Model documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
10 KiB
TypeScript
217 lines
10 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 { EditSiteButton } from "../site-form";
|
|
import { SiteCharts } from "./site-charts";
|
|
import { ConsumptionForm } from "./consumption-form";
|
|
import type { Metadata } from "next";
|
|
|
|
interface Props { params: Promise<{ id: string }> }
|
|
|
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
const { id } = await params;
|
|
const site = await db.site.findUnique({ where: { id }, select: { name: true } });
|
|
return { title: site?.name ?? "Site Detail" };
|
|
}
|
|
|
|
export default async function SiteDetailPage({ params }: Props) {
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
if (!hasPermission(session.user.role, "manage_sites")) redirect("/dashboard");
|
|
|
|
const { id } = await params;
|
|
|
|
const thirtyDaysAgo = new Date();
|
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
|
|
const [site, products] = await Promise.all([
|
|
db.site.findUnique({
|
|
where: { id },
|
|
include: {
|
|
vessels: { select: { id: true, name: true, isActive: true } },
|
|
inventory: {
|
|
include: { product: { select: { id: true, name: true, code: true } } },
|
|
orderBy: { quantity: "desc" },
|
|
},
|
|
consumption: {
|
|
where: { date: { gte: thirtyDaysAgo } },
|
|
include: { product: { select: { name: true } } },
|
|
orderBy: { date: "asc" },
|
|
},
|
|
purchaseOrders: {
|
|
select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 8,
|
|
},
|
|
},
|
|
}),
|
|
db.product.findMany({ where: { isActive: true }, select: { id: true, name: true, code: true }, orderBy: { name: "asc" } }),
|
|
]);
|
|
|
|
if (!site) notFound();
|
|
|
|
const canEdit = session.user.role === "ADMIN";
|
|
|
|
// Build chart data: inventory bar
|
|
const inventoryChartData = site.inventory.map((inv) => ({
|
|
name: inv.product.name.length > 20 ? inv.product.name.substring(0, 18) + "…" : inv.product.name,
|
|
quantity: Number(inv.quantity),
|
|
}));
|
|
|
|
// Build consumption chart: group by date, sum quantities
|
|
const consumptionByDate = new Map<string, number>();
|
|
for (const c of site.consumption) {
|
|
const key = formatDate(c.date);
|
|
consumptionByDate.set(key, (consumptionByDate.get(key) ?? 0) + Number(c.quantity));
|
|
}
|
|
const consumptionChartData = Array.from(consumptionByDate.entries()).map(([date, qty]) => ({ date, qty }));
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
|
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
|
SUBMITTED: "Submitted", REJECTED: "Rejected",
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-6xl space-y-6">
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
|
<Link href="/admin/sites" className="hover:text-neutral-700">Sites</Link>
|
|
<span>/</span>
|
|
<span className="text-neutral-900 font-medium">{site.name}</span>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-1">
|
|
<span className="font-mono text-xs text-neutral-500">{site.code}</span>
|
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${site.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
|
|
{site.isActive ? "Active" : "Inactive"}
|
|
</span>
|
|
</div>
|
|
<h1 className="text-2xl font-semibold text-neutral-900">{site.name}</h1>
|
|
{site.address && <p className="mt-1 text-sm text-neutral-500">{site.address}</p>}
|
|
{site.latitude && site.longitude && (
|
|
<p className="text-xs text-neutral-400 mt-0.5">{site.latitude.toFixed(5)}, {site.longitude.toFixed(5)}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Link href={`/po/new?costCentreRef=s:${site.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
|
+ Create PO
|
|
</Link>
|
|
{canEdit && <EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-3 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">Assigned Vessels</p>
|
|
<p className="text-2xl font-semibold text-neutral-900">{site.vessels.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">Items Tracked</p>
|
|
<p className="text-2xl font-semibold text-neutral-900">{site.inventory.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">POs (all time)</p>
|
|
<p className="text-2xl font-semibold text-neutral-900">{site.purchaseOrders.length}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts */}
|
|
{(inventoryChartData.length > 0 || consumptionChartData.length > 0) && (
|
|
<SiteCharts inventoryData={inventoryChartData} consumptionData={consumptionChartData} />
|
|
)}
|
|
|
|
{/* Inventory table */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Inventory at this site</h2>
|
|
{site.inventory.length === 0 ? (
|
|
<p className="text-sm text-neutral-400 italic">No inventory tracked yet. Updated automatically when POs are delivered here.</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">Item</th>
|
|
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th>
|
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Qty on hand</th>
|
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-neutral-100">
|
|
{site.inventory.map((inv) => (
|
|
<tr key={inv.id}>
|
|
<td className="py-2 pr-4">
|
|
<Link href={`/admin/products/${inv.product.id}`} className="font-medium text-primary-600 hover:underline">
|
|
{inv.product.name}
|
|
</Link>
|
|
</td>
|
|
<td className="py-2 pl-4 font-mono text-xs text-neutral-500">{inv.product.code}</td>
|
|
<td className="py-2 pl-4 text-right font-semibold text-neutral-900">{Number(inv.quantity)}</td>
|
|
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(inv.updatedAt)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* Record consumption */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Record Daily Consumption</h2>
|
|
<ConsumptionForm siteId={site.id} products={products} />
|
|
</div>
|
|
|
|
{/* Assigned vessels */}
|
|
{site.vessels.length > 0 && (
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Assigned Vessels (Cost Centres)</h2>
|
|
<div className="flex flex-wrap gap-2">
|
|
{site.vessels.map((v) => (
|
|
<Link key={v.id} href={`/admin/vessels/${v.id}`}
|
|
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
|
{v.name}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent POs */}
|
|
{site.purchaseOrders.length > 0 && (
|
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Recent Purchase Orders</h2>
|
|
<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">
|
|
{site.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>
|
|
);
|
|
}
|