refactor: revert cost centre to vessels only, remove vessel-site link

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>
This commit is contained in:
Hardik 2026-05-30 18:14:24 +05:30
parent 565f9d5833
commit 280966a369
39 changed files with 190 additions and 424 deletions

View file

@ -31,7 +31,6 @@ export default async function SiteDetailPage({ params }: Props) {
db.site.findUnique({ db.site.findUnique({
where: { id }, where: { id },
include: { include: {
vessels: { select: { id: true, name: true, isActive: true } },
inventory: { inventory: {
include: { product: { select: { id: true, name: true, code: true } } }, include: { product: { select: { id: true, name: true, code: true } } },
orderBy: { quantity: "desc" }, orderBy: { quantity: "desc" },
@ -100,7 +99,7 @@ export default async function SiteDetailPage({ params }: Props) {
)} )}
</div> </div>
<div className="flex gap-2"> <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"> <Link href={`/po/new`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Create PO + Create PO
</Link> </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 }} />} {canEdit && <EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />}
@ -108,11 +107,7 @@ export default async function SiteDetailPage({ params }: Props) {
</div> </div>
{/* Summary cards */} {/* Summary cards */}
<div className="grid grid-cols-3 gap-4"> <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">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"> <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-xs text-neutral-500 mb-1">Items Tracked</p>
<p className="text-2xl font-semibold text-neutral-900">{site.inventory.length}</p> <p className="text-2xl font-semibold text-neutral-900">{site.inventory.length}</p>
@ -167,20 +162,6 @@ export default async function SiteDetailPage({ params }: Props) {
<ConsumptionForm siteId={site.id} products={products} /> <ConsumptionForm siteId={site.id} products={products} />
</div> </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 */} {/* Recent POs */}
{site.purchaseOrders.length > 0 && ( {site.purchaseOrders.length > 0 && (

View file

@ -85,7 +85,6 @@ export async function deleteSite(id: string): Promise<Result> {
await tx.purchaseOrder.updateMany({ where: { siteId: id, status: "DRAFT" }, data: { siteId: null } }); await tx.purchaseOrder.updateMany({ where: { siteId: id, status: "DRAFT" }, data: { siteId: null } });
await tx.itemConsumption.deleteMany({ where: { siteId: id } }); await tx.itemConsumption.deleteMany({ where: { siteId: id } });
await tx.itemInventory.deleteMany({ where: { siteId: id } }); await tx.itemInventory.deleteMany({ where: { siteId: id } });
await tx.vessel.updateMany({ where: { siteId: id }, data: { siteId: null } });
await tx.site.delete({ where: { id } }); await tx.site.delete({ where: { id } });
}); });

View file

@ -15,7 +15,7 @@ export default async function SitesPage() {
const sites = await db.site.findMany({ const sites = await db.site.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
_count: { select: { vessels: true, inventory: true } }, _count: { select: { inventory: true } },
}, },
}); });
@ -32,7 +32,6 @@ export default async function SitesPage() {
latitude: s.latitude ?? null, latitude: s.latitude ?? null,
longitude: s.longitude ?? null, longitude: s.longitude ?? null,
isActive: s.isActive, isActive: s.isActive,
vesselCount: s._count.vessels,
inventoryCount: s._count.inventory, inventoryCount: s._count.inventory,
}))} }))}
/> />

View file

@ -18,7 +18,6 @@ export type SiteRow = {
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
isActive: boolean; isActive: boolean;
vesselCount: number;
inventoryCount: number; inventoryCount: number;
}; };
@ -123,7 +122,6 @@ export function SitesTable({
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Name</SortableTh> <SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Name</SortableTh>
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Code</SortableTh> <SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Code</SortableTh>
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Address</SortableTh> <SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Address</SortableTh>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vessels</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th> <th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th> <th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh> <SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
@ -133,7 +131,7 @@ export function SitesTable({
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={canEdit ? 8 : 7} className="px-4 py-8 text-center text-neutral-400"> <td colSpan={canEdit ? 7 : 6} className="px-4 py-8 text-center text-neutral-400">
No sites match your search. No sites match your search.
</td> </td>
</tr> </tr>
@ -149,9 +147,6 @@ export function SitesTable({
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate"> <td className="px-4 py-3 text-neutral-500 max-w-xs truncate">
{site.address ?? <span className="italic text-neutral-400"></span>} {site.address ?? <span className="italic text-neutral-400"></span>}
</td> </td>
<td className="px-4 py-3 text-right text-neutral-600">
{site.vesselCount || <span className="text-neutral-400"></span>}
</td>
<td className="px-4 py-3 text-right text-neutral-600"> <td className="px-4 py-3 text-right text-neutral-600">
{site.inventoryCount || <span className="text-neutral-400"></span>} {site.inventoryCount || <span className="text-neutral-400"></span>}
</td> </td>

View file

@ -24,7 +24,6 @@ export default async function VesselDetailPage({ params }: Props) {
const vessel = await db.vessel.findUnique({ const vessel = await db.vessel.findUnique({
where: { id }, where: { id },
include: { include: {
site: true,
purchaseOrders: { purchaseOrders: {
select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } }, select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
@ -60,13 +59,8 @@ export default async function VesselDetailPage({ params }: Props) {
</span> </span>
</div> </div>
<h1 className="text-2xl font-semibold text-neutral-900">{vessel.name}</h1> <h1 className="text-2xl font-semibold text-neutral-900">{vessel.name}</h1>
{vessel.site && (
<p className="mt-1 text-sm text-neutral-500">
Home site: <Link href={`/admin/sites/${vessel.site.id}`} className="text-primary-600 hover:underline">{vessel.site.name}</Link>
</p>
)}
</div> </div>
<Link href={`/po/new?costCentreRef=v:${vessel.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"> <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 + Create PO
</Link> </Link>
</div> </div>

View file

@ -12,7 +12,6 @@ type ActionResult = { ok: true } | { error: string };
const vesselSchema = z.object({ const vesselSchema = z.object({
name: z.string().min(1, "Vessel name is required"), name: z.string().min(1, "Vessel name is required"),
code: z.string().optional(), code: z.string().optional(),
siteId: z.string().optional(),
}); });
export async function createVessel(formData: FormData): Promise<ActionResult> { export async function createVessel(formData: FormData): Promise<ActionResult> {
@ -24,7 +23,6 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
const parsed = vesselSchema.safeParse({ const parsed = vesselSchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
code: (formData.get("code") as string).trim() || undefined, code: (formData.get("code") as string).trim() || undefined,
siteId: (formData.get("siteId") as string) || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
@ -32,16 +30,14 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
let code: string; let code: string;
if (parsed.data.code) { if (parsed.data.code) {
// User supplied a custom code — validate uniqueness
const conflict = await db.vessel.findUnique({ where: { code: parsed.data.code } }); const conflict = await db.vessel.findUnique({ where: { code: parsed.data.code } });
if (conflict) return { error: `Code "${parsed.data.code}" is already in use by another vessel.` }; if (conflict) return { error: `Code "${parsed.data.code}" is already in use by another vessel.` };
code = parsed.data.code; code = parsed.data.code;
} else { } else {
// Auto-generate next available code
code = nextId("SITE", existingCodes.map((v) => v.code)); code = nextId("SITE", existingCodes.map((v) => v.code));
} }
await db.vessel.create({ data: { name: parsed.data.name, code, siteId: parsed.data.siteId ?? null } }); await db.vessel.create({ data: { name: parsed.data.name, code } });
revalidatePath("/admin/vessels"); revalidatePath("/admin/vessels");
return { ok: true }; return { ok: true };
} }
@ -57,11 +53,10 @@ export async function updateVessel(formData: FormData): Promise<ActionResult> {
const parsed = vesselSchema.safeParse({ const parsed = vesselSchema.safeParse({
name: formData.get("name"), name: formData.get("name"),
siteId: (formData.get("siteId") as string) || undefined,
}); });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
await db.vessel.update({ where: { id }, data: { name: parsed.data.name, siteId: parsed.data.siteId ?? null } }); await db.vessel.update({ where: { id }, data: { name: parsed.data.name } });
revalidatePath("/admin/vessels"); revalidatePath("/admin/vessels");
return { ok: true }; return { ok: true };
} }

View file

@ -14,13 +14,9 @@ export default async function AdminVesselsPage() {
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard"); if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const [vessels, sites] = await Promise.all([ const vessels = await db.vessel.findMany({
db.vessel.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { site: { select: { name: true } } }, });
}),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
const suggestedCode = nextId("SITE", vessels.map((v) => v.code)); const suggestedCode = nextId("SITE", vessels.map((v) => v.code));
@ -31,11 +27,8 @@ export default async function AdminVesselsPage() {
id: v.id, id: v.id,
code: v.code, code: v.code,
name: v.name, name: v.name,
siteId: v.siteId ?? null,
siteName: v.site?.name ?? null,
isActive: v.isActive, isActive: v.isActive,
}))} }))}
sites={sites}
/> />
); );
} }

View file

@ -5,38 +5,25 @@ import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog"; import { AdminDialog } from "@/components/ui/admin-dialog";
import { createVessel, updateVessel, toggleVesselActive } from "./actions"; import { createVessel, updateVessel, toggleVesselActive } from "./actions";
type SiteOption = { id: string; name: string };
type VesselRow = { type VesselRow = {
id: string; id: string;
name: string; name: string;
code: string; code: string;
siteId: string | null;
isActive: boolean; isActive: boolean;
}; };
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function VesselFormFields({ function VesselFormFields({ vessel, suggestedCode }: { vessel?: VesselRow; suggestedCode?: string }) {
vessel,
sites,
suggestedCode,
}: {
vessel?: VesselRow;
sites: SiteOption[];
suggestedCode?: string;
}) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
{vessel ? ( {vessel ? (
/* Editing: show code read-only — code changes on existing data would break references */
<p className="font-mono text-sm text-neutral-700 px-3 py-2 bg-neutral-50 rounded-lg border border-neutral-200"> <p className="font-mono text-sm text-neutral-700 px-3 py-2 bg-neutral-50 rounded-lg border border-neutral-200">
{vessel.code} {vessel.code}
</p> </p>
) : ( ) : (
/* Creating: editable, pre-filled with next available code */
<input <input
name="code" name="code"
required required
@ -50,22 +37,11 @@ function VesselFormFields({
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label> <label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
<input name="name" defaultValue={vessel?.name} required className={INPUT} /> <input name="name" defaultValue={vessel?.name} required className={INPUT} />
</div> </div>
{sites.length > 0 && (
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Assigned Site</label>
<select name="siteId" defaultValue={vessel?.siteId ?? ""} className={INPUT}>
<option value=""> No site </option>
{sites.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
)}
</div> </div>
); );
} }
export function AddVesselButton({ sites, suggestedCode }: { sites: SiteOption[]; suggestedCode?: string }) { export function AddVesselButton({ suggestedCode }: { suggestedCode?: string }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -88,7 +64,7 @@ export function AddVesselButton({ sites, suggestedCode }: { sites: SiteOption[];
</button> </button>
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<VesselFormFields sites={sites} suggestedCode={suggestedCode} /> <VesselFormFields suggestedCode={suggestedCode} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} <button type="button" onClick={() => setOpen(false)}
@ -108,12 +84,10 @@ export function AddVesselButton({ sites, suggestedCode }: { sites: SiteOption[];
export function EditVesselButton({ export function EditVesselButton({
vessel, vessel,
sites,
open: controlledOpen, open: controlledOpen,
onOpenChange, onOpenChange,
}: { }: {
vessel: VesselRow; vessel: VesselRow;
sites: SiteOption[];
open?: boolean; open?: boolean;
onOpenChange?: (v: boolean) => void; onOpenChange?: (v: boolean) => void;
}) { }) {
@ -147,7 +121,7 @@ export function EditVesselButton({
)} )}
<AdminDialog title="Edit Vessel" open={open} onClose={() => setOpen(false)}> <AdminDialog title="Edit Vessel" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<VesselFormFields vessel={vessel} sites={sites} /> <VesselFormFields vessel={vessel} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>} {error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} <button type="button" onClick={() => setOpen(false)}

View file

@ -13,16 +13,12 @@ export type VesselRow = {
id: string; id: string;
code: string; code: string;
name: string; name: string;
siteId: string | null;
siteName: string | null;
isActive: boolean; isActive: boolean;
}; };
type SiteOption = { id: string; name: string };
const CHIPS = ["Active", "Inactive"]; const CHIPS = ["Active", "Inactive"];
function VesselActionsMenu({ vessel, sites }: { vessel: VesselRow; sites: SiteOption[] }) { function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false); const [toggleOpen, setToggleOpen] = useState(false);
@ -39,8 +35,7 @@ function VesselActionsMenu({ vessel, sites }: { vessel: VesselRow; sites: SiteOp
</RowActionsMenu> </RowActionsMenu>
<EditVesselButton <EditVesselButton
vessel={{ id: vessel.id, name: vessel.name, code: vessel.code, siteId: vessel.siteId, isActive: vessel.isActive }} vessel={{ id: vessel.id, name: vessel.name, code: vessel.code, isActive: vessel.isActive }}
sites={sites}
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
/> />
@ -62,13 +57,12 @@ function VesselActionsMenu({ vessel, sites }: { vessel: VesselRow; sites: SiteOp
); );
} }
export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: VesselRow[]; sites: SiteOption[]; suggestedCode?: string }) { export function VesselsTable({ vessels, suggestedCode }: { vessels: VesselRow[]; suggestedCode?: string }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } = const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<VesselRow>({ useTableControls<VesselRow>({
rows: vessels, rows: vessels,
defaultSortKey: "code", defaultSortKey: "code",
searchText: (v) => searchText: (v) => [v.code, v.name, v.isActive ? "active" : "inactive"].join(" "),
[v.code, v.name, v.siteName ?? "", v.isActive ? "active" : "inactive"].join(" "),
chipMatch: (v, chip) => { chipMatch: (v, chip) => {
if (chip.toLowerCase() === "active") return v.isActive; if (chip.toLowerCase() === "active") return v.isActive;
if (chip.toLowerCase() === "inactive") return !v.isActive; if (chip.toLowerCase() === "inactive") return !v.isActive;
@ -76,7 +70,6 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
}, },
sortValue: (v, key) => { sortValue: (v, key) => {
if (key === "isActive") return v.isActive ? "Active" : "Inactive"; if (key === "isActive") return v.isActive ? "Active" : "Inactive";
if (key === "siteName") return v.siteName ?? "";
const val = v[key as keyof VesselRow]; const val = v[key as keyof VesselRow];
if (val === null || val === undefined) return ""; if (val === null || val === undefined) return "";
return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val); return typeof val === "string" || typeof val === "number" || typeof val === "boolean" ? val : String(val);
@ -87,7 +80,7 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
<div> <div>
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1> <h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
<AddVesselButton sites={sites} suggestedCode={suggestedCode} /> <AddVesselButton suggestedCode={suggestedCode} />
</div> </div>
<TableControls <TableControls
@ -105,7 +98,6 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
<tr> <tr>
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Code</SortableTh> <SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Code</SortableTh>
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Name</SortableTh> <SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Name</SortableTh>
<SortableTh sortKey="siteName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Site</SortableTh>
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Status</SortableTh> <SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof VesselRow)}>Status</SortableTh>
<th className="px-4 py-3 w-10"></th> <th className="px-4 py-3 w-10"></th>
</tr> </tr>
@ -113,7 +105,7 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400"> <td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
No vessels match your search. No vessels match your search.
</td> </td>
</tr> </tr>
@ -122,9 +114,6 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
<tr key={vessel.id} className="hover:bg-neutral-50"> <tr key={vessel.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{vessel.code}</td> <td className="px-4 py-3 font-mono text-xs text-neutral-600">{vessel.code}</td>
<td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td> <td className="px-4 py-3 font-medium text-neutral-900">{vessel.name}</td>
<td className="px-4 py-3 text-neutral-500">
{vessel.siteName ?? <span className="italic text-neutral-400"></span>}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${ <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 ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
@ -133,7 +122,7 @@ export function VesselsTable({ vessels, sites, suggestedCode }: { vessels: Vesse
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<VesselActionsMenu vessel={vessel} sites={sites} /> <VesselActionsMenu vessel={vessel} />
</td> </td>
</tr> </tr>
))} ))}

View file

@ -7,7 +7,7 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
type SerializedLineItem = { type SerializedLineItem = {
@ -35,8 +35,7 @@ type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
interface Props { interface Props {
po: PoFull; po: PoFull;
costCentres: CostCentreGroup[]; vessels: VesselOption[];
initialCostCentreRef: string;
accounts: AccountGroup[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
} }
@ -51,7 +50,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />; return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
} }
export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors }: Props) { export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
const router = useRouter(); const router = useRouter();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
@ -160,17 +159,10 @@ export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accou
</div> </div>
<div> <div>
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label> <label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}> <select name="vesselId" required defaultValue={po.vesselId ?? ""} className={INPUT}>
<option value="">Select cost centre</option> <option value="">Select cost centre</option>
{costCentres.map((group) => ( {vessels.map((v) => (
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}> <option key={v.id} value={v.id}>{v.code} {v.name}</option>
{group.siteRef && (
<option value={group.siteRef}>{group.siteName} (Site)</option>
)}
{group.vessels.map((v) => (
<option key={v.ref} value={v.ref}>{v.label}</option>
))}
</optgroup>
))} ))}
</select> </select>
</div> </div>

View file

@ -43,7 +43,7 @@ export async function managerEditPo(
const parsed = createPoSchema.safeParse({ const parsed = createPoSchema.safeParse({
title: formData.get("title"), title: formData.get("title"),
costCentreRef: formData.get("costCentreRef"), vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"), accountId: formData.get("accountId"),
projectCode: formData.get("projectCode") || undefined, projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined, dateRequired: formData.get("dateRequired") || undefined,
@ -67,8 +67,6 @@ export async function managerEditPo(
} }
const data = parsed.data; const data = parsed.data;
const newVesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
const newCostCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
const newTotal = data.lineItems.reduce( const newTotal = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0 0
@ -85,7 +83,7 @@ export async function managerEditPo(
}; };
const original = { const original = {
title: po.title, title: po.title,
vesselId: po.vesselId ?? po.siteId ?? "", vesselId: po.vesselId,
accountId: po.accountId, accountId: po.accountId,
vendorId: po.vendorId, vendorId: po.vendorId,
projectCode: po.projectCode, projectCode: po.projectCode,
@ -114,8 +112,7 @@ export async function managerEditPo(
where: { id: poId }, where: { id: poId },
data: { data: {
title: data.title, title: data.title,
vesselId: newVesselId, vesselId: data.vesselId,
siteId: newCostCentreSiteId,
accountId: data.accountId, accountId: data.accountId,
vendorId: data.vendorId ?? null, vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null, projectCode: data.projectCode ?? null,

View file

@ -5,7 +5,7 @@ import { notFound, redirect } from "next/navigation";
import { ApprovalActions } from "./approval-actions"; import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail"; import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form"; import { ManagerEditPoForm } from "./manager-edit-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { interface Props {
@ -22,14 +22,13 @@ export default async function ApprovalDetailPage({ params }: Props) {
const { id } = await params; const { id } = await params;
// Check if manager has uploaded a signature — required to approve
const currentUser = await db.user.findUnique({ const currentUser = await db.user.findUnique({
where: { id: session.user.id }, where: { id: session.user.id },
select: { signatureKey: true }, select: { signatureKey: true },
}); });
const hasSignature = !!(currentUser?.signatureKey); const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, sites, accounts, vendors] = await Promise.all([ const [po, vessels, leafAccounts, vendors] = await Promise.all([
db.purchaseOrder.findUnique({ db.purchaseOrder.findUnique({
where: { id }, where: { id },
include: { include: {
@ -44,8 +43,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
receipt: true, receipt: true,
}, },
}), }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" }, orderBy: { code: "asc" },
@ -57,9 +55,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
if (!po) notFound(); if (!po) notFound();
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`); if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
const costCentres = buildCostCentreGroups(vessels, sites); const accounts = buildAccountGroups(leafAccounts);
const accountGroups = buildAccountGroups(accounts);
const initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
const serializedPo = { const serializedPo = {
...po, ...po,
@ -93,13 +89,11 @@ export default async function ApprovalDetailPage({ params }: Props) {
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly /> <PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
{/* Direct field editing is desktop-only */}
<div className="hidden md:block"> <div className="hidden md:block">
<ManagerEditPoForm <ManagerEditPoForm
po={serializedPo} po={serializedPo}
costCentres={costCentres} vessels={vessels}
initialCostCentreRef={initialCostCentreRef} accounts={accounts}
accounts={accountGroups}
vendors={vendors} vendors={vendors}
/> />
</div> </div>
@ -115,10 +109,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
<p className="text-sm text-warning-700 mt-0.5"> <p className="text-sm text-warning-700 mt-0.5">
You must upload your approval signature before you can approve, reject, or request edits on purchase orders. You must upload your approval signature before you can approve, reject, or request edits on purchase orders.
</p> </p>
<a <a href="/profile" className="mt-2 inline-block text-sm font-medium text-primary-600 hover:text-primary-700 underline">
href="/profile"
className="mt-2 inline-block text-sm font-medium text-primary-600 hover:text-primary-700 underline"
>
Go to Profile to upload your signature Go to Profile to upload your signature
</a> </a>
</div> </div>

View file

@ -4,33 +4,31 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
interface Props { interface Props {
costCentres: { ref: string; name: string }[]; vessels: { id: string; name: string }[];
} }
export function ApprovalsSearch({ costCentres }: Props) { export function ApprovalsSearch({ vessels }: Props) {
const router = useRouter(); const router = useRouter();
const sp = useSearchParams(); const sp = useSearchParams();
const [q, setQ] = useState(sp.get("q") ?? ""); const [q, setQ] = useState(sp.get("q") ?? "");
const [costCentreRef, setCostCentreRef] = useState(sp.get("costCentreRef") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
function apply() { function apply() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim()); if (q.trim()) params.set("q", q.trim());
if (costCentreRef) params.set("costCentreRef", costCentreRef); if (vesselId) params.set("vesselId", vesselId);
if (dateFrom) params.set("dateFrom", dateFrom); if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
router.push(`/approvals?${params.toString()}`); router.push(`/approvals?${params.toString()}`);
} }
function clear() { function clear() {
setQ(""); setCostCentreRef(""); setDateFrom(""); setDateTo(""); setQ(""); setVesselId(""); setDateFrom("");
router.push("/approvals"); router.push("/approvals");
} }
const hasFilters = q || costCentreRef || dateFrom || dateTo; const hasFilters = q || vesselId || dateFrom;
return ( return (
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4"> <div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
@ -44,12 +42,10 @@ export function ApprovalsSearch({ costCentres }: Props) {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label> <label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
<select value={costCentreRef} onChange={(e) => setCostCentreRef(e.target.value)} <select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"> className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="">All cost centres</option> <option value="">All cost centres</option>
{costCentres.map((c) => ( {vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
<option key={c.ref} value={c.ref}>{c.name}</option>
))}
</select> </select>
</div> </div>
<div> <div>

View file

@ -13,9 +13,8 @@ export const metadata: Metadata = { title: "Approvals" };
interface Props { interface Props {
searchParams: Promise<{ searchParams: Promise<{
q?: string; q?: string;
costCentreRef?: string; vesselId?: string;
dateFrom?: string; dateFrom?: string;
dateTo?: string;
}>; }>;
} }
@ -25,7 +24,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard"); if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
const { q, costCentreRef, dateFrom } = await searchParams; const { q, vesselId, dateFrom } = await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = { const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {
status: "MGR_REVIEW", status: "MGR_REVIEW",
@ -38,27 +37,18 @@ export default async function ApprovalsPage({ searchParams }: Props) {
{ title: { contains: q.trim(), mode: "insensitive" } }, { title: { contains: q.trim(), mode: "insensitive" } },
]; ];
} }
if (costCentreRef) { if (vesselId) where.vesselId = vesselId;
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
}
if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) }; if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) };
const [pending, vessels, sites] = await Promise.all([ const [pending, vessels] = await Promise.all([
db.purchaseOrder.findMany({ db.purchaseOrder.findMany({
where, where,
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true }, include: { submitter: true, vessel: true, account: true },
orderBy: { submittedAt: "asc" }, orderBy: { submittedAt: "asc" },
}), }),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.site.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]); ]);
const costCentres = [
...vessels.map((v) => ({ ref: `v:${v.id}`, name: v.name })),
...sites.map((s) => ({ ref: `s:${s.id}`, name: s.name })),
];
return ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4">
@ -69,7 +59,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</div> </div>
<Suspense> <Suspense>
<ApprovalsSearch costCentres={costCentres} /> <ApprovalsSearch vessels={vessels} />
</Suspense> </Suspense>
{pending.length === 0 ? ( {pending.length === 0 ? (
@ -98,7 +88,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td> <td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td> <td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td> <td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-right font-mono text-sm"> <td className="px-4 py-3 text-right font-mono text-sm">
{formatCurrency(Number(po.totalAmount), po.currency)} {formatCurrency(Number(po.totalAmount), po.currency)}
</td> </td>
@ -130,7 +120,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p> <p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-neutral-500 truncate max-w-[55%]"> <span className="text-neutral-500 truncate max-w-[55%]">
{po.submitter.name} · {po.vessel?.name ?? po.site?.name ?? "—"} {po.submitter.name} · {po.vessel.name}
</span> </span>
<span className="font-mono font-semibold text-neutral-900 shrink-0"> <span className="font-mono font-semibold text-neutral-900 shrink-0">
{formatCurrency(Number(po.totalAmount), po.currency)} {formatCurrency(Number(po.totalAmount), po.currency)}

View file

@ -206,7 +206,7 @@ async function ManagerDashboard() {
</Link> </Link>
</td> </td>
<td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td> <td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />
</td> </td>

View file

@ -18,33 +18,33 @@ const STATUSES = [
]; ];
interface Props { interface Props {
costCentres: { ref: string; name: string }[]; vessels: { id: string; name: string }[];
} }
export function HistoryFilters({ costCentres }: Props) { export function HistoryFilters({ vessels }: Props) {
const router = useRouter(); const router = useRouter();
const sp = useSearchParams(); const sp = useSearchParams();
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? ""); const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [costCentreRef, setCostCentreRef] = useState(sp.get("costCentreRef") ?? ""); const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [status, setStatus] = useState(sp.get("status") ?? ""); const [status, setStatus] = useState(sp.get("status") ?? "");
function apply() { function apply() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom); if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo); if (dateTo) params.set("dateTo", dateTo);
if (costCentreRef) params.set("costCentreRef", costCentreRef); if (vesselId) params.set("vesselId", vesselId);
if (status) params.set("status", status); if (status) params.set("status", status);
router.push(`/history?${params.toString()}`); router.push(`/history?${params.toString()}`);
} }
function clear() { function clear() {
setDateFrom(""); setDateTo(""); setCostCentreRef(""); setStatus(""); setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
router.push("/history"); router.push("/history");
} }
const hasFilters = dateFrom || dateTo || costCentreRef || status; const hasFilters = dateFrom || dateTo || vesselId || status;
return ( return (
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4"> <div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
@ -61,12 +61,10 @@ export function HistoryFilters({ costCentres }: Props) {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label> <label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
<select value={costCentreRef} onChange={(e) => setCostCentreRef(e.target.value)} <select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"> className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="">All cost centres</option> <option value="">All cost centres</option>
{costCentres.map((c) => ( {vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
<option key={c.ref} value={c.ref}>{c.name}</option>
))}
</select> </select>
</div> </div>
<div> <div>

View file

@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate, PO_STATUS_LABELS } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge"; import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters"; import { HistoryFilters } from "./history-filters";
import { Suspense } from "react"; import { Suspense } from "react";
@ -16,7 +16,7 @@ interface Props {
searchParams: Promise<{ searchParams: Promise<{
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
costCentreRef?: string; vesselId?: string;
status?: string; status?: string;
}>; }>;
} }
@ -27,7 +27,7 @@ export default async function HistoryPage({ searchParams }: Props) {
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard"); if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
const { dateFrom, dateTo, costCentreRef, status } = await searchParams; const { dateFrom, dateTo, vesselId, status } = await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {}; const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) { if (dateFrom || dateTo) {
@ -40,32 +40,23 @@ export default async function HistoryPage({ searchParams }: Props) {
} }
where.createdAt = createdAt; where.createdAt = createdAt;
} }
if (costCentreRef) { if (vesselId) where.vesselId = vesselId;
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
}
if (status) where.status = status as POStatus; if (status) where.status = status as POStatus;
const [orders, vessels, sites] = await Promise.all([ const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({ db.purchaseOrder.findMany({
where, where,
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true }, include: { submitter: true, vessel: true, account: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 200, take: 200,
}), }),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.site.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]); ]);
const costCentres = [
...vessels.map((v) => ({ ref: `v:${v.id}`, name: v.name })),
...sites.map((s) => ({ ref: `s:${s.id}`, name: s.name })),
];
const exportParams = new URLSearchParams({ format: "csv" }); const exportParams = new URLSearchParams({ format: "csv" });
if (dateFrom) exportParams.set("dateFrom", dateFrom); if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo); if (dateTo) exportParams.set("dateTo", dateTo);
if (costCentreRef) exportParams.set("costCentreRef", costCentreRef); if (vesselId) exportParams.set("vesselId", vesselId);
if (status) exportParams.set("status", status); if (status) exportParams.set("status", status);
return ( return (
@ -91,7 +82,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div> </div>
<Suspense> <Suspense>
<HistoryFilters costCentres={costCentres} /> <HistoryFilters vessels={vessels} />
</Suspense> </Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -116,7 +107,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</Link> </Link>
</td> </td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td> <td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td> <td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<PoStatusBadge status={po.status} /> <PoStatusBadge status={po.status} />

View file

@ -23,7 +23,6 @@ export default async function MyOrdersPage() {
orderBy: { updatedAt: "desc" }, orderBy: { updatedAt: "desc" },
include: { include: {
vessel: { select: { name: true } }, vessel: { select: { name: true } },
site: { select: { name: true } },
account: { select: { name: true, code: true } }, account: { select: { name: true, code: true } },
actions: { actions: {
where: { where: {
@ -68,8 +67,7 @@ type PoRow = {
title: string; title: string;
status: import("@prisma/client").POStatus; status: import("@prisma/client").POStatus;
totalAmount: import("@prisma/client").Prisma.Decimal; totalAmount: import("@prisma/client").Prisma.Decimal;
vessel: { name: string } | null; vessel: { name: string };
site: { name: string } | null;
account: { name: string; code: string }; account: { name: string; code: string };
updatedAt: Date; updatedAt: Date;
managerNote: string | null; managerNote: string | null;
@ -111,7 +109,7 @@ function PoTable({ title, rows, className = "" }: { title: string; rows: PoRow[]
</p> </p>
)} )}
</td> </td>
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3"><PoStatusBadge status={po.status} /></td> <td className="px-4 py-3"><PoStatusBadge status={po.status} /></td>
<td className="px-4 py-3 text-right font-mono text-xs">{formatCurrency(Number(po.totalAmount))}</td> <td className="px-4 py-3 text-right font-mono text-xs">{formatCurrency(Number(po.totalAmount))}</td>
<td className="px-4 py-3 text-neutral-500">{formatDate(po.updatedAt)}</td> <td className="px-4 py-3 text-neutral-500">{formatDate(po.updatedAt)}</td>

View file

@ -117,7 +117,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate"> <td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">
{po.title} {po.title}
</td> </td>
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.vendor?.name ?? "—"}</td> <td className="px-4 py-3 text-neutral-600">{po.vendor?.name ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td> <td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">

View file

@ -45,7 +45,7 @@ export default async function PaymentsPage() {
</div> </div>
<h3 className="font-medium text-neutral-900 truncate">{po.title}</h3> <h3 className="font-medium text-neutral-900 truncate">{po.title}</h3>
<div className="mt-1 flex flex-wrap gap-3 text-sm text-neutral-500"> <div className="mt-1 flex flex-wrap gap-3 text-sm text-neutral-500">
<span>{po.vessel?.name ?? po.site?.name ?? "—"}</span> <span>{po.vessel.name}</span>
<span>·</span> <span>·</span>
<span>{po.submitter.name}</span> <span>{po.submitter.name}</span>
{po.vendor && ( {po.vendor && (

View file

@ -45,7 +45,7 @@ export async function updatePo(
const parsed = createPoSchema.safeParse({ const parsed = createPoSchema.safeParse({
title: formData.get("title"), title: formData.get("title"),
costCentreRef: formData.get("costCentreRef"), vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"), accountId: formData.get("accountId"),
projectCode: formData.get("projectCode") || undefined, projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined, dateRequired: formData.get("dateRequired") || undefined,
@ -69,8 +69,6 @@ export async function updatePo(
} }
const data = parsed.data; const data = parsed.data;
const newVesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
const newCostCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
const total = data.lineItems.reduce( const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0 0
@ -102,7 +100,6 @@ export async function updatePo(
include: { include: {
lineItems: { orderBy: { sortOrder: "asc" } }, lineItems: { orderBy: { sortOrder: "asc" } },
vessel: true, vessel: true,
site: { select: { name: true } },
account: true, account: true,
vendor: true, vendor: true,
}, },
@ -120,8 +117,8 @@ export async function updatePo(
})), })),
fields: { fields: {
title: currentPo.title, title: currentPo.title,
vessel: currentPo.vessel?.name ?? currentPo.site?.name ?? null, vessel: currentPo.vessel?.name ?? null,
vesselId: currentPo.vesselId ?? currentPo.siteId ?? "", vesselId: currentPo.vesselId,
account: `${currentPo.account.name} (${currentPo.account.code})`, account: `${currentPo.account.name} (${currentPo.account.code})`,
accountId: currentPo.accountId, accountId: currentPo.accountId,
vendor: currentPo.vendor?.name ?? null, vendor: currentPo.vendor?.name ?? null,
@ -138,8 +135,7 @@ export async function updatePo(
where: { id: poId }, where: { id: poId },
data: { data: {
title: data.title, title: data.title,
vesselId: newVesselId, vesselId: data.vesselId,
siteId: newCostCentreSiteId,
accountId: data.accountId, accountId: data.accountId,
vendorId: data.vendorId ?? null, vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null, projectCode: data.projectCode ?? null,

View file

@ -4,7 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updatePo } from "./actions"; import { updatePo } from "./actions";
import type { Vendor, PurchaseOrder } from "@prisma/client"; import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor"; import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
@ -36,14 +36,13 @@ type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
interface Props { interface Props {
po: PoWithItems; po: PoWithItems;
costCentres: CostCentreGroup[]; vessels: VesselOption[];
initialCostCentreRef: string;
accounts: AccountGroup[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
managerNoteAuthor?: string | null; managerNoteAuthor?: string | null;
} }
export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors, managerNoteAuthor }: Props) { export function EditPoForm({ po, vessels, accounts, vendors, managerNoteAuthor }: Props) {
const router = useRouter(); const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({ po.lineItems.map((li) => ({
@ -131,17 +130,10 @@ export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, ve
<label className="block text-sm font-medium text-neutral-700 mb-1.5"> <label className="block text-sm font-medium text-neutral-700 mb-1.5">
Cost Centre <span className="text-danger">*</span> Cost Centre <span className="text-danger">*</span>
</label> </label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT_CLS}> <select name="vesselId" required defaultValue={po.vesselId} className={INPUT_CLS}>
<option value="">Select cost centre</option> <option value="">Select cost centre</option>
{costCentres.map((group) => ( {vessels.map((v) => (
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}> <option key={v.id} value={v.id}>{v.code} {v.name}</option>
{group.siteRef && (
<option value={group.siteRef}>{group.siteName} (Site)</option>
)}
{group.vessels.map((v) => (
<option key={v.ref} value={v.ref}>{v.label}</option>
))}
</optgroup>
))} ))}
</select> </select>
</div> </div>

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form"; import { EditPoForm } from "./edit-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next"; import type { Metadata } from "next";
interface Props { interface Props {
@ -23,15 +23,13 @@ export default async function EditPoPage({ params }: Props) {
}); });
if (!po) notFound(); if (!po) notFound();
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`); if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) redirect(`/po/${id}`);
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER"; const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`); if (!canEdit) redirect(`/po/${id}`);
const [vessels, sites, leafAccounts, vendors, noteAction] = await Promise.all([ const [vessels, leafAccounts, vendors, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" }, orderBy: { code: "asc" },
@ -47,9 +45,7 @@ export default async function EditPoPage({ params }: Props) {
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
const costCentres = buildCostCentreGroups(vessels, sites); const accounts = buildAccountGroups(leafAccounts);
const accountGroups = buildAccountGroups(leafAccounts);
const initialCostCentreRef = po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "";
const serializedPo = { const serializedPo = {
...po, ...po,
@ -71,9 +67,8 @@ export default async function EditPoPage({ params }: Props) {
</div> </div>
<EditPoForm <EditPoForm
po={serializedPo} po={serializedPo}
costCentres={costCentres} vessels={vessels}
initialCostCentreRef={initialCostCentreRef} accounts={accounts}
accounts={accountGroups}
vendors={vendors} vendors={vendors}
managerNoteAuthor={noteAction?.actor.name ?? null} managerNoteAuthor={noteAction?.actor.name ?? null}
/> />

View file

@ -26,7 +26,6 @@ export default async function PoDetailPage({ params }: Props) {
include: { include: {
submitter: true, submitter: true,
vessel: true, vessel: true,
site: { select: { id: true, name: true } },
account: true, account: true,
vendor: true, vendor: true,
lineItems: { orderBy: { sortOrder: "asc" } }, lineItems: { orderBy: { sortOrder: "asc" } },

View file

@ -28,7 +28,7 @@ export async function confirmReceipt({
include: { include: {
submitter: true, submitter: true,
lineItems: true, lineItems: true,
vessel: { include: { site: true } }, vessel: true,
}, },
}); });
if (!po) return { error: "PO not found" }; if (!po) return { error: "PO not found" };
@ -134,7 +134,6 @@ export async function confirmReceipt({
// Auto-update inventory for delivered quantities // Auto-update inventory for delivered quantities
const siteId = const siteId =
(po as typeof po & { siteId?: string | null }).siteId ?? (po as typeof po & { siteId?: string | null }).siteId ??
po.vessel?.site?.id ??
null; null;
if (siteId) { if (siteId) {

View file

@ -9,7 +9,7 @@ import type { ParsedImportLine } from "@/app/api/po/import/route";
export type ImportPoInput = { export type ImportPoInput = {
title: string; title: string;
costCentreRef: string; vesselId: string;
accountId: string; accountId: string;
vendorId?: string; vendorId?: string;
piQuotationNo?: string; piQuotationNo?: string;
@ -32,9 +32,6 @@ export async function importPo(
return { error: "You do not have permission to import purchase orders." }; return { error: "You do not have permission to import purchase orders." };
} }
const importVesselId = input.costCentreRef.startsWith("v:") ? input.costCentreRef.slice(2) : null;
const importSiteId = input.costCentreRef.startsWith("s:") ? input.costCentreRef.slice(2) : null;
const total = input.lineItems.reduce( const total = input.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)), (sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)),
0 0
@ -47,8 +44,7 @@ export async function importPo(
status: "DRAFT", status: "DRAFT",
totalAmount: total, totalAmount: total,
currency: "INR", currency: "INR",
vesselId: importVesselId, vesselId: input.vesselId,
siteId: importSiteId,
accountId: input.accountId, accountId: input.accountId,
vendorId: input.vendorId ?? null, vendorId: input.vendorId ?? null,
piQuotationNo: input.piQuotationNo ?? null, piQuotationNo: input.piQuotationNo ?? null,

View file

@ -3,7 +3,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { Vendor } from "@prisma/client"; import type { Vendor } from "@prisma/client";
import type { CostCentreGroup, AccountGroup } from "@/app/(portal)/po/new/new-po-form"; import type { VesselOption, AccountGroup } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select"; import { SearchableSelect } from "@/components/ui/searchable-select";
import { importPo } from "./actions"; import { importPo } from "./actions";
import type { ParsedImport } from "@/app/api/po/import/route"; import type { ParsedImport } from "@/app/api/po/import/route";
@ -13,7 +13,7 @@ const INPUT_CLS =
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; "w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
interface Props { interface Props {
costCentres: CostCentreGroup[]; vessels: VesselOption[];
accounts: AccountGroup[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
} }
@ -21,12 +21,12 @@ interface Props {
type PreviewState = { type PreviewState = {
parsed: ParsedImport; parsed: ParsedImport;
title: string; title: string;
costCentreRef: string; vesselId: string;
accountId: string; accountId: string;
vendorId: string; vendorId: string;
}; };
export function ImportForm({ costCentres, accounts, vendors }: Props) { export function ImportForm({ vessels, accounts, vendors }: Props) {
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [parsing, setParsing] = useState(false); const [parsing, setParsing] = useState(false);
@ -66,7 +66,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
title: parsed.vendorName title: parsed.vendorName
? `${parsed.vendorName} — Import` ? `${parsed.vendorName} — Import`
: "Imported Purchase Order", : "Imported Purchase Order",
costCentreRef: costCentres[0]?.siteRef ?? costCentres[0]?.vessels[0]?.ref ?? "", vesselId: vessels[0]?.id ?? "",
accountId: accounts[0]?.items[0]?.id ?? "", accountId: accounts[0]?.items[0]?.id ?? "",
vendorId: matchedVendor?.id ?? "", vendorId: matchedVendor?.id ?? "",
}); });
@ -85,7 +85,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
const result = await importPo({ const result = await importPo({
title: preview.title, title: preview.title,
costCentreRef: preview.costCentreRef, vesselId: preview.vesselId,
accountId: preview.accountId, accountId: preview.accountId,
vendorId: preview.vendorId || undefined, vendorId: preview.vendorId || undefined,
piQuotationNo: preview.parsed.piQuotationNo || undefined, piQuotationNo: preview.parsed.piQuotationNo || undefined,
@ -184,21 +184,14 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
Cost Centre <span className="text-danger">*</span> Cost Centre <span className="text-danger">*</span>
</label> </label>
<select <select
value={preview.costCentreRef} value={preview.vesselId}
onChange={(e) => setPreview({ ...preview, costCentreRef: e.target.value })} onChange={(e) => setPreview({ ...preview, vesselId: e.target.value })}
required required
className={INPUT_CLS} className={INPUT_CLS}
> >
<option value="">Select cost centre</option> <option value="">Select cost centre</option>
{costCentres.map((group) => ( {vessels.map((v) => (
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}> <option key={v.id} value={v.id}>{v.code} {v.name}</option>
{group.siteRef && (
<option value={group.siteRef}>{group.siteName} (Site)</option>
)}
{group.vessels.map((v) => (
<option key={v.ref} value={v.ref}>{v.label}</option>
))}
</optgroup>
))} ))}
</select> </select>
</div> </div>
@ -295,7 +288,7 @@ export function ImportForm({ costCentres, accounts, vendors }: Props) {
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitting || !preview.costCentreRef || !preview.accountId} disabled={submitting || !preview.vesselId || !preview.accountId}
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors" className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
> >
{submitting ? "Creating…" : "Create as Draft"} {submitting ? "Creating…" : "Create as Draft"}

View file

@ -2,7 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ImportForm } from "./import-form"; import { ImportForm } from "./import-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { title: "Import Purchase Order" }; export const metadata: Metadata = { title: "Import Purchase Order" };
@ -14,9 +14,8 @@ export default async function ImportPoPage() {
const { role } = session.user; const { role } = session.user;
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard"); if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
const [vessels, sites, leafAccounts, vendors] = await Promise.all([ const [vessels, leafAccounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" }, orderBy: { code: "asc" },
@ -25,7 +24,6 @@ export default async function ImportPoPage() {
db.vendor.findMany({ orderBy: { name: "asc" } }), db.vendor.findMany({ orderBy: { name: "asc" } }),
]); ]);
const costCentres = buildCostCentreGroups(vessels, sites);
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
return ( return (
@ -37,7 +35,7 @@ export default async function ImportPoPage() {
You then select the cost centre, accounting code, and confirm before saving as a draft. You then select the cost centre, accounting code, and confirm before saving as a draft.
</p> </p>
</div> </div>
<ImportForm costCentres={costCentres} accounts={accounts} vendors={vendors} /> <ImportForm vessels={vessels} accounts={accounts} vendors={vendors} />
</div> </div>
); );
} }

View file

@ -51,7 +51,7 @@ export async function createPo(
const parsed = createPoSchema.safeParse({ const parsed = createPoSchema.safeParse({
title: formData.get("title"), title: formData.get("title"),
costCentreRef: formData.get("costCentreRef"), vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"), accountId: formData.get("accountId"),
projectCode: formData.get("projectCode") || undefined, projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined, dateRequired: formData.get("dateRequired") || undefined,
@ -75,9 +75,6 @@ export async function createPo(
} }
const data = parsed.data; const data = parsed.data;
const vesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
const costCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
// totalAmount = grand total including GST // totalAmount = grand total including GST
const total = data.lineItems.reduce( const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate), (sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
@ -91,8 +88,7 @@ export async function createPo(
status: intent === "submit" ? "SUBMITTED" : "DRAFT", status: intent === "submit" ? "SUBMITTED" : "DRAFT",
totalAmount: total, totalAmount: total,
currency: data.currency, currency: data.currency,
vesselId, vesselId: data.vesselId,
siteId: costCentreSiteId,
accountId: data.accountId, accountId: data.accountId,
vendorId: data.vendorId ?? null, vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null, projectCode: data.projectCode ?? null,

View file

@ -11,15 +11,7 @@ import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po"; import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
// Cost centres grouped by site: the site itself is selectable, vessels are listed under it export type VesselOption = { id: string; code: string; name: string };
export type CostCentreGroup = {
siteId: string | null; // null = "Unassigned Vessels" fallback group
siteName: string;
siteRef: string | null; // "s:siteId" — selectable; null = no site option
vessels: { ref: string; label: string }[];
};
// Accounting codes grouped by sub-category
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] }; export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
const INPUT_CLS = const INPUT_CLS =
@ -28,15 +20,15 @@ const INPUT_CLS =
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 }; const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
interface Props { interface Props {
costCentres: CostCentreGroup[]; vessels: VesselOption[];
accounts: AccountGroup[]; accounts: AccountGroup[];
vendors: Vendor[]; vendors: Vendor[];
initialLineItems?: LineItemInput[]; initialLineItems?: LineItemInput[];
initialVendorId?: string; initialVendorId?: string;
initialCostCentreRef?: string; initialVesselId?: string;
} }
export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, initialVendorId, initialCostCentreRef }: Props) { export function NewPoForm({ vessels, accounts, vendors, initialLineItems, initialVendorId, initialVesselId }: Props) {
const router = useRouter(); const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>( const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE] initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
@ -96,22 +88,15 @@ export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, in
<input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" /> <input name="title" required className={INPUT_CLS} placeholder="Brief description of what is being ordered" />
</div> </div>
{/* Cost Centre — grouped by site */} {/* Cost Centre — vessels only */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5"> <label className="block text-sm font-medium text-neutral-700 mb-1.5">
Cost Centre <span className="text-danger">*</span> Cost Centre <span className="text-danger">*</span>
</label> </label>
<select name="costCentreRef" required defaultValue={initialCostCentreRef ?? ""} className={INPUT_CLS}> <select name="vesselId" required defaultValue={initialVesselId ?? ""} className={INPUT_CLS}>
<option value="">Select cost centre</option> <option value="">Select cost centre</option>
{costCentres.map((group) => ( {vessels.map((v) => (
<optgroup key={group.siteId ?? "__unassigned"} label={group.siteName}> <option key={v.id} value={v.id}>{v.code} {v.name}</option>
{group.siteRef && (
<option value={group.siteRef}>{group.siteName} (Site)</option>
)}
{group.vessels.map((v) => (
<option key={v.ref} value={v.ref}>{v.label}</option>
))}
</optgroup>
))} ))}
</select> </select>
</div> </div>

View file

@ -3,7 +3,7 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form"; import { NewPoForm } from "./new-po-form";
import { buildCostCentreGroups, buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart"; import type { CartItem } from "@/lib/cart";
@ -11,18 +11,16 @@ import type { CartItem } from "@/lib/cart";
export const metadata: Metadata = { title: "New Purchase Order" }; export const metadata: Metadata = { title: "New Purchase Order" };
interface Props { interface Props {
searchParams: Promise<{ cart?: string; costCentreRef?: string }>; searchParams: Promise<{ cart?: string; vesselId?: string }>;
} }
export default async function NewPoPage({ searchParams }: Props) { export default async function NewPoPage({ searchParams }: Props) {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "create_po")) { if (!hasPermission(session.user.role, "create_po")) redirect("/dashboard");
redirect("/dashboard");
}
const { cart, costCentreRef: initialCostCentreRef } = await searchParams; const { cart, vesselId: initialVesselId } = await searchParams;
let initialLineItems: LineItemInput[] | undefined; let initialLineItems: LineItemInput[] | undefined;
let initialVendorId: string | undefined; let initialVendorId: string | undefined;
@ -44,13 +42,12 @@ export default async function NewPoPage({ searchParams }: Props) {
if (vendorIds.length === 1) initialVendorId = vendorIds[0]; if (vendorIds.length === 1) initialVendorId = vendorIds[0];
} }
} catch { } catch {
// malformed cart param — ignore and start empty // malformed cart param — ignore
} }
} }
const [vessels, sites, leafAccounts, vendors] = await Promise.all([ const [vessels, leafAccounts, vendors] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true, siteId: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
orderBy: { code: "asc" }, orderBy: { code: "asc" },
@ -59,7 +56,6 @@ export default async function NewPoPage({ searchParams }: Props) {
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
]); ]);
const costCentres = buildCostCentreGroups(vessels, sites);
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
return ( return (
@ -71,12 +67,12 @@ export default async function NewPoPage({ searchParams }: Props) {
</p> </p>
</div> </div>
<NewPoForm <NewPoForm
costCentres={costCentres} vessels={vessels}
accounts={accounts} accounts={accounts}
vendors={vendors} vendors={vendors}
initialLineItems={initialLineItems} initialLineItems={initialLineItems}
initialVendorId={initialVendorId} initialVendorId={initialVendorId}
initialCostCentreRef={initialCostCentreRef} initialVesselId={initialVesselId}
/> />
</div> </div>
); );

View file

@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
const format = sp.get("format") ?? "csv"; const format = sp.get("format") ?? "csv";
const dateFrom = sp.get("dateFrom"); const dateFrom = sp.get("dateFrom");
const dateTo = sp.get("dateTo"); const dateTo = sp.get("dateTo");
const costCentreRef = sp.get("costCentreRef") ?? sp.get("vesselId"); const vesselId = sp.get("vesselId");
const status = sp.get("status"); const status = sp.get("status");
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {}; const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
@ -38,16 +38,12 @@ export async function GET(request: NextRequest) {
} }
where.createdAt = createdAt; where.createdAt = createdAt;
} }
if (costCentreRef) { if (vesselId) where.vesselId = vesselId;
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
else where.vesselId = costCentreRef; // legacy plain vesselId
}
if (status) where.status = status as POStatus; if (status) where.status = status as POStatus;
const orders = await db.purchaseOrder.findMany({ const orders = await db.purchaseOrder.findMany({
where, where,
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true, vendor: true }, include: { submitter: true, vessel: true, account: true, vendor: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
@ -57,7 +53,7 @@ export async function GET(request: NextRequest) {
<td>${po.poNumber}</td> <td>${po.poNumber}</td>
<td>${po.title}</td> <td>${po.title}</td>
<td>${PO_STATUS_LABELS[po.status] ?? po.status}</td> <td>${PO_STATUS_LABELS[po.status] ?? po.status}</td>
<td>${po.vessel?.name ?? po.site?.name ?? "—"}</td> <td>${po.vessel.name}</td>
<td>${po.submitter.name}</td> <td>${po.submitter.name}</td>
<td>${po.vendor?.name ?? "—"}</td> <td>${po.vendor?.name ?? "—"}</td>
<td style="text-align:right">${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })}</td> <td style="text-align:right">${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })}</td>
@ -111,7 +107,7 @@ export async function GET(request: NextRequest) {
po.poNumber, po.poNumber,
`"${po.title.replace(/"/g, '""')}"`, `"${po.title.replace(/"/g, '""')}"`,
po.status, po.status,
po.vessel?.name ?? po.site?.name ?? "", po.vessel.name,
po.account.name, po.account.name,
po.vendor?.name ?? "", po.vendor?.name ?? "",
po.submitter.name, po.submitter.name,

View file

@ -38,8 +38,7 @@ type PoWithRelations = {
paidAt: Date | null; paidAt: Date | null;
closedAt: Date | null; closedAt: Date | null;
submitter: { id: string; name: string; email: string }; submitter: { id: string; name: string; email: string };
vessel: { id: string; name: string } | null; vessel: { id: string; name: string };
site?: { id: string; name: string } | null;
account: { id: string; name: string; code: string }; account: { id: string; name: string; code: string };
vendor: { vendor: {
id: string; id: string;
@ -229,7 +228,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
po.status === "MGR_REVIEW" && po.status === "MGR_REVIEW" &&
(currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => { (currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => {
const snap = resubmitSnapshot.fields; const snap = resubmitSnapshot.fields;
const currentVessel = po.vessel?.name ?? po.site?.name ?? null; const currentVessel = po.vessel?.name ?? null;
const currentAccount = `${po.account.name} (${po.account.code})`; const currentAccount = `${po.account.name} (${po.account.code})`;
const currentVendor = po.vendor?.name ?? null; const currentVendor = po.vendor?.name ?? null;
const currentDateRequired = po.dateRequired?.toISOString() ?? null; const currentDateRequired = po.dateRequired?.toISOString() ?? null;
@ -237,7 +236,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
const fieldChanges: { label: string; before: string | null; after: string | null }[] = []; const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
if (snap.title !== po.title) if (snap.title !== po.title)
fieldChanges.push({ label: "Title", before: snap.title, after: po.title }); fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
if (snap.vesselId !== (po.vessel?.id ?? po.site?.id ?? "")) if (snap.vesselId !== po.vessel.id)
fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel }); fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel });
if (snap.accountId !== po.account.id) if (snap.accountId !== po.account.id)
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount }); fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
@ -287,7 +286,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6"> <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> <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"> <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 ?? po.site?.name ?? "—"}</dd></div> <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">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> <div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
{approvalAction && ( {approvalAction && (

View file

@ -1,40 +1,3 @@
import type { CostCentreGroup } from "@/app/(portal)/po/new/new-po-form";
type VesselLike = { id: string; name: string; code: string; siteId: string | null };
type SiteLike = { id: string; name: string };
/**
* Builds the grouped cost-centre list used by PO form dropdowns.
* Each group = one site (as an optgroup), with the site itself as a selectable
* option followed by its vessels.
* Vessels with no site appear under an "Unassigned Vessels" group at the end.
*/
export function buildCostCentreGroups(
vessels: VesselLike[],
sites: SiteLike[]
): CostCentreGroup[] {
const groups: CostCentreGroup[] = sites.map((s) => ({
siteId: s.id,
siteName: s.name,
siteRef: `s:${s.id}`,
vessels: vessels
.filter((v) => v.siteId === s.id)
.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}` })),
}));
const unassigned = vessels.filter((v) => !v.siteId);
if (unassigned.length > 0) {
groups.push({
siteId: null,
siteName: "Unassigned Vessels",
siteRef: null,
vessels: unassigned.map((v) => ({ ref: `v:${v.id}`, label: `${v.code}${v.name}` })),
});
}
return groups;
}
/** /**
* Builds grouped accounting codes for the SearchableSelect component. * Builds grouped accounting codes for the SearchableSelect component.
* Only returns leaf items (no children), grouped by sub-category. * Only returns leaf items (no children), grouped by sub-category.

View file

@ -29,10 +29,7 @@ export const TC_DEFAULTS = {
export const createPoSchema = z.object({ export const createPoSchema = z.object({
title: z.string().min(1, "Title is required").max(200), title: z.string().min(1, "Title is required").max(200),
costCentreRef: z.string().min(1, "Cost Centre is required").refine( vesselId: z.string().min(1, "Cost Centre is required"),
(v) => v.startsWith("v:") || v.startsWith("s:"),
"Invalid cost centre selection"
),
accountId: z.string().min(1, "Accounting Code is required"), accountId: z.string().min(1, "Accounting Code is required"),
projectCode: z.string().optional(), projectCode: z.string().optional(),
dateRequired: z.string().optional(), dateRequired: z.string().optional(),

View file

@ -0,0 +1,7 @@
-- Remove vessel → site relationship (vessels are standalone cost centres)
ALTER TABLE "Vessel" DROP COLUMN IF EXISTS "siteId";
-- Restore vesselId as required on PurchaseOrder
-- First null out any rows that have no vesselId (set to first vessel as fallback, should be none in practice)
UPDATE "PurchaseOrder" SET "vesselId" = (SELECT id FROM "Vessel" LIMIT 1) WHERE "vesselId" IS NULL;
ALTER TABLE "PurchaseOrder" ALTER COLUMN "vesselId" SET NOT NULL;

View file

@ -100,7 +100,6 @@ model Site {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
vessels Vessel[]
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
inventory ItemInventory[] inventory ItemInventory[]
consumption ItemConsumption[] consumption ItemConsumption[]
@ -112,9 +111,6 @@ model Vessel {
code String @unique code String @unique
isActive Boolean @default(true) isActive Boolean @default(true)
siteId String?
site Site? @relation(fields: [siteId], references: [id])
purchaseOrders PurchaseOrder[] purchaseOrders PurchaseOrder[]
} }
@ -257,8 +253,8 @@ model PurchaseOrder {
submitterId String submitterId String
submitter User @relation("Submitter", fields: [submitterId], references: [id]) submitter User @relation("Submitter", fields: [submitterId], references: [id])
vesselId String? vesselId String
vessel Vessel? @relation(fields: [vesselId], references: [id]) vessel Vessel @relation(fields: [vesselId], references: [id])
accountId String accountId String
account Account @relation(fields: [accountId], references: [id]) account Account @relation(fields: [accountId], references: [id])
vendorId String? vendorId String?

View file

@ -19,20 +19,20 @@ const db = new PrismaClient();
// ─── Users ──────────────────────────────────────────────────────────────────── // ─── Users ────────────────────────────────────────────────────────────────────
const USERS: { employeeId: string; name: string; email: string; role: Role }[] = [ const USERS: { employeeId: string; name: string; email: string; role: Role }[] = [
{ employeeId: "PMS-001", name: "Akshata Teli", email: "akshata@pelagiamarine.com", role: Role.ACCOUNTS }, { employeeId: "ACC-001", name: "Akshata Teli", email: "akshata@pelagiamarine.com", role: Role.ACCOUNTS },
{ employeeId: "PMS-002", name: "Chhagan Sarang", email: "chhagan.sarang@pelagiamarine.com", role: Role.MANAGER }, { employeeId: "ACC-002", name: "Dipali K", email: "dipali.k@pelagiamarine.com", role: Role.ACCOUNTS },
{ employeeId: "PMS-003", name: "Dipali K", email: "dipali.k@pelagiamarine.com", role: Role.ACCOUNTS }, { employeeId: "ACC-003", name: "Nikita Accounts", email: "nikita.m@pelagiamarine.com", role: Role.ACCOUNTS },
{ employeeId: "PMS-004", name: "Eeshan Singh", email: "eeshan.singh@pelagiamarine.com", role: Role.TECHNICAL }, { employeeId: "ACC-004", name: "Shailesh B", email: "shailesh.b@pelagiamarine.com", role: Role.ACCOUNTS },
{ employeeId: "PMS-005", name: "Kaushal Pal Singh", email: "kps@pelagiamarine.com", role: Role.MANAGER }, { employeeId: "MGR-001", name: "Chhagan Sarang", email: "chhagan.sarang@pelagiamarine.com", role: Role.MANAGER },
{ employeeId: "PMS-006", name: "Manjuprasad B", email: "manjuprasad.b@pelagiamarine.com", role: Role.TECHNICAL }, { employeeId: "MGR-002", name: "Kaushal Pal Singh", email: "kps@pelagiamarine.com", role: Role.MANAGER },
{ employeeId: "PMS-007", name: "Mayur Deore", email: "mayur@pelagiamarine.com", role: Role.MANNING }, { employeeId: "MGR-003", name: "Rakesh Kumar Pandey", email: "rkp@pelagiamarine.com", role: Role.MANAGER },
{ employeeId: "PMS-008", name: "Nikita Accounts", email: "nikita.m@pelagiamarine.com", role: Role.ACCOUNTS }, { employeeId: "MGR-004", name: "Tajinder Kaur", email: "tajinder.kaur@pelagiamarine.com", role: Role.MANAGER },
{ employeeId: "PMS-009", name: "Rakesh Kumar Pandey", email: "rkp@pelagiamarine.com", role: Role.MANAGER }, { employeeId: "MAN-001", name: "Mayur Deore", email: "mayur@pelagiamarine.com", role: Role.MANNING },
{ employeeId: "PMS-010", name: "Shailesh B", email: "shailesh.b@pelagiamarine.com", role: Role.ACCOUNTS }, { employeeId: "MAN-002", name: "Sunil Gupta", email: "sunil.gupta@pelagiamarine.com", role: Role.MANNING },
{ employeeId: "PMS-011", name: "Shrikant T", email: "shrikant.t@pelagiamarine.com", role: Role.TECHNICAL }, { employeeId: "TCH-001", name: "Eeshan Singh", email: "eeshan.singh@pelagiamarine.com", role: Role.TECHNICAL },
{ employeeId: "PMS-012", name: "Sunil Gupta", email: "sunil.gupta@pelagiamarine.com", role: Role.MANNING }, { employeeId: "TCH-002", name: "Manjuprasad B", email: "manjuprasad.b@pelagiamarine.com", role: Role.TECHNICAL },
{ employeeId: "PMS-013", name: "Supriya Sutar", email: "supriya.s@pelagiamarine.com", role: Role.TECHNICAL }, { employeeId: "TCH-003", name: "Shrikant T", email: "shrikant.t@pelagiamarine.com", role: Role.TECHNICAL },
{ employeeId: "PMS-014", name: "Tajinder Kaur", email: "tajinder.kaur@pelagiamarine.com", role: Role.MANAGER }, { employeeId: "TCH-004", name: "Supriya Sutar", email: "supriya.s@pelagiamarine.com", role: Role.TECHNICAL },
]; ];
// ─── Sites ──────────────────────────────────────────────────────────────────── // ─── Sites ────────────────────────────────────────────────────────────────────
@ -49,17 +49,17 @@ const SITES: { code: string; name: string }[] = [
// ─── Vessels (code, name, site code) ───────────────────────────────────────── // ─── Vessels (code, name, site code) ─────────────────────────────────────────
const VESSELS: { code: string; name: string; siteCode: string }[] = [ const VESSELS: { code: string; name: string }[] = [
{ code: "HNR1", name: "HNR 1", siteCode: "HLDA" }, { code: "HNR1", name: "HNR 1" },
{ code: "HNR2", name: "HNR 2", siteCode: "LACD" }, { code: "HNR2", name: "HNR 2" },
{ code: "HNR3", name: "HNR 3", siteCode: "THKM" }, { code: "HNR3", name: "HNR 3" },
{ code: "HNR4", name: "HNR 4", siteCode: "THNK" }, { code: "HNR4", name: "HNR 4" },
{ code: "CHAMPION", name: "Champion", siteCode: "PMSK" }, { code: "CHAMPION", name: "Champion" },
{ code: "HANUNAM", name: "Hanunam", siteCode: "KVRT" }, { code: "HANUNAM", name: "Hanunam" },
{ code: "SEJAL", name: "Sejal", siteCode: "HLDA" }, { code: "SEJAL", name: "Sejal" },
{ code: "SEJAL2", name: "Sejal 2", siteCode: "LACD" }, { code: "SEJAL2", name: "Sejal 2" },
{ code: "GD3000", name: "GD 3000", siteCode: "THKM" }, { code: "GD3000", name: "GD 3000" },
{ code: "THILAKKAM", name: "Thilakkam", siteCode: "THNK" }, { code: "THILAKKAM", name: "Thilakkam" },
]; ];
// ─── Main ───────────────────────────────────────────────────────────────────── // ─── Main ─────────────────────────────────────────────────────────────────────
@ -86,31 +86,24 @@ async function main() {
// ── Sites ────────────────────────────────────────────────────────────────── // ── Sites ──────────────────────────────────────────────────────────────────
console.log("\n📍 Seeding sites…"); console.log("\n📍 Seeding sites…");
const siteIdMap = new Map<string, string>();
for (const s of SITES) { for (const s of SITES) {
const site = await db.site.upsert({ await db.site.upsert({
where: { code: s.code }, where: { code: s.code },
update: { name: s.name }, update: { name: s.name },
create: { code: s.code, name: s.name }, create: { code: s.code, name: s.name },
}); });
siteIdMap.set(s.code, site.id);
console.log(`${s.name} (${s.code})`); console.log(`${s.name} (${s.code})`);
} }
// ── Vessels ──────────────────────────────────────────────────────────────── // ── Vessels ────────────────────────────────────────────────────────────────
console.log("\n🚢 Seeding vessels…"); console.log("\n🚢 Seeding vessels…");
for (const v of VESSELS) { for (const v of VESSELS) {
const siteId = siteIdMap.get(v.siteCode);
if (!siteId) {
console.warn(` ⚠ Unknown site code "${v.siteCode}" for vessel ${v.code} — skipping`);
continue;
}
await db.vessel.upsert({ await db.vessel.upsert({
where: { code: v.code }, where: { code: v.code },
update: { name: v.name, siteId }, update: { name: v.name },
create: { code: v.code, name: v.name, siteId }, create: { code: v.code, name: v.name },
}); });
console.log(`${v.name} (${v.code})${v.siteCode}`); console.log(`${v.name} (${v.code})`);
} }
// ── Accounting Codes ─────────────────────────────────────────────────────── // ── Accounting Codes ───────────────────────────────────────────────────────

View file

@ -158,25 +158,23 @@ async function main() {
}); });
// ─── Vessels (Cost Centres) ────────────────────────────────────────────────── // ─── Vessels (Cost Centres) ──────────────────────────────────────────────────
const findOrCreateVessel = async (name: string, siteId: string, code: string) => { const findOrCreateVessel = async (name: string, code: string) => {
const vessel = await db.vessel.findFirst({ where: { name } }); const vessel = await db.vessel.findFirst({ where: { name } });
if (vessel) { if (vessel) return vessel;
return db.vessel.update({ where: { id: vessel.id }, data: { siteId } }); return db.vessel.create({ data: { name, code } });
}
return db.vessel.create({ data: { name, code, siteId } });
}; };
const mvStar = await findOrCreateVessel("MV Pelagia Star", siteBOM.id, "SITE-001"); const mvStar = await findOrCreateVessel("MV Pelagia Star", "SITE-001");
const mvWind = await findOrCreateVessel("MV Aegean Wind", siteJNP.id, "SITE-002"); const mvWind = await findOrCreateVessel("MV Aegean Wind", "SITE-002");
const mvPoseidon = await findOrCreateVessel("MV Poseidon", siteKDL.id, "SITE-003"); const mvPoseidon = await findOrCreateVessel("MV Poseidon", "SITE-003");
const mvNereid = await findOrCreateVessel("MV Nereid", siteCHE.id, "SITE-004"); const mvNereid = await findOrCreateVessel("MV Nereid", "SITE-004");
const mvThetis = await findOrCreateVessel("MV Thetis", siteKOC.id, "SITE-005"); const mvThetis = await findOrCreateVessel("MV Thetis", "SITE-005");
const mvTriton = await findOrCreateVessel("MV Triton", siteVIZ.id, "SITE-006"); const mvTriton = await findOrCreateVessel("MV Triton", "SITE-006");
const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", siteHAL.id, "SITE-007"); const mvAmphitrite = await findOrCreateVessel("MV Amphitrite", "SITE-007");
const mvProteus = await findOrCreateVessel("MV Proteus", sitePAR.id, "SITE-008"); const mvProteus = await findOrCreateVessel("MV Proteus", "SITE-008");
const mvGalatea = await findOrCreateVessel("MV Galatea", siteMNG.id, "SITE-009"); const mvGalatea = await findOrCreateVessel("MV Galatea", "SITE-009");
const mvCallisto = await findOrCreateVessel("MV Callisto", siteGOA.id, "SITE-010"); const mvCallisto = await findOrCreateVessel("MV Callisto", "SITE-010");
await findOrCreateVessel("MV Doris", siteCHE.id, "SITE-011"); await findOrCreateVessel("MV Doris", "SITE-011");
// ─── Accounting Codes (hierarchical) ───────────────────────────────────────── // ─── Accounting Codes (hierarchical) ─────────────────────────────────────────
// Seed in two passes: first create all entries without parentId, then link parents // Seed in two passes: first create all entries without parentId, then link parents