feat(approvals): manager can edit all PO fields during review
Replaces the line-items-only editor with a full ManagerEditPoForm that covers every field: title, vessel, account, vendor, project code, delivery date, PI/Quotation No+Date, Requisition No+Date, place of delivery, all structured T&C fields, and line items (with GST rate). Edit toggle is amber-styled to distinguish manager changes from submitter input. On save, a complete snapshot of original values is written to the audit trail (MANAGER_LINE_EDIT action with metadata.original). managerEditPo server action: validates manager permission, checks status == MGR_REVIEW, recalculates totalAmount as grand total including GST, and persists all updated fields. Approval detail page now fetches vessels/accounts/vendors to populate the edit form dropdowns.
This commit is contained in:
parent
ac584bce2f
commit
2ca1861226
3 changed files with 461 additions and 25 deletions
|
|
@ -0,0 +1,278 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { managerEditPo } from "./manager-po-edit-actions";
|
||||||
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
|
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
import type { Vessel, Account, Vendor, PurchaseOrder, POLineItem } from "@prisma/client";
|
||||||
|
|
||||||
|
type PoFull = PurchaseOrder & {
|
||||||
|
lineItems: POLineItem[];
|
||||||
|
vessel: { id: string; name: string };
|
||||||
|
account: { id: string; name: string; code: string };
|
||||||
|
vendor: { id: string; name: string; vendorId: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
po: PoFull;
|
||||||
|
vessels: Vessel[];
|
||||||
|
accounts: Account[];
|
||||||
|
vendors: Vendor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30 placeholder:text-neutral-400";
|
||||||
|
const LABEL = "block text-xs font-semibold text-amber-800 mb-1";
|
||||||
|
|
||||||
|
export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
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 [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
|
po.lineItems.map((li) => ({
|
||||||
|
description: li.description,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
size: li.size ?? undefined,
|
||||||
|
unitPrice: Number(li.unitPrice),
|
||||||
|
gstRate: Number((li as POLineItem & { gstRate?: unknown }).gstRate ?? 0.18),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
function isoDate(d: Date | null | undefined) {
|
||||||
|
if (!d) return "";
|
||||||
|
return new Date(d).toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const form = document.getElementById("mgr-edit-po-form") as HTMLFormElement;
|
||||||
|
const data = new FormData(form);
|
||||||
|
lineItems.forEach((item, i) => {
|
||||||
|
data.set(`lineItems[${i}].description`, item.description);
|
||||||
|
data.set(`lineItems[${i}].quantity`, String(item.quantity));
|
||||||
|
data.set(`lineItems[${i}].unit`, item.unit);
|
||||||
|
data.set(`lineItems[${i}].size`, item.size ?? "");
|
||||||
|
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
||||||
|
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await managerEditPo(po.id, data);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setPending(false);
|
||||||
|
} else {
|
||||||
|
setSaved(true);
|
||||||
|
setEditing(false);
|
||||||
|
setPending(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-6 py-4">
|
||||||
|
{saved && (
|
||||||
|
<p className="mb-3 text-xs text-amber-700 bg-amber-100 border border-amber-200 rounded px-3 py-1.5">
|
||||||
|
PO updated. Original values are preserved in the activity trail.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-sm text-amber-700 hover:text-amber-900 font-semibold underline underline-offset-2"
|
||||||
|
>
|
||||||
|
✏️ Edit PO (Manager)
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-xs text-amber-600">
|
||||||
|
Edit any field on this PO. Original values will be saved to the audit trail.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 rounded-lg border-2 border-amber-400 bg-amber-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-amber-200">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-amber-800 uppercase tracking-wide">Manager — Editing PO</p>
|
||||||
|
<p className="text-xs text-amber-600 mt-0.5">All changes will be saved to the audit trail with the original values.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditing(false); setError(""); }}
|
||||||
|
className="text-xs text-amber-700 hover:text-amber-900 underline"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="mgr-edit-po-form" className="p-6 space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||||
|
|
||||||
|
{/* Order Information */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Order Information</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className={LABEL}>Title <span className="text-danger">*</span></label>
|
||||||
|
<input name="title" required defaultValue={po.title} className={INPUT} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Vessel <span className="text-danger">*</span></label>
|
||||||
|
<select name="vesselId" required defaultValue={po.vesselId} className={INPUT}>
|
||||||
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Account / Cost Centre <span className="text-danger">*</span></label>
|
||||||
|
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
|
||||||
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Project Code</label>
|
||||||
|
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Delivery Date Required</label>
|
||||||
|
<input name="dateRequired" type="date" defaultValue={isoDate(po.dateRequired)} className={INPUT} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quotation Reference */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Quotation Reference</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>PI / Quotation No.</label>
|
||||||
|
<input name="piQuotationNo" defaultValue={extPo.piQuotationNo ?? ""} className={INPUT} placeholder="e.g. Verbal" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>PI / Quotation Date</label>
|
||||||
|
<input name="piQuotationDate" type="date" defaultValue={isoDate(extPo.piQuotationDate)} className={INPUT} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Requisition */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Requisition</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Vessel / Office Requisition No.</label>
|
||||||
|
<input name="requisitionNo" defaultValue={extPo.requisitionNo ?? ""} className={INPUT} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={LABEL}>Requisition Date</label>
|
||||||
|
<input name="requisitionDate" type="date" defaultValue={isoDate(extPo.requisitionDate)} className={INPUT} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||||||
|
<label className={LABEL}>Place of Delivery</label>
|
||||||
|
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Vendor */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
||||||
|
<label className={LABEL}>Vendor</label>
|
||||||
|
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
|
||||||
|
<option value="">No vendor selected</option>
|
||||||
|
{vendors.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Line Items */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Line Items</h3>
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-white p-4">
|
||||||
|
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Terms & Conditions */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms & Conditions</h3>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div className="rounded-lg bg-amber-100 border border-amber-200 px-3 py-2 text-xs text-amber-700 select-none">
|
||||||
|
<span className="font-semibold">1.</span> {TC_FIXED_LINE}
|
||||||
|
</div>
|
||||||
|
{([
|
||||||
|
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
|
||||||
|
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
|
||||||
|
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
|
||||||
|
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
|
||||||
|
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
|
||||||
|
] as const).map(({ n, label, name, key }) => (
|
||||||
|
<div key={name} className="flex items-center gap-3">
|
||||||
|
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right">{n}.</span>
|
||||||
|
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800">{label}</label>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
defaultValue={(extPo[key] ?? TC_DEFAULTS[key]) as string}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right mt-2">7.</span>
|
||||||
|
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800 mt-2">Others</label>
|
||||||
|
<textarea
|
||||||
|
name="tcOthers"
|
||||||
|
rows={2}
|
||||||
|
defaultValue={(extPo.tcOthers ?? TC_DEFAULTS.tcOthers) as string}
|
||||||
|
className={INPUT}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2 border-t border-amber-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-amber-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{pending ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditing(false); setError(""); }}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { createPoSchema } from "@/lib/validations/po";
|
||||||
|
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<{
|
||||||
|
description: string; quantity: number; unit: string;
|
||||||
|
size?: string; unitPrice: number; gstRate: number;
|
||||||
|
}> = [];
|
||||||
|
let i = 0;
|
||||||
|
while (formData.has(`lineItems[${i}].description`)) {
|
||||||
|
lineItems.push({
|
||||||
|
description: formData.get(`lineItems[${i}].description`) as string,
|
||||||
|
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"),
|
||||||
|
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;
|
||||||
|
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) => ({
|
||||||
|
description: li.description,
|
||||||
|
quantity: Number(li.quantity),
|
||||||
|
unit: li.unit,
|
||||||
|
size: li.size ?? undefined,
|
||||||
|
unitPrice: Number(li.unitPrice),
|
||||||
|
gstRate: Number((li as typeof li & { gstRate?: unknown }).gstRate ?? 0.18),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
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: newTotal,
|
||||||
|
lineItems: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: data.lineItems.map((item, idx) => ({
|
||||||
|
description: item.description,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { hasPermission } from "@/lib/permissions";
|
||||||
import { notFound, redirect } from "next/navigation";
|
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 { ManagerLineItemsEditor } from "./manager-line-items-editor";
|
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -21,7 +21,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const [po, vessels, accounts, vendors] = await Promise.all([
|
||||||
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
|
|
@ -33,19 +34,15 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
receipt: true,
|
receipt: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
const lineItemsForEditor = po.lineItems.map((li) => ({
|
|
||||||
description: li.description,
|
|
||||||
quantity: Number(li.quantity),
|
|
||||||
unit: li.unit,
|
|
||||||
size: li.size ?? undefined,
|
|
||||||
unitPrice: Number(li.unitPrice),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
|
@ -54,10 +51,16 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
<p className="mt-1 text-sm text-neutral-500">{po.poNumber} — {po.title}</p>
|
<p className="mt-1 text-sm text-neutral-500">{po.poNumber} — {po.title}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
|
||||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-white px-6 pb-4 pt-2">
|
|
||||||
<ManagerLineItemsEditor poId={po.id} initialItems={lineItemsForEditor} />
|
<ManagerEditPoForm
|
||||||
</div>
|
po={po}
|
||||||
|
vessels={vessels}
|
||||||
|
accounts={accounts}
|
||||||
|
vendors={vendors}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<ApprovalActions poId={po.id} poStatus={po.status} />
|
<ApprovalActions poId={po.id} poStatus={po.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue