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:
parent
7e12e24af0
commit
a685e093ac
7 changed files with 697 additions and 0 deletions
165
App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts
Normal file
165
App/pelagia-portal/app/(portal)/approvals/[id]/actions.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx
Normal file
66
App/pelagia-portal/app/(portal)/approvals/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
App/pelagia-portal/app/(portal)/approvals/page.tsx
Normal file
110
App/pelagia-portal/app/(portal)/approvals/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue