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.
This commit is contained in:
Hardik 2026-05-06 00:15:05 +05:30
parent 7e12e24af0
commit a685e093ac
7 changed files with 697 additions and 0 deletions

View file

@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}

View file

@ -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<string | null>(null);
const [pending, setPending] = useState<string | null>(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 (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Note {activeAction !== "approve_note" ? "(required)" : "(optional)"}
</label>
<textarea
rows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 resize-none"
placeholder={
activeAction === "reject"
? "Reason for rejection…"
: activeAction === "request_edits"
? "What needs to be changed…"
: "Optional note for the submitter…"
}
/>
</div>
)}
{error && (
<p className="mb-4 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
setActiveAction("reject");
if (activeAction === "reject") dispatch("reject", true);
}}
disabled={!!pending}
className="rounded-lg border border-danger bg-danger-50 px-4 py-2.5 text-sm font-medium text-danger-700 hover:bg-danger-100 disabled:opacity-60 transition-colors"
>
{pending === "reject" ? "Rejecting…" : activeAction === "reject" ? "Confirm Reject" : "Reject"}
</button>
<button
onClick={() => {
setActiveAction("request_edits");
if (activeAction === "request_edits") dispatch("request_edits", true);
}}
disabled={!!pending}
className="rounded-lg border border-warning bg-warning-50 px-4 py-2.5 text-sm font-medium text-warning-700 hover:bg-warning-100 disabled:opacity-60 transition-colors"
>
{pending === "request_edits" ? "Sending…" : activeAction === "request_edits" ? "Send Edit Request" : "Request Edits"}
</button>
<button
onClick={() => dispatch("request_vendor_id")}
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 transition-colors"
>
{pending === "request_vendor_id" ? "Sending…" : "Request Vendor ID"}
</button>
<button
onClick={() => {
setActiveAction("approve_note");
if (activeAction === "approve_note") dispatch("approve_note");
}}
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 transition-colors"
>
{pending === "approve_note" ? "Approving…" : activeAction === "approve_note" ? "Confirm Approve with Remarks" : "Approve with Remarks"}
</button>
<button
onClick={() => dispatch("approve")}
disabled={!!pending}
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
>
{pending === "approve" ? "Approving…" : "Approve"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,79 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
type ActionResult = { ok: true } | { error: string };
const lineItemSchema = z.object({
description: z.string().min(1),
quantity: z.coerce.number().positive(),
unit: z.string().min(1),
size: z.string().optional(),
unitPrice: z.coerce.number().nonnegative(),
});
export async function managerEditLineItems({
poId,
lineItems,
}: {
poId: string;
lineItems: Array<{ description: string; quantity: number; unit: string; size?: string; unitPrice: number }>;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
return { error: "Forbidden" };
}
const parsed = z.array(lineItemSchema).min(1).safeParse(lineItems);
if (!parsed.success) return { error: parsed.error.errors[0].message };
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: "Line items can only be edited while the PO is under review." };
const originalSnapshot = po.lineItems.map((li) => ({
description: li.description,
quantity: Number(li.quantity),
unit: li.unit,
size: li.size ?? undefined,
unitPrice: Number(li.unitPrice),
}));
const newTotal = parsed.data.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0);
await db.purchaseOrder.update({
where: { id: poId },
data: {
totalAmount: newTotal,
lineItems: {
deleteMany: {},
create: parsed.data.map((item, idx) => ({
description: item.description,
quantity: item.quantity,
unit: item.unit,
size: item.size ?? null,
unitPrice: item.unitPrice,
totalPrice: item.quantity * item.unitPrice,
sortOrder: idx,
})),
},
actions: {
create: {
actionType: "MANAGER_LINE_EDIT",
actorId: session.user.id,
metadata: { original: originalSnapshot },
},
},
},
});
revalidatePath(`/approvals/${poId}`);
return { ok: true };
}

View file

@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { managerEditLineItems } from "./manager-line-edit-actions";
import type { LineItemInput } from "@/lib/validations/po";
interface Props {
poId: string;
initialItems: LineItemInput[];
}
export function ManagerLineItemsEditor({ poId, initialItems }: Props) {
const [editing, setEditing] = useState(false);
const [items, setItems] = useState<LineItemInput[]>(initialItems);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
async function handleSave() {
setPending(true);
setError("");
const result = await managerEditLineItems({ poId, lineItems: items });
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setSaved(true);
setEditing(false);
setPending(false);
}
}
function handleCancel() {
setItems(initialItems);
setEditing(false);
setError("");
}
if (!editing) {
return (
<div className="mt-2">
{saved && (
<p className="mb-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
Line items updated. The original values are shown with strikethrough on the detail view.
</p>
)}
<button
onClick={() => setEditing(true)}
className="text-sm text-amber-700 hover:text-amber-800 font-medium underline underline-offset-2"
>
Edit line items
</button>
</div>
);
}
return (
<div className="mt-4 rounded-lg border-2 border-amber-300 bg-amber-50 p-4">
<p className="text-xs font-semibold text-amber-700 mb-3 uppercase tracking-wide">
Manager Editing Line Items
</p>
<LineItemsEditor items={items} onChange={setItems} />
{error && <p className="mt-2 text-sm text-danger-700">{error}</p>}
<div className="mt-4 flex gap-3">
<button
onClick={handleSave}
disabled={pending}
className="rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white hover:bg-amber-700 disabled:opacity-60 transition-colors"
>
{pending ? "Saving…" : "Save Changes"}
</button>
<button
onClick={handleCancel}
disabled={pending}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
Cancel
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { notFound, redirect } from "next/navigation";
import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail";
import { ManagerLineItemsEditor } from "./manager-line-items-editor";
import type { Metadata } from "next";
interface Props {
params: Promise<{ id: string }>;
}
export const metadata: Metadata = { title: "Review PO" };
export default async function ApprovalDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
const { id } = await params;
const po = await db.purchaseOrder.findUnique({
where: { id },
include: {
submitter: true,
vessel: true,
account: true,
vendor: true,
lineItems: { orderBy: { sortOrder: "asc" } },
documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
receipt: true,
},
});
if (!po) notFound();
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 (
<div className="max-w-4xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Review Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500">{po.poNumber} {po.title}</p>
</div>
</div>
<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} />
</div>
<div className="mt-6">
<ApprovalActions poId={po.id} poStatus={po.status} />
</div>
</div>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
interface Props {
vessels: { id: string; name: string }[];
}
export function ApprovalsSearch({ vessels }: Props) {
const router = useRouter();
const sp = useSearchParams();
const [q, setQ] = useState(sp.get("q") ?? "");
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
function apply() {
const params = new URLSearchParams();
if (q.trim()) params.set("q", q.trim());
if (vesselId) params.set("vesselId", vesselId);
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
router.push(`/approvals?${params.toString()}`);
}
function clear() {
setQ(""); setVesselId(""); setDateFrom(""); setDateTo("");
router.push("/approvals");
}
const hasFilters = q || vesselId || dateFrom || dateTo;
return (
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-neutral-600 mb-1">Search (PO number or submitter)</label>
<input type="text" value={q} onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && apply()}
placeholder="e.g. PO-0012 or John…"
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Vessel</label>
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
<option value="">All vessels</option>
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 mb-1">Submitted from</label>
<input type="date" value={dateFrom} onChange={(e) => setDateFrom(e.target.value)}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<button onClick={apply}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700 transition-colors">
Search
</button>
{hasFilters && (
<button onClick={clear}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50 transition-colors">
Clear
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,110 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { ApprovalsSearch } from "./approvals-search";
import { Suspense } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Approvals" };
interface Props {
searchParams: Promise<{
q?: string;
vesselId?: string;
dateFrom?: string;
dateTo?: string;
}>;
}
export default async function ApprovalsPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
const { q, vesselId, dateFrom } = await searchParams;
const where: Parameters<typeof db.purchaseOrder.findMany>[0]["where"] = {
status: "MGR_REVIEW",
};
if (q?.trim()) {
where.OR = [
{ poNumber: { contains: q.trim(), mode: "insensitive" } },
{ submitter: { name: { contains: q.trim(), mode: "insensitive" } } },
{ title: { contains: q.trim(), mode: "insensitive" } },
];
}
if (vesselId) where.vesselId = vesselId;
if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) };
const [pending, vessels] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { submittedAt: "asc" },
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
return (
<div>
<div className="mb-4">
<h1 className="text-2xl font-semibold text-neutral-900">Approval Queue</h1>
<p className="mt-1 text-sm text-neutral-500">
{pending.length} order{pending.length !== 1 ? "s" : ""} awaiting your decision
</p>
</div>
<Suspense>
<ApprovalsSearch vessels={vessels} />
</Suspense>
{pending.length === 0 ? (
<div className="rounded-lg border border-neutral-200 bg-white p-12 text-center">
<p className="text-neutral-500">No purchase orders awaiting approval.</p>
</div>
) : (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vessel</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitted</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{pending.map((po) => (
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td>
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
<td className="px-4 py-3 text-right font-mono text-sm">
{formatCurrency(Number(po.totalAmount), po.currency)}
</td>
<td className="px-4 py-3 text-neutral-500">
{po.submittedAt ? formatDate(po.submittedAt) : "—"}
</td>
<td className="px-4 py-3">
<Link href={`/approvals/${po.id}`} className="text-primary-600 hover:text-primary-700 font-medium">
Review
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}