From a685e093acae3c01d886fd0b476faeafd44103a9 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 6 May 2026 00:15:05 +0530 Subject: [PATCH] feat(approvals): manager approval queue with decisions and line item editing Approval queue: paginated list with search (PO number, vessel, submitter, date range). Decision actions: approve, approve with note, reject (with reason), request edits, request vendor ID. Manager line edit: amend line items during review; original snapshot saved to audit trail; diff shown with amber strikethrough on PO detail. --- .../app/(portal)/approvals/[id]/actions.ts | 165 ++++++++++++++++++ .../approvals/[id]/approval-actions.tsx | 121 +++++++++++++ .../[id]/manager-line-edit-actions.ts | 79 +++++++++ .../[id]/manager-line-items-editor.tsx | 83 +++++++++ .../app/(portal)/approvals/[id]/page.tsx | 66 +++++++ .../(portal)/approvals/approvals-search.tsx | 73 ++++++++ .../app/(portal)/approvals/page.tsx | 110 ++++++++++++ 7 files changed, 697 insertions(+) create mode 100644 App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/approvals/[id]/approval-actions.tsx create mode 100644 App/pelagia-portal/app/(portal)/approvals/[id]/manager-line-edit-actions.ts create mode 100644 App/pelagia-portal/app/(portal)/approvals/[id]/manager-line-items-editor.tsx create mode 100644 App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/approvals/approvals-search.tsx create mode 100644 App/pelagia-portal/app/(portal)/approvals/page.tsx diff --git a/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts b/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts new file mode 100644 index 0000000..6353ddf --- /dev/null +++ b/App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts @@ -0,0 +1,165 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { canPerformAction } from "@/lib/po-state-machine"; +import { notify } from "@/lib/notifier"; +import { revalidatePath } from "next/cache"; + +type ActionResult = { ok: true } | { error: string }; + +export async function approvepo({ + poId, + note, + withNote = false, +}: { + poId: string; + note?: string; + withNote?: boolean; +}): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + + const action = withNote ? "approve_with_note" : "approve"; + if (!canPerformAction(po.status, action, session.user.role)) { + return { error: "You cannot approve this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "MGR_APPROVED", + approvedAt: new Date(), + managerNote: note ?? null, + actions: { + create: { + actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED", + note: note ?? null, + actorId: session.user.id, + }, + }, + }, + }); + + const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); + await notify({ + event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED", + po, + recipients: [po.submitter, ...accounts], + note, + }); + + revalidatePath("/approvals"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} + +export async function rejectPo({ + poId, + note, +}: { + poId: string; + note: string; +}): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "reject", session.user.role)) { + return { error: "You cannot reject this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "REJECTED", + managerNote: note, + actions: { + create: { actionType: "REJECTED", note, actorId: session.user.id }, + }, + }, + }); + + await notify({ event: "PO_REJECTED", po, recipients: [po.submitter], note }); + + revalidatePath("/approvals"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} + +export async function requestEdits({ + poId, + note, +}: { + poId: string; + note: string; +}): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "request_edits", session.user.role)) { + return { error: "You cannot request edits on this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "EDITS_REQUESTED", + managerNote: note, + actions: { + create: { actionType: "EDITS_REQUESTED", note, actorId: session.user.id }, + }, + }, + }); + + await notify({ event: "EDITS_REQUESTED", po, recipients: [po.submitter], note }); + + revalidatePath("/approvals"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} + +export async function requestVendorId({ poId }: { poId: string }): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const po = await db.purchaseOrder.findUnique({ + where: { id: poId }, + include: { submitter: true }, + }); + if (!po) return { error: "PO not found" }; + if (!canPerformAction(po.status, "request_vendor_id", session.user.role)) { + return { error: "You cannot request a vendor ID for this PO." }; + } + + await db.purchaseOrder.update({ + where: { id: poId }, + data: { + status: "VENDOR_ID_PENDING", + actions: { + create: { actionType: "VENDOR_ID_REQUESTED", actorId: session.user.id }, + }, + }, + }); + + await notify({ event: "VENDOR_ID_REQUESTED", po, recipients: [po.submitter] }); + + revalidatePath("/approvals"); + revalidatePath(`/po/${poId}`); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/approvals/[id]/approval-actions.tsx b/App/pelagia-portal/app/(portal)/approvals/[id]/approval-actions.tsx new file mode 100644 index 0000000..fee7e5d --- /dev/null +++ b/App/pelagia-portal/app/(portal)/approvals/[id]/approval-actions.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { approvepo, rejectPo, requestEdits, requestVendorId } from "./actions"; +import type { POStatus } from "@prisma/client"; + +export function ApprovalActions({ + poId, + poStatus, +}: { + poId: string; + poStatus: POStatus; +}) { + const router = useRouter(); + const [note, setNote] = useState(""); + const [activeAction, setActiveAction] = useState(null); + const [pending, setPending] = useState(null); + const [error, setError] = useState(""); + + async function dispatch(action: string, requireNote = false) { + if (requireNote && !note.trim()) { + setError("A note is required for this action."); + return; + } + setPending(action); + setError(""); + let result: { ok: true } | { error: string } | undefined; + if (action === "approve") result = await approvepo({ poId, note }); + else if (action === "approve_note") result = await approvepo({ poId, note, withNote: true }); + else if (action === "reject") result = await rejectPo({ poId, note }); + else if (action === "request_edits") result = await requestEdits({ poId, note }); + else if (action === "request_vendor_id") result = await requestVendorId({ poId }); + + if (result && "error" in result) { + setError(result.error); + setPending(null); + } else { + router.push("/approvals"); + router.refresh(); + } + } + + return ( +
+

Decision

+ + {(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && ( +
+ +