- Undo Vessel→Cost Centre rename in admin (admin shows "Vessel Management" again) - Sidebar: "Cost Centres"→"Vessels", "Accounts"→"Accounting Codes" - PO forms (new/edit/import/manager-edit) now show both Vessels (with code) and Sites in the Cost Centre dropdown, encoded as v:<id> / s:<id> via a costCentreRef field - vesselId on PurchaseOrder is now nullable; siteId is set when a site is the cost centre - History, approvals, dashboard, my-orders, payments display vessel.name ?? site.name as Cost Centre - History and approvals cost centre filters use costCentreRef URL param supporting both types - Admin vessel form: adds Site assignment dropdown - Admin accounts: renamed to "Accounting Code" throughout (pages, forms, sidebar) - PO detail and exports: "Account" label renamed to "Accounting Code" - Site detail: "Assigned Vessels (Cost Centres)" heading; vessel detail breadcrumb fixed - Create PO links from vessel/site detail use ?costCentreRef= param - Export routes handle costCentreRef filter param (with legacy vesselId fallback) - DB migration: ALTER TABLE PurchaseOrder ALTER COLUMN vesselId DROP NOT NULL - CLAUDE.md updated with Cost Centre Model documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
7.5 KiB
TypeScript
201 lines
7.5 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"),
|
|
costCentreRef: formData.get("costCentreRef"),
|
|
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 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(
|
|
(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,
|
|
site: { select: { name: 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 ?? currentPo.site?.name ?? null,
|
|
vesselId: currentPo.vesselId ?? currentPo.siteId ?? "",
|
|
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: newVesselId,
|
|
siteId: newCostCentreSiteId,
|
|
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 };
|
|
}
|