pelagia-portal/App/app/(portal)/approvals/[id]/manager-po-edit-actions.ts
Hardik 3babfe26ef
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 32s
feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
Follow-up to the merged #11 PR (which shipped the enum-based catalogue): make
categories user-defined data and the PO T&C a dynamic editor.

- categories are a TermsCategory TABLE (not an enum) — admins add new ones;
- every PO T&C line is catalogued, incl. the previously-fixed boilerplate
  (seeded under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
  clause (components/po/po-terms-editor.tsx), used by new/edit/manager-edit.

Migration: the already-released 20260624140000 migration is untouched; a new
20260624150000 FORWARD migration renames the enum, creates the table, migrates
existing enum clauses onto category rows, adds isDefault/sortOrder + the two
fixed lines under General, and adds PurchaseOrder.terms (JSON snapshot that
supersedes the legacy tc* columns for export/detail; old POs fall back to tc*).

Tests rewritten for category creation + catalogue/default helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:43:24 +05:30

166 lines
6.1 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { revalidatePath } from "next/cache";
export async function managerEditPo(
poId: string,
formData: FormData
): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
return { error: "Forbidden" };
}
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { lineItems: { orderBy: { sortOrder: "asc" } } },
});
if (!po) return { error: "PO not found" };
if (po.status !== "MGR_REVIEW") return { error: "PO can only be edited while under review." };
// Parse line items from FormData
const lineItems: Array<{
name: string; description?: string; quantity: number; unit: string;
size?: string; unitPrice: number; gstRate: number;
}> = [];
let i = 0;
while (formData.has(`lineItems[${i}].name`)) {
lineItems.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),
});
i++;
}
const parsed = createPoSchema.safeParse({
title: formData.get("title"),
vesselId: formData.get("vesselId"),
accountId: formData.get("accountId"),
companyId: (formData.get("companyId") as string) || undefined,
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,
});
if (!parsed.success) {
return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
}
const data = parsed.data;
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
const newTotal = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
);
// Snapshot all original values for the audit trail
const extPo = po as typeof po & {
piQuotationNo?: string | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionDate?: Date | null;
placeOfDelivery?: string | null;
tcDelivery?: string | null; tcDispatch?: string | null;
tcInspection?: string | null; tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null; tcOthers?: string | null;
};
const original = {
title: po.title,
vesselId: po.vesselId,
accountId: po.accountId,
vendorId: po.vendorId,
projectCode: po.projectCode,
piQuotationNo: extPo.piQuotationNo,
requisitionNo: extPo.requisitionNo,
placeOfDelivery: extPo.placeOfDelivery,
tcDelivery: extPo.tcDelivery,
tcDispatch: extPo.tcDispatch,
tcInspection: extPo.tcInspection,
tcTransitInsurance: extPo.tcTransitInsurance,
tcPaymentTerms: extPo.tcPaymentTerms,
tcOthers: extPo.tcOthers,
totalAmount: Number(po.totalAmount),
lineItems: po.lineItems.map((li) => ({
name: (li as typeof li & { name: string }).name,
description: li.description ?? undefined,
quantity: Number(li.quantity),
unit: li.unit,
size: li.size ?? undefined,
unitPrice: Number(li.unitPrice),
gstRate: Number(li.gstRate ?? 0.18),
})),
};
await db.purchaseOrder.update({
where: { id: poId },
data: {
title: data.title,
vesselId: data.vesselId,
accountId: data.accountId,
companyId: data.companyId ?? null,
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,
terms,
totalAmount: newTotal,
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,
})),
},
actions: {
create: {
actionType: "MANAGER_LINE_EDIT",
actorId: session.user.id,
metadata: { original },
},
},
},
});
revalidatePath(`/approvals/${poId}`);
return { ok: true };
}