pelagia-portal/App/app/(portal)/approvals/page.tsx
Hardik c14a22588e
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): Phase 5b — appraisal (flagged)
Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across
three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on
5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5.

What's in
- Schema: Appraisal (on CrewAssignment) + AppraisalStatus
  (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType +=
  APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal.
- State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED,
  MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject.
- Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/
  site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal
  (approve_appraisal — Manager); reject paths require remarks; notifications
  APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL.
- Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals
  tab; MPO verifies in the Verification queue (Appraisals section); Manager approves
  in the central /approvals queue (Appraisal kind).

Tests & docs
- Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) —
  raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise,
  site staff can't verify, MPO can't approve). type-check clean; full unit (245) +
  integration (205) green (verified with RESEND_API_KEY unset).
- CLAUDE.md updated — completes Phase 5 (I + H).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:09:32 +05:30

228 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { CREWING_ENABLED } from "@/lib/feature-flags";
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
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;
}>;
}
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: NonNullable<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 } }),
]);
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
const role = session.user.role;
const showCrewing =
CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver") ||
hasPermission(role, "decide_leave") ||
hasPermission(role, "approve_appraisal"));
const crewGates = showCrewing
? await db.applicationGate.findMany({
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
orderBy: { createdAt: "asc" },
include: {
application: {
include: {
crewMember: { select: { name: true } },
requisition: { select: { code: true, rank: { select: { name: true } } } },
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
},
},
},
})
: [];
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
const sal = g.application.salaryStructures[0];
const detail =
g.gate === "SALARY" && sal
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
: g.gate === "WAIVER"
? "Returning crew — interview waiver"
: "Interview cleared";
return {
id: g.applicationId,
kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code,
detail,
link: `/crewing/applications/${g.applicationId}`,
};
});
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
? (await db.leaveRequest.findMany({
where: { status: "APPLIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((l) => ({
id: l.id,
kind: "LEAVE" as CrewApprovalKind,
candidateName: l.assignment.crewMember.name,
rank: l.assignment.rank.name,
requisitionCode: `${l.fromDate.toLocaleDateString()}${l.toDate.toLocaleDateString()}`,
detail: l.type.toLowerCase(),
link: "/crewing/leave",
}))
: [];
// MPO-verified appraisals awaiting Manager approval (§8.13/§8.14).
const appraisalItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "approve_appraisal"))
? (await db.appraisal.findMany({
where: { status: "MPO_VERIFIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((a) => ({
id: a.id,
kind: "APPRAISAL" as CrewApprovalKind,
candidateName: a.assignment.crewMember.name,
rank: a.assignment.rank.name,
requisitionCode: a.period,
detail: "MPO-verified appraisal",
link: "/approvals",
}))
: [];
const allCrewItems = [...crewItems, ...leaveItems, ...appraisalItems];
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>
) : (
<>
{/* ── Desktop table ─────────────────────────────────────────── */}
<div className="hidden md:block 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">Cost Centre</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>
{/* ── Mobile cards ──────────────────────────────────────────── */}
<div className="md:hidden space-y-3">
{pending.map((po) => (
<Link key={po.id} href={`/approvals/${po.id}`} className="block">
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm active:bg-neutral-50 transition-colors">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="font-mono text-xs text-neutral-500">{po.poNumber}</span>
<span className="text-xs text-neutral-400 shrink-0">
{po.submittedAt ? formatDate(po.submittedAt) : "—"}
</span>
</div>
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-500 truncate max-w-[55%]">
{po.submitter.name} · {po.vessel.name}
</span>
<span className="font-mono font-semibold text-neutral-900 shrink-0">
{formatCurrency(Number(po.totalAmount), po.currency)}
</span>
</div>
<div className="mt-3">
<span className="block w-full rounded-lg bg-primary-600 py-2 text-center text-sm font-semibold text-white">
Review
</span>
</div>
</div>
</Link>
))}
</div>
</>
)}
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
</div>
);
}