pelagia-portal/App/app/(portal)/po/[id]/edit/actions.ts
Hardik 280966a369 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>
2026-05-30 18:14:24 +05:30

197 lines
7.2 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { createPoSchema } from "@/lib/validations/po";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
function parseLineItems(formData: FormData) {
const items = [];
let i = 0;
while (formData.has(`lineItems[${i}].name`)) {
items.push({
name: formData.get(`lineItems[${i}].name`) as string,
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
unit: formData.get(`lineItems[${i}].unit`) as string,
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
});
i++;
}
return items;
}
export async function updatePo(
poId: string,
formData: FormData
): Promise<{ id: string } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
if (!po) return { error: "PO not found" };
if (!["DRAFT", "EDITS_REQUESTED"].includes(po.status)) {
return { error: "This PO cannot be edited in its current state." };
}
if (po.submitterId !== session.user.id && session.user.role !== "SUPERUSER") {
return { error: "You can only edit your own purchase orders." };
}
const intent = formData.get("intent") as "save" | "submit" | "resubmit";
const parsed = createPoSchema.safeParse({
title: formData.get("title"),
vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"),
projectCode: formData.get("projectCode") || undefined,
dateRequired: formData.get("dateRequired") || undefined,
vendorId: formData.get("vendorId") || undefined,
piQuotationNo: formData.get("piQuotationNo") || undefined,
piQuotationDate: formData.get("piQuotationDate") || undefined,
requisitionNo: formData.get("requisitionNo") || undefined,
requisitionDate: formData.get("requisitionDate") || undefined,
placeOfDelivery: formData.get("placeOfDelivery") || undefined,
tcDelivery: formData.get("tcDelivery") || undefined,
tcDispatch: formData.get("tcDispatch") || undefined,
tcInspection: formData.get("tcInspection") || undefined,
tcTransitInsurance: formData.get("tcTransitInsurance") || undefined,
tcPaymentTerms: formData.get("tcPaymentTerms") || undefined,
tcOthers: formData.get("tcOthers") || undefined,
lineItems: parseLineItems(formData),
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
}
const data = parsed.data;
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
);
const isSubmit = intent === "submit" && po.status === "DRAFT";
const isResubmit = intent === "resubmit" && po.status === "EDITS_REQUESTED";
const shouldSubmit = isSubmit || isResubmit;
// Before mutating, snapshot the current PO state so the manager can see
// exactly what the submitter changed when they resubmit after edits requested.
let resubmitSnapshot: {
lineItems: Array<{
name: string; description: string | null; quantity: number;
unit: string; size: string | null; unitPrice: number; gstRate: number;
}>;
fields: {
title: string;
vessel: string | null; vesselId: string;
account: string; accountId: string;
vendor: string | null; vendorId: string | null;
projectCode: string | null; dateRequired: string | null; placeOfDelivery: string | null;
};
} | null = null;
if (isResubmit) {
const currentPo = await db.purchaseOrder.findUnique({
where: { id: poId },
include: {
lineItems: { orderBy: { sortOrder: "asc" } },
vessel: true,
account: true,
vendor: true,
},
});
if (currentPo) {
resubmitSnapshot = {
lineItems: currentPo.lineItems.map((li) => ({
name: li.name,
description: li.description,
quantity: Number(li.quantity),
unit: li.unit,
size: li.size,
unitPrice: Number(li.unitPrice),
gstRate: Number(li.gstRate),
})),
fields: {
title: currentPo.title,
vessel: currentPo.vessel?.name ?? null,
vesselId: currentPo.vesselId,
account: `${currentPo.account.name} (${currentPo.account.code})`,
accountId: currentPo.accountId,
vendor: currentPo.vendor?.name ?? null,
vendorId: currentPo.vendorId,
projectCode: currentPo.projectCode,
dateRequired: currentPo.dateRequired?.toISOString() ?? null,
placeOfDelivery: currentPo.placeOfDelivery,
},
};
}
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
title: data.title,
vesselId: data.vesselId,
accountId: data.accountId,
vendorId: data.vendorId ?? null,
projectCode: data.projectCode ?? null,
dateRequired: data.dateRequired ? new Date(data.dateRequired) : null,
piQuotationNo: data.piQuotationNo ?? null,
piQuotationDate: data.piQuotationDate ? new Date(data.piQuotationDate) : null,
requisitionNo: data.requisitionNo ?? null,
requisitionDate: data.requisitionDate ? new Date(data.requisitionDate) : null,
placeOfDelivery: data.placeOfDelivery ?? null,
tcDelivery: data.tcDelivery ?? null,
tcDispatch: data.tcDispatch ?? null,
tcInspection: data.tcInspection ?? null,
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
totalAmount: total,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt,
lineItems: {
deleteMany: {},
create: data.lineItems.map((item, idx) => ({
name: item.name,
description: item.description ?? null,
quantity: item.quantity,
unit: item.unit,
size: item.size ?? null,
unitPrice: item.unitPrice,
totalPrice: item.quantity * item.unitPrice,
gstRate: item.gstRate,
sortOrder: idx,
accountId: (item as { accountId?: string }).accountId ?? null,
})),
},
actions: {
create: shouldSubmit
? {
actionType: "SUBMITTED",
actorId: session.user.id,
...(isResubmit && resubmitSnapshot ? { metadata: { editSnapshot: resubmitSnapshot } } : {}),
}
: undefined,
},
},
});
if (shouldSubmit) {
const [fullPo, managers] = await Promise.all([
db.purchaseOrder.findUnique({ where: { id: poId }, include: { submitter: true } }),
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
]);
if (fullPo) {
await notify({ event: "PO_SUBMITTED", po: fullPo, recipients: managers });
}
}
revalidatePath(`/po/${poId}`);
revalidatePath("/dashboard");
return { id: poId };
}