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