From 3ec3a2b4ef5ece7a1253a4b892ce84629a7c235a Mon Sep 17 00:00:00 2001
From: Hardik
Date: Mon, 22 Jun 2026 18:49:12 +0530
Subject: [PATCH] =?UTF-8?q?feat(crewing):=20Phase=203b=20=E2=80=94=20recru?=
=?UTF-8?q?itment=20pipeline=20(flagged)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Second slice of Phase 3 (stacked on 3a candidates). The gated 7-stage
recruitment pipeline per Crewing-Implementation-Spec §5.1/§8.4–8.5/§8.13.
Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged.
What's in
- Schema (crewing_pipeline migration): Application (one per requisition+candidate)
+ 7-stage ApplicationStage; ApplicationGate (SALARY/SELECTION/WAIVER pending =
Manager queue items); ReferenceCheck; effective-dated SalaryStructure (attached
to the Application now, bound to the assignment in 3c); minimal BankDetail/EpfDetail
captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). CrewAction +=
applicationId; pipeline CrewActionTypes.
- State machine: lib/application-pipeline.ts — sourcing advances MPO/Manager;
approve_salary + select are Manager-only; orthogonal canReject; BOARD_STAGES.
- Actions: addApplication (first candidate → requisition SHORTLISTING), advanceStage,
recordReferenceCheck, verifyDocuments (bank/EPF), agreeSalary→approveSalary/returnSalary,
recordInterviewResult, requestInterviewWaiver→approve/decline, selectCandidate
(→ requisition SELECTED)/returnSelection, rejectApplication. Waiver never automatic (R2).
Notifications SALARY/SELECTION/WAIVER + CANDIDATE_PROPOSED.
- Screens: pipeline board per requisition (7 columns + Add candidate); application
workhorse (7-step stepper + adaptive per-stage action card); "Open pipeline" on the
requisition detail. Central /approvals gains a crewing section (inline Approve/Return)
for one unified Manager queue (§8.13 R8).
Tests & docs
- Unit: application-pipeline.test.ts (9). Integration: applications.test.ts (10) —
full happy path, salary/selection/waiver approvals + Manager-only gating, failed
interview, reject, site-staff lockout. type-check clean; full unit (234) + integration
(163) green.
- CLAUDE.md "Crewing" updated with the Phase 3b surface.
Deferred: onboarding (Epic D, Phase 3c) — SELECTED → ONBOARDED, CrewAssignment,
employeeId, requisition → FILLED, salary bound to the assignment.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
App/CLAUDE.md | 8 +
.../(portal)/approvals/crewing-approvals.tsx | 113 ++++
App/app/(portal)/approvals/page.tsx | 47 ++
.../crewing/applications/[id]/page.tsx | 142 +++++
.../(portal)/crewing/applications/actions.ts | 537 ++++++++++++++++++
.../applications/application-action-card.tsx | 347 +++++++++++
.../crewing/applications/application-ui.ts | 47 ++
.../crewing/requisitions/[id]/page.tsx | 26 +-
.../requisitions/[id]/pipeline/page.tsx | 63 ++
.../[id]/pipeline/pipeline-board.tsx | 125 ++++
App/lib/application-pipeline.ts | 99 ++++
App/lib/notifier.ts | 10 +-
App/lib/requisition-service.ts | 7 +
.../migration.sql | 168 ++++++
App/prisma/schema.prisma | 176 +++++-
App/tests/integration/applications.test.ts | 209 +++++++
App/tests/unit/application-pipeline.test.ts | 74 +++
17 files changed, 2189 insertions(+), 9 deletions(-)
create mode 100644 App/app/(portal)/approvals/crewing-approvals.tsx
create mode 100644 App/app/(portal)/crewing/applications/[id]/page.tsx
create mode 100644 App/app/(portal)/crewing/applications/actions.ts
create mode 100644 App/app/(portal)/crewing/applications/application-action-card.tsx
create mode 100644 App/app/(portal)/crewing/applications/application-ui.ts
create mode 100644 App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
create mode 100644 App/app/(portal)/crewing/requisitions/[id]/pipeline/pipeline-board.tsx
create mode 100644 App/lib/application-pipeline.ts
create mode 100644 App/prisma/migrations/20260622130627_crewing_pipeline/migration.sql
create mode 100644 App/tests/integration/applications.test.ts
create mode 100644 App/tests/unit/application-pipeline.test.ts
diff --git a/App/CLAUDE.md b/App/CLAUDE.md
index 0820d46..1238bf5 100644
--- a/App/CLAUDE.md
+++ b/App/CLAUDE.md
@@ -143,6 +143,14 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
+**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):**
+
+- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`.
+- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns.
+- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`).
+- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail.
+- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue.
+
### GST Calculation
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
diff --git a/App/app/(portal)/approvals/crewing-approvals.tsx b/App/app/(portal)/approvals/crewing-approvals.tsx
new file mode 100644
index 0000000..22ff719
--- /dev/null
+++ b/App/app/(portal)/approvals/crewing-approvals.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { Badge } from "@/components/ui/badge";
+import { AdminDialog } from "@/components/ui/admin-dialog";
+import {
+ approveSalary,
+ returnSalary,
+ selectCandidate,
+ returnSelection,
+ approveInterviewWaiver,
+ declineInterviewWaiver,
+} from "../crewing/applications/actions";
+
+export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER";
+
+export type CrewApprovalItem = {
+ applicationId: string;
+ kind: CrewApprovalKind;
+ candidateName: string;
+ rank: string;
+ requisitionCode: string;
+ detail: string; // amount for salary, etc.
+};
+
+const KIND_LABEL: Record = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
+const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
+
+const approveFn: Record Promise<{ ok: true } | { error: string }>> = {
+ SALARY: approveSalary,
+ SELECTION: selectCandidate,
+ WAIVER: approveInterviewWaiver,
+};
+const returnFn: Record Promise<{ ok: true } | { error: string }>> = {
+ SALARY: returnSalary,
+ SELECTION: returnSelection,
+ WAIVER: declineInterviewWaiver,
+};
+
+function Row({ item }: { item: CrewApprovalItem }) {
+ const router = useRouter();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ const [returnOpen, setReturnOpen] = useState(false);
+ const [reason, setReason] = useState("");
+
+ async function approve() {
+ setPending(true); setError("");
+ const res = await approveFn[item.kind](item.applicationId);
+ setPending(false);
+ if ("error" in res) setError(res.error); else router.refresh();
+ }
+ async function doReturn(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true); setError("");
+ const res = await returnFn[item.kind](item.applicationId, reason);
+ setPending(false);
+ if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
+ }
+
+ return (
+
+ | {KIND_LABEL[item.kind]} |
+
+ {item.candidateName}
+ {item.rank} · {item.requisitionCode}
+ |
+ {item.detail} |
+
+
+
+
+
+ {error && {error} }
+ setReturnOpen(false)}>
+
+
+ |
+
+ );
+}
+
+export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
+ return (
+
+
Crewing approvals
+
{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision
+
+
+
+
+ | Kind |
+ Candidate |
+ Detail |
+ |
+
+
+
+ {items.map((item) =>
)}
+
+
+
+
+ );
+}
diff --git a/App/app/(portal)/approvals/page.tsx b/App/app/(portal)/approvals/page.tsx
index 067d250..27faa98 100644
--- a/App/app/(portal)/approvals/page.tsx
+++ b/App/app/(portal)/approvals/page.tsx
@@ -5,6 +5,8 @@ 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";
@@ -49,6 +51,49 @@ export default async function ApprovalsPage({ searchParams }: Props) {
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"));
+
+ 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 {
+ applicationId: g.applicationId,
+ kind: g.gate as CrewApprovalKind,
+ candidateName: g.application.crewMember.name,
+ rank: g.application.requisition.rank.name,
+ requisitionCode: g.application.requisition.code,
+ detail,
+ };
+ });
+
return (
@@ -137,6 +182,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
>
)}
+
+ {showCrewing && crewItems.length > 0 &&
}
);
}
diff --git a/App/app/(portal)/crewing/applications/[id]/page.tsx b/App/app/(portal)/crewing/applications/[id]/page.tsx
new file mode 100644
index 0000000..2fdb825
--- /dev/null
+++ b/App/app/(portal)/crewing/applications/[id]/page.tsx
@@ -0,0 +1,142 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { hasPermission } from "@/lib/permissions";
+import { CREWING_ENABLED } from "@/lib/feature-flags";
+import { redirect, notFound } from "next/navigation";
+import Link from "next/link";
+import { ArrowLeft, Check } from "lucide-react";
+import { Badge } from "@/components/ui/badge";
+import { ApplicationActionCard } from "../application-action-card";
+import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
+import { experienceLabel } from "../../candidates/candidate-ui";
+import { cn } from "@/lib/utils";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = { title: "Application" };
+
+export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ if (!CREWING_ENABLED) notFound();
+
+ const session = await auth();
+ if (!session?.user) redirect("/login");
+ const role = session.user.role;
+ if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
+
+ const { id } = await params;
+ const app = await db.application.findUnique({
+ where: { id },
+ include: {
+ requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
+ crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
+ gates: true,
+ salaryStructures: { orderBy: { createdAt: "desc" } },
+ },
+ });
+ if (!app) notFound();
+
+ const gate = (t: string) => app.gates.find((g) => g.gate === t);
+ const salaryPending = gate("SALARY")?.result === "PENDING";
+ const waiverPending = gate("WAIVER")?.result === "PENDING";
+ const selectionPending = gate("SELECTION")?.result === "PENDING";
+ const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
+
+ const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
+ const curIdx = stageIndex(app.stage);
+
+ return (
+
+
+
Pipeline · {app.requisition.code}
+
+
+
+
{app.crewMember.name}
+ {STAGE_LABEL[app.stage]}
+ {app.crewMember.type === "EX_HAND" && (
+ Returning crew
+ )}
+
+
+ {app.requisition.rank.name} · {loc} · {app.requisition.code}
+
+
+ {/* 7-step stepper */}
+
+ {STAGE_ORDER.map((s, i) => {
+ const done = curIdx > i || app.stage === "ONBOARDED";
+ const current = curIdx === i;
+ return (
+
+ {done && }
+ {STAGE_LABEL[s]}
+
+ );
+ })}
+
+
+
+ {/* Adaptive action card */}
+
+
+ {/* Profile */}
+
+
+
Profile
+
+
+ {([
+ ["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
+ ["Last rank held", app.crewMember.currentRank?.name ?? "—"],
+ ["Experience", experienceLabel(app.crewMember.experienceMonths)],
+ ["Source", app.crewMember.source],
+ ] as [string, string][]).map(([k, v]) => (
+
+
- {k}
+ - {v}
+
+ ))}
+
+ {app.crewMember.type === "EX_HAND" && (
+
+ Returning crew — prior docs/bank/tour on file; interview may be waived with Manager approval.
+
+ )}
+
+
+ View full candidate profile →
+
+
+
+
+
+ );
+}
diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts
new file mode 100644
index 0000000..f63a20d
--- /dev/null
+++ b/App/app/(portal)/crewing/applications/actions.ts
@@ -0,0 +1,537 @@
+"use server";
+
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { hasPermission, type Permission } from "@/lib/permissions";
+import { CREWING_ENABLED } from "@/lib/feature-flags";
+import {
+ canPerformAction,
+ canReject,
+ getTransition,
+ type ApplicationAction,
+} from "@/lib/application-pipeline";
+import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
+import { notifyCrew } from "@/lib/notifier";
+import { SalaryRateBasis } from "@prisma/client";
+import type { Role } from "@prisma/client";
+import { z } from "zod";
+import { revalidatePath } from "next/cache";
+
+type ActionResult = { ok: true; id?: string } | { error: string };
+
+const appPath = (id: string) => `/crewing/applications/${id}`;
+
+async function guard(
+ permission: Permission
+): Promise<{ error: string } | { userId: string; role: Role }> {
+ if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
+ const session = await auth();
+ if (!session?.user) return { error: "Unauthorized" };
+ if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
+ return { userId: session.user.id, role: session.user.role };
+}
+
+// Load an application with the bits the actions need; null if missing.
+async function loadApp(id: string) {
+ return db.application.findUnique({
+ where: { id },
+ include: {
+ requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
+ crewMember: { select: { id: true, name: true, type: true } },
+ },
+ });
+}
+
+function revalidateApp(applicationId: string, requisitionId: string) {
+ revalidatePath(appPath(applicationId));
+ revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
+ revalidatePath("/approvals");
+}
+
+// ── Add a candidate to a requisition's pipeline ────────────────────────────────
+
+export async function addApplication(formData: FormData): Promise {
+ const g = await guard("manage_candidates");
+ if ("error" in g) return g;
+
+ const requisitionId = formData.get("requisitionId") as string;
+ const crewMemberId = formData.get("crewMemberId") as string;
+ if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
+
+ const [requisition, candidate, existing] = await Promise.all([
+ db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
+ db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
+ db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
+ ]);
+ if (!requisition) return { error: "Requisition not found" };
+ if (!candidate) return { error: "Candidate not found" };
+ if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
+ return { error: `Cannot add candidates to a ${requisition.status} requisition` };
+ }
+ if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
+
+ const application = await db.application.create({
+ data: {
+ requisitionId,
+ crewMemberId,
+ type: candidate.type,
+ stage: "SHORTLISTED",
+ actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
+ },
+ });
+
+ // First candidate moves the requisition from OPEN into sourcing.
+ if (requisition.status === "OPEN") {
+ await db.requisition.update({
+ where: { id: requisitionId },
+ data: {
+ status: "SHORTLISTING",
+ actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
+ },
+ });
+ }
+
+ revalidateApp(application.id, requisitionId);
+ return { ok: true, id: application.id };
+}
+
+// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
+// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
+// select have dedicated actions below.
+
+export async function advanceStage(id: string, action: ApplicationAction): Promise {
+ if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
+ return { error: "Use the dedicated action for this step" };
+ }
+ const g = await guard("manage_candidates");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ const transition = getTransition(app.stage, action);
+ if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
+ if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
+
+ await db.application.update({
+ where: { id },
+ data: {
+ stage: transition.to,
+ // Completing the competency & references stage records its gate.
+ ...(action === "verify_competency"
+ ? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
+ : {}),
+ actions: {
+ create: {
+ actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
+ actorId: g.userId,
+ crewMemberId: app.crewMemberId,
+ metadata: { from: app.stage, to: transition.to },
+ },
+ },
+ },
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+const referenceSchema = z.object({
+ refereeName: z.string().trim().min(1, "Referee name is required"),
+ refereeContact: z.string().optional(),
+ outcome: z.string().optional(),
+ note: z.string().optional(),
+});
+
+export async function recordReferenceCheck(formData: FormData): Promise {
+ const g = await guard("record_reference_check");
+ if ("error" in g) return g;
+
+ const id = formData.get("applicationId") as string;
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+
+ const parsed = referenceSchema.safeParse({
+ refereeName: formData.get("refereeName"),
+ refereeContact: (formData.get("refereeContact") as string) || undefined,
+ outcome: (formData.get("outcome") as string) || undefined,
+ note: (formData.get("note") as string) || undefined,
+ });
+ if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
+
+ await db.referenceCheck.create({
+ data: {
+ applicationId: id,
+ refereeName: parsed.data.refereeName,
+ refereeContact: parsed.data.refereeContact ?? null,
+ outcome: parsed.data.outcome ?? null,
+ note: parsed.data.note ?? null,
+ recordedById: g.userId,
+ },
+ });
+ await db.crewAction.create({
+ data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
+
+const docsSchema = z.object({
+ accountName: z.string().optional(),
+ accountNumber: z.string().optional(),
+ ifsc: z.string().optional(),
+ bankName: z.string().optional(),
+ uan: z.string().optional(),
+ aadhaarLast4: z.string().optional(),
+ pfNumber: z.string().optional(),
+ note: z.string().optional(),
+});
+
+export async function verifyDocuments(formData: FormData): Promise {
+ const g = await guard("manage_candidates");
+ if ("error" in g) return g;
+
+ const id = formData.get("applicationId") as string;
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ const transition = getTransition(app.stage, "verify_docs");
+ if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
+
+ const parsed = docsSchema.safeParse(Object.fromEntries(formData));
+ if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
+ const d = parsed.data;
+ const crewMemberId = app.crewMember.id;
+
+ await db.$transaction(async (tx) => {
+ // Capture bank / EPF (PII — encryption deferred to Phase 4).
+ await tx.bankDetail.upsert({
+ where: { crewMemberId },
+ update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
+ create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
+ });
+ await tx.epfDetail.upsert({
+ where: { crewMemberId },
+ update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
+ create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
+ });
+ await tx.application.update({
+ where: { id },
+ data: {
+ stage: transition.to,
+ gates: {
+ create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
+ },
+ actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
+ },
+ });
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
+
+const salarySchema = z.object({
+ rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
+ basic: z.coerce.number().positive("Basic must be greater than 0"),
+ victualingPerDay: z.coerce.number().min(0).default(0),
+ currency: z.string().default("INR"),
+});
+
+export async function agreeSalary(formData: FormData): Promise {
+ const g = await guard("manage_candidates");
+ if ("error" in g) return g;
+
+ const id = formData.get("applicationId") as string;
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
+
+ const parsed = salarySchema.safeParse(Object.fromEntries(formData));
+ if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
+ const d = parsed.data;
+
+ await db.$transaction(async (tx) => {
+ // One live proposed structure per application — replace any prior draft.
+ await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
+ await tx.salaryStructure.create({
+ data: {
+ applicationId: id,
+ rateBasis: d.rateBasis,
+ basic: d.basic,
+ victualingPerDay: d.victualingPerDay,
+ currency: d.currency,
+ },
+ });
+ // Salary gate goes PENDING for the Manager's queue.
+ await tx.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
+ update: { result: "PENDING", decidedById: null, note: null },
+ create: { applicationId: id, gate: "SALARY", result: "PENDING" },
+ });
+ await tx.crewAction.create({
+ data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
+ });
+ });
+
+ const managers = await getManagerRecipients();
+ await notifyCrew({
+ event: "SALARY_FOR_APPROVAL",
+ recipients: managers,
+ subject: `Salary for approval — ${app.crewMember.name}`,
+ body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
+ link: appPath(id),
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function approveSalary(id: string): Promise {
+ const g = await guard("approve_salary_structure");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
+
+ await db.$transaction(async (tx) => {
+ await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
+ await tx.applicationGate.update({
+ where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
+ data: { result: "VERIFIED", decidedById: g.userId },
+ });
+ await tx.application.update({
+ where: { id },
+ data: {
+ stage: "PROPOSED",
+ actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
+ },
+ });
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function returnSalary(id: string, reason: string): Promise {
+ const g = await guard("approve_salary_structure");
+ if ("error" in g) return g;
+ if (!reason?.trim()) return { error: "A reason is required to return for revision" };
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+
+ await db.applicationGate.updateMany({
+ where: { applicationId: id, gate: "SALARY" },
+ data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
+ });
+ await db.crewAction.create({
+ data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
+ });
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
+
+export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise {
+ const g = await guard("record_interview_result");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
+
+ if (!accepted) {
+ // A failed interview rejects the application.
+ return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
+ }
+
+ await db.$transaction(async (tx) => {
+ await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
+ await tx.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
+ update: { result: "VERIFIED", decidedById: g.userId },
+ create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
+ });
+ // Selection now pending for the Manager.
+ await tx.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
+ update: { result: "PENDING", decidedById: null },
+ create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
+ });
+ await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
+ });
+
+ const managers = await getManagerRecipients();
+ await notifyCrew({
+ event: "SELECTION_FOR_APPROVAL",
+ recipients: managers,
+ subject: `Selection for approval — ${app.crewMember.name}`,
+ body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
+ link: appPath(id),
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function requestInterviewWaiver(id: string, note?: string): Promise {
+ const g = await guard("request_interview_waiver");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
+ if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
+
+ await db.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
+ update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
+ create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
+ });
+ await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
+
+ const managers = await getManagerRecipients();
+ await notifyCrew({
+ event: "WAIVER_REQUESTED",
+ recipients: managers,
+ subject: `Interview waiver requested — ${app.crewMember.name}`,
+ body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
+ link: appPath(id),
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function approveInterviewWaiver(id: string): Promise {
+ const g = await guard("approve_interview_waiver");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+
+ await db.$transaction(async (tx) => {
+ await tx.application.update({ where: { id }, data: { interviewWaived: true } });
+ await tx.applicationGate.update({
+ where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
+ data: { result: "VERIFIED", decidedById: g.userId },
+ });
+ // Waived → selection is now pending.
+ await tx.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
+ update: { result: "PENDING", decidedById: null },
+ create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
+ });
+ await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function declineInterviewWaiver(id: string, reason: string): Promise {
+ const g = await guard("approve_interview_waiver");
+ if ("error" in g) return g;
+ if (!reason?.trim()) return { error: "A reason is required to decline" };
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+
+ await db.applicationGate.updateMany({
+ where: { applicationId: id, gate: "WAIVER" },
+ data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
+ });
+ await db.crewAction.create({
+ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
+ });
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function selectCandidate(id: string): Promise {
+ const g = await guard("select_candidate");
+ if ("error" in g) return g;
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
+
+ const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
+ if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
+ return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
+ }
+
+ await db.$transaction(async (tx) => {
+ await tx.applicationGate.upsert({
+ where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
+ update: { result: "VERIFIED", decidedById: g.userId },
+ create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
+ });
+ await tx.application.update({
+ where: { id },
+ data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
+ });
+ // The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
+ await tx.requisition.update({
+ where: { id: app.requisition.id },
+ data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
+ });
+ });
+
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+export async function returnSelection(id: string, reason: string): Promise {
+ const g = await guard("select_candidate");
+ if ("error" in g) return g;
+ if (!reason?.trim()) return { error: "A reason is required to return" };
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+
+ await db.$transaction(async (tx) => {
+ await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
+ await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
+ await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
+ });
+ revalidateApp(id, app.requisition.id);
+ return { ok: true };
+}
+
+// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
+
+async function rejectApplicationInternal(
+ id: string,
+ crewMemberId: string,
+ requisitionId: string,
+ userId: string,
+ reason: string
+): Promise {
+ await db.application.update({
+ where: { id },
+ data: {
+ stage: "REJECTED",
+ rejectedReason: reason,
+ rejectedAt: new Date(),
+ actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
+ },
+ });
+ revalidateApp(id, requisitionId);
+ return { ok: true };
+}
+
+export async function rejectApplication(id: string, reason: string): Promise {
+ const g = await guard("manage_candidates");
+ if ("error" in g) return g;
+ if (!reason?.trim()) return { error: "A reason is required to reject" };
+
+ const app = await loadApp(id);
+ if (!app) return { error: "Application not found" };
+ if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
+
+ return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
+}
diff --git a/App/app/(portal)/crewing/applications/application-action-card.tsx b/App/app/(portal)/crewing/applications/application-action-card.tsx
new file mode 100644
index 0000000..0bfe368
--- /dev/null
+++ b/App/app/(portal)/crewing/applications/application-action-card.tsx
@@ -0,0 +1,347 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
+import { AdminDialog } from "@/components/ui/admin-dialog";
+import {
+ advanceStage,
+ agreeSalary,
+ approveSalary,
+ returnSalary,
+ verifyDocuments,
+ recordReferenceCheck,
+ recordInterviewResult,
+ requestInterviewWaiver,
+ approveInterviewWaiver,
+ selectCandidate,
+ returnSelection,
+ rejectApplication,
+} from "./actions";
+
+const INPUT =
+ "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";
+const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
+const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
+const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
+
+export type ActionCardProps = {
+ id: string;
+ stage: ApplicationStage;
+ isExHand: boolean;
+ interviewResult: InterviewOutcome;
+ interviewWaived: boolean;
+ rejectedReason: string | null;
+ salaryPending: boolean;
+ waiverPending: boolean;
+ selectionPending: boolean;
+ salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
+ perms: {
+ manage: boolean;
+ recordReference: boolean;
+ recordInterview: boolean;
+ requestWaiver: boolean;
+ approveSalary: boolean;
+ approveWaiver: boolean;
+ select: boolean;
+ };
+};
+
+function useAction() {
+ const router = useRouter();
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
+ setPending(true);
+ setError("");
+ const res = await fn();
+ setPending(false);
+ if ("error" in res) setError(res.error);
+ else router.refresh();
+ return res;
+ }
+ return { pending, error, run };
+}
+
+function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
+ return (
+
+
+
{title}
+ {sub &&
{sub}
}
+
+
{children}
+
+ );
+}
+
+function RejectButton({ id }: { id: string }) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [reason, setReason] = useState("");
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true); setError("");
+ const res = await rejectApplication(id, reason);
+ setPending(false);
+ if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
+ }
+ return (
+ <>
+
+ setOpen(false)}>
+
+
+ >
+ );
+}
+
+function Err({ msg }: { msg: string }) {
+ return msg ? {msg}
: null;
+}
+
+export function ApplicationActionCard(p: ActionCardProps) {
+ const { run, pending, error } = useAction();
+ const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
+
+ // Reference-check form state (COMPETENCY_AND_REFERENCES).
+ const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
+ // Bank/EPF form state (DOC_VERIFICATION).
+ const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
+ // Salary form state (SALARY_AGREEMENT).
+ const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
+
+ function fdFrom(obj: Record, extra?: Record) {
+ const fd = new FormData();
+ Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
+ return fd;
+ }
+
+ const footer = (
+ <>
+
+ {canReject && (
+
+
+
+ )}
+ >
+ );
+
+ switch (p.stage) {
+ case "SHORTLISTED":
+ return (
+
+ {p.perms.manage && (
+
+ )}
+ {footer}
+
+ );
+
+ case "COMPETENCY_AND_REFERENCES":
+ return (
+
+ {p.perms.recordReference && (
+
+ )}
+ {p.perms.manage && (
+
+ )}
+ {footer}
+
+ );
+
+ case "DOC_VERIFICATION":
+ return (
+
+ {p.perms.manage ? (
+ <>
+
+ setDocs({ ...docs, accountName: e.target.value })} />
+ setDocs({ ...docs, accountNumber: e.target.value })} />
+ setDocs({ ...docs, ifsc: e.target.value })} />
+ setDocs({ ...docs, bankName: e.target.value })} />
+ setDocs({ ...docs, uan: e.target.value })} />
+ setDocs({ ...docs, aadhaarLast4: e.target.value })} />
+ setDocs({ ...docs, pfNumber: e.target.value })} />
+
+
+ >
+ ) : (
+ Awaiting document verification by the MPO.
+ )}
+ {footer}
+
+ );
+
+ case "SALARY_AGREEMENT":
+ if (p.salaryPending) {
+ return (
+
+
+ Proposed: {p.salary?.currency} {p.salary?.basic} / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
+
+ {p.perms.approveSalary ? (
+
+
+ returnSalary(p.id, reason)} />
+
+ ) : (
+ Awaiting Manager approval.
+ )}
+ {footer}
+
+ );
+ }
+ return (
+
+ {p.perms.manage ? (
+ <>
+
+
+ setSal({ ...sal, basic: e.target.value })} />
+ setSal({ ...sal, victualingPerDay: e.target.value })} />
+
+
+ >
+ ) : (
+ Awaiting the MPO to agree the salary.
+ )}
+ {footer}
+
+ );
+
+ case "PROPOSED":
+ return (
+
+ {p.perms.manage && (
+
+ )}
+ {footer}
+
+ );
+
+ case "INTERVIEW":
+ return (
+
+ {/* Interview result row */}
+ {p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
+
+
+
+
+ )}
+ {/* Waiver (ex-hand) */}
+ {p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
+
+ )}
+ {p.waiverPending && (
+ p.perms.approveWaiver ? (
+
+ Waiver requested.
+
+
+ ) : (
+ Interview waiver awaiting Manager approval.
+ )
+ )}
+ {/* Selection row */}
+ {(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
+ p.perms.select ? (
+
+
+ returnSelection(p.id, reason)} />
+
+ ) : (
+ {p.interviewWaived ? "Interview waived" : "Interview passed"} — awaiting Manager selection.
+ )
+ )}
+ {footer}
+
+ );
+
+ case "SELECTED":
+ return (
+
+ Candidate selected.
+
+
+ );
+
+ case "REJECTED":
+ return (
+
+ {p.rejectedReason ?? "This candidate was rejected."}
+
+ );
+
+ default:
+ return (
+
+ This candidate has been onboarded.
+
+ );
+ }
+}
+
+function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [reason, setReason] = useState("");
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true); setError("");
+ const res = await onReturn(reason);
+ setPending(false);
+ if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
+ }
+ return (
+ <>
+
+ setOpen(false)}>
+
+
+ >
+ );
+}
diff --git a/App/app/(portal)/crewing/applications/application-ui.ts b/App/app/(portal)/crewing/applications/application-ui.ts
new file mode 100644
index 0000000..070d0cf
--- /dev/null
+++ b/App/app/(portal)/crewing/applications/application-ui.ts
@@ -0,0 +1,47 @@
+import type { ApplicationStage } from "@prisma/client";
+import type { BadgeProps } from "@/components/ui/badge";
+
+type Variant = NonNullable;
+
+// The 7 board columns in order (mirrors lib/application-pipeline BOARD_STAGES;
+// kept here as a client-safe constant for the stepper/board UI).
+export const STAGE_ORDER: ApplicationStage[] = [
+ "SHORTLISTED",
+ "COMPETENCY_AND_REFERENCES",
+ "DOC_VERIFICATION",
+ "SALARY_AGREEMENT",
+ "PROPOSED",
+ "INTERVIEW",
+ "SELECTED",
+];
+
+export const STAGE_LABEL: Record = {
+ SHORTLISTED: "Shortlisted",
+ COMPETENCY_AND_REFERENCES: "Competency & references",
+ DOC_VERIFICATION: "Documents",
+ SALARY_AGREEMENT: "Salary",
+ PROPOSED: "Proposed",
+ INTERVIEW: "Interview",
+ SELECTED: "Selected",
+ REJECTED: "Rejected",
+ ONBOARDED: "Onboarded",
+};
+
+export const STAGE_VARIANT: Record = {
+ SHORTLISTED: "outline",
+ COMPETENCY_AND_REFERENCES: "default",
+ DOC_VERIFICATION: "default",
+ SALARY_AGREEMENT: "warning",
+ PROPOSED: "default",
+ INTERVIEW: "warning",
+ SELECTED: "success",
+ REJECTED: "danger",
+ ONBOARDED: "success",
+};
+
+// Index of a stage within the 7-step flow (−1 for REJECTED; 7 for ONBOARDED).
+export function stageIndex(stage: ApplicationStage): number {
+ if (stage === "REJECTED") return -1;
+ if (stage === "ONBOARDED") return STAGE_ORDER.length;
+ return STAGE_ORDER.indexOf(stage);
+}
diff --git a/App/app/(portal)/crewing/requisitions/[id]/page.tsx b/App/app/(portal)/crewing/requisitions/[id]/page.tsx
index ba0ec19..793ac68 100644
--- a/App/app/(portal)/crewing/requisitions/[id]/page.tsx
+++ b/App/app/(portal)/crewing/requisitions/[id]/page.tsx
@@ -33,6 +33,7 @@ export default async function RequisitionDetailPage({
site: { select: { name: true } },
raisedBy: { select: { name: true } },
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
+ _count: { select: { applications: true } },
},
});
if (!req) notFound();
@@ -69,7 +70,15 @@ export default async function RequisitionDetailPage({
{req.code} · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
- {canWithdraw && }
+
+
+ Open pipeline
+
+ {canWithdraw && }
+
{req.autoRaised && (
@@ -108,15 +117,20 @@ export default async function RequisitionDetailPage({
)}
- {/* Candidates — populated by the recruitment pipeline (Phase 3) */}
+ {/* Candidates — the recruitment pipeline (Phase 3b) */}
Candidates
-
- The recruitment pipeline arrives in a later phase. Candidates attached to this
- requisition will appear here.
-
+
+
{req._count.applications}
+
+ candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
+
+
+ Open recruitment pipeline →
+
+
diff --git a/App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx b/App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
new file mode 100644
index 0000000..ff84770
--- /dev/null
+++ b/App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
@@ -0,0 +1,63 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { hasPermission } from "@/lib/permissions";
+import { CREWING_ENABLED } from "@/lib/feature-flags";
+import { redirect, notFound } from "next/navigation";
+import { PipelineBoard } from "./pipeline-board";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = { title: "Recruitment pipeline" };
+
+export default async function PipelinePage({ params }: { params: Promise<{ id: string }> }) {
+ if (!CREWING_ENABLED) notFound();
+
+ const session = await auth();
+ if (!session?.user) redirect("/login");
+ const role = session.user.role;
+ if (!hasPermission(role, "view_requisitions")) redirect("/dashboard");
+
+ const { id } = await params;
+ const requisition = await db.requisition.findUnique({
+ where: { id },
+ include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
+ });
+ if (!requisition) notFound();
+
+ const applications = await db.application.findMany({
+ where: { requisitionId: id },
+ include: { crewMember: { select: { id: true, name: true, type: true, experienceMonths: true } } },
+ orderBy: { createdAt: "asc" },
+ });
+
+ const canManage = hasPermission(role, "manage_candidates");
+ // Candidates available to add: in the pool (not employees) and not already applied here.
+ const appliedIds = new Set(applications.map((a) => a.crewMemberId));
+ const pool = canManage
+ ? (await db.crewMember.findMany({
+ where: { status: { not: "EMPLOYEE" } },
+ orderBy: { name: "asc" },
+ select: { id: true, name: true, type: true },
+ })).filter((c) => !appliedIds.has(c.id))
+ : [];
+
+ return (
+ ({
+ id: a.id,
+ stage: a.stage,
+ crewName: a.crewMember.name,
+ isExHand: a.crewMember.type === "EX_HAND",
+ experienceMonths: a.crewMember.experienceMonths,
+ }))}
+ pool={pool}
+ canManage={canManage}
+ />
+ );
+}
diff --git a/App/app/(portal)/crewing/requisitions/[id]/pipeline/pipeline-board.tsx b/App/app/(portal)/crewing/requisitions/[id]/pipeline/pipeline-board.tsx
new file mode 100644
index 0000000..4c80b1f
--- /dev/null
+++ b/App/app/(portal)/crewing/requisitions/[id]/pipeline/pipeline-board.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { ArrowLeft } from "lucide-react";
+import type { ApplicationStage, RequisitionStatus } from "@prisma/client";
+import { AdminDialog } from "@/components/ui/admin-dialog";
+import { STAGE_ORDER, STAGE_LABEL } from "../../../applications/application-ui";
+import { addApplication } from "../../../applications/actions";
+
+type AppCard = { id: string; stage: ApplicationStage; crewName: string; isExHand: boolean; experienceMonths: number };
+type PoolItem = { id: string; name: string; type: string };
+
+export function PipelineBoard({
+ requisition,
+ applications,
+ pool,
+ canManage,
+}: {
+ requisition: { id: string; code: string; rank: string; location: string; status: RequisitionStatus };
+ applications: AppCard[];
+ pool: PoolItem[];
+ canManage: boolean;
+}) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [crewMemberId, setCrewMemberId] = useState("");
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+
+ async function add(e: React.FormEvent) {
+ e.preventDefault();
+ setPending(true); setError("");
+ const fd = new FormData();
+ fd.set("requisitionId", requisition.id);
+ fd.set("crewMemberId", crewMemberId);
+ const res = await addApplication(fd);
+ setPending(false);
+ if ("error" in res) setError(res.error);
+ else { setOpen(false); setCrewMemberId(""); router.refresh(); }
+ }
+
+ const byStage = (s: ApplicationStage) => applications.filter((a) => a.stage === s);
+ const rejected = applications.filter((a) => a.stage === "REJECTED");
+
+ return (
+
+
+
Requisition
+
+
+
+
+
{requisition.rank} — {requisition.location}
+
Recruitment pipeline · {requisition.code} · {applications.length} candidate{applications.length === 1 ? "" : "s"}
+
+ {canManage && (
+
+ )}
+
+
+
+ {STAGE_ORDER.map((s) => {
+ const cards = byStage(s);
+ return (
+
+
+ {STAGE_LABEL[s]}
+ {cards.length}
+
+
+ {cards.map((a) => (
+
+
{a.crewName}
+
+ {Math.floor(a.experienceMonths / 12)} yrs
+ {a.isExHand && · ex-hand}
+
+
+ ))}
+ {cards.length === 0 &&
—
}
+
+
+ );
+ })}
+
+
+ {rejected.length > 0 && (
+
+
Rejected ({rejected.length})
+
+ {rejected.map((a) => (
+
+ {a.crewName}
+
+ ))}
+
+
+ )}
+
+
setOpen(false)}>
+
+
+
+ );
+}
diff --git a/App/lib/application-pipeline.ts b/App/lib/application-pipeline.ts
new file mode 100644
index 0000000..9a11a6c
--- /dev/null
+++ b/App/lib/application-pipeline.ts
@@ -0,0 +1,99 @@
+import type { ApplicationStage, Role } from "@prisma/client";
+
+// Recruitment pipeline state machine (Crewing-Implementation-Spec §5.1) — mirrors
+// po-state-machine / requisition-state-machine. The 7 board stages advance in
+// order; ONBOARDED is the terminal system state set at onboarding (Phase 3c);
+// REJECTED is an orthogonal branch reachable from any active stage.
+//
+// Stage advances are modelled here. The within-stage work — recording reference
+// checks, capturing bank/EPF, agreeing the salary, recording the interview
+// result, requesting a waiver — happens in server actions; this machine governs
+// when a candidate may move to the next column and who may move them.
+//
+// Manager-gated advances (spec §6): SALARY_AGREEMENT → PROPOSED (salary approval)
+// and INTERVIEW → SELECTED (final selection) are Manager-only. The interview
+// waiver is a separate Manager-approved action (R2), never automatic.
+
+export type ApplicationAction =
+ | "start_competency" // SHORTLISTED → COMPETENCY_AND_REFERENCES
+ | "verify_competency" // COMPETENCY_AND_REFERENCES → DOC_VERIFICATION
+ | "verify_docs" // DOC_VERIFICATION → SALARY_AGREEMENT
+ | "approve_salary" // SALARY_AGREEMENT → PROPOSED (Manager)
+ | "propose_accepted" // PROPOSED → INTERVIEW
+ | "select" // INTERVIEW → SELECTED (Manager)
+ | "onboard"; // SELECTED → ONBOARDED (Phase 3c)
+
+interface Transition {
+ to: ApplicationStage;
+ allowedRoles: Role[];
+}
+
+type TransitionMap = Partial>;
+
+const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
+const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
+
+const TRANSITIONS: Partial> = {
+ SHORTLISTED: {
+ start_competency: { to: "COMPETENCY_AND_REFERENCES", allowedRoles: SOURCING_ROLES },
+ },
+ COMPETENCY_AND_REFERENCES: {
+ verify_competency: { to: "DOC_VERIFICATION", allowedRoles: SOURCING_ROLES },
+ },
+ DOC_VERIFICATION: {
+ verify_docs: { to: "SALARY_AGREEMENT", allowedRoles: SOURCING_ROLES },
+ },
+ SALARY_AGREEMENT: {
+ // Manager approves the agreed salary structure (spec §6).
+ approve_salary: { to: "PROPOSED", allowedRoles: MANAGER_ROLES },
+ },
+ PROPOSED: {
+ propose_accepted: { to: "INTERVIEW", allowedRoles: SOURCING_ROLES },
+ },
+ INTERVIEW: {
+ // Final selection is a Manager approval (spec §6). The action enforces that
+ // the interview was accepted or a Manager-approved waiver is in place (R2).
+ select: { to: "SELECTED", allowedRoles: MANAGER_ROLES },
+ },
+ SELECTED: {
+ // The onboarding side-effect (Phase 3c) moves SELECTED → ONBOARDED.
+ onboard: { to: "ONBOARDED", allowedRoles: SOURCING_ROLES },
+ },
+};
+
+// The 7 visible board columns, in order (spec §8.4). ONBOARDED/REJECTED are not
+// board columns — they are terminal/branch states.
+export const BOARD_STAGES: ApplicationStage[] = [
+ "SHORTLISTED",
+ "COMPETENCY_AND_REFERENCES",
+ "DOC_VERIFICATION",
+ "SALARY_AGREEMENT",
+ "PROPOSED",
+ "INTERVIEW",
+ "SELECTED",
+];
+
+export function getTransition(from: ApplicationStage, action: ApplicationAction): Transition | null {
+ return TRANSITIONS[from]?.[action] ?? null;
+}
+
+export function canPerformAction(from: ApplicationStage, action: ApplicationAction, role: Role): boolean {
+ return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
+}
+
+export function getAvailableActions(stage: ApplicationStage, role: Role): ApplicationAction[] {
+ const map = TRANSITIONS[stage];
+ if (!map) return [];
+ return (Object.keys(map) as ApplicationAction[]).filter((a) => canPerformAction(stage, a, role));
+}
+
+// ── Rejection (orthogonal) ───────────────────────────────────────────────────
+// A candidate may be rejected with remarks from any active stage (not once
+// SELECTED/ONBOARDED, and not again if already REJECTED), by MPO or Manager.
+
+export const REJECT_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
+const TERMINAL: ApplicationStage[] = ["SELECTED", "ONBOARDED", "REJECTED"];
+
+export function canReject(from: ApplicationStage, role: Role): boolean {
+ return !TERMINAL.includes(from) && REJECT_ROLES.includes(role);
+}
diff --git a/App/lib/notifier.ts b/App/lib/notifier.ts
index 33f71fa..b6392be 100644
--- a/App/lib/notifier.ts
+++ b/App/lib/notifier.ts
@@ -28,7 +28,11 @@ export type NotificationEvent =
export type CrewNotificationEvent =
| "REQUISITION_RAISED"
| "RELIEF_REQUESTED"
- | "RELIEF_CONVERTED";
+ | "RELIEF_CONVERTED"
+ | "CANDIDATE_PROPOSED"
+ | "SALARY_FOR_APPROVAL"
+ | "SELECTION_FOR_APPROVAL"
+ | "WAIVER_REQUESTED";
interface NotifyParams {
event: NotificationEvent;
@@ -425,6 +429,10 @@ const CREW_ACTION_LABEL: Record = {
REQUISITION_RAISED: "View Requisition",
RELIEF_REQUESTED: "View Requisitions",
RELIEF_CONVERTED: "View Requisition",
+ CANDIDATE_PROPOSED: "View Candidate",
+ SALARY_FOR_APPROVAL: "Review Salary",
+ SELECTION_FOR_APPROVAL: "Review Selection",
+ WAIVER_REQUESTED: "Review Waiver",
};
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
diff --git a/App/lib/requisition-service.ts b/App/lib/requisition-service.ts
index 1cd7ef7..fb6c4f2 100644
--- a/App/lib/requisition-service.ts
+++ b/App/lib/requisition-service.ts
@@ -82,6 +82,13 @@ export function getMpoRecipients(): Promise {
});
}
+/** Manager recipients — for the approval gates (salary / selection / waiver). */
+export function getManagerRecipients(): Promise {
+ return db.user.findMany({
+ where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
+ });
+}
+
/**
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
diff --git a/App/prisma/migrations/20260622130627_crewing_pipeline/migration.sql b/App/prisma/migrations/20260622130627_crewing_pipeline/migration.sql
new file mode 100644
index 0000000..ec5ae28
--- /dev/null
+++ b/App/prisma/migrations/20260622130627_crewing_pipeline/migration.sql
@@ -0,0 +1,168 @@
+-- CreateEnum
+CREATE TYPE "ApplicationStage" AS ENUM ('SHORTLISTED', 'COMPETENCY_AND_REFERENCES', 'DOC_VERIFICATION', 'SALARY_AGREEMENT', 'PROPOSED', 'INTERVIEW', 'SELECTED', 'REJECTED', 'ONBOARDED');
+
+-- CreateEnum
+CREATE TYPE "ApplicationGateType" AS ENUM ('COMPETENCY_REFERENCE', 'DOCUMENT', 'SALARY', 'INTERVIEW', 'WAIVER', 'SELECTION');
+
+-- CreateEnum
+CREATE TYPE "GateResult" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
+
+-- CreateEnum
+CREATE TYPE "InterviewOutcome" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
+
+-- CreateEnum
+CREATE TYPE "SalaryRateBasis" AS ENUM ('MONTHLY', 'DAILY');
+
+-- AlterEnum
+-- This migration adds more than one value to an enum.
+-- With PostgreSQL versions 11 and earlier, this is not possible
+-- in a single migration. This can be worked around by creating
+-- multiple migrations, each migration adding only one value to
+-- the enum.
+
+
+ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_CREATED';
+ALTER TYPE "CrewActionType" ADD VALUE 'GATE_PASSED';
+ALTER TYPE "CrewActionType" ADD VALUE 'GATE_FAILED';
+ALTER TYPE "CrewActionType" ADD VALUE 'REFERENCE_RECORDED';
+ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_AGREED';
+ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_APPROVED';
+ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_PROPOSED';
+ALTER TYPE "CrewActionType" ADD VALUE 'INTERVIEW_RECORDED';
+ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_REQUESTED';
+ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_APPROVED';
+ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_SELECTED';
+ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_REJECTED';
+
+-- AlterTable
+ALTER TABLE "CrewAction" ADD COLUMN "applicationId" TEXT;
+
+-- CreateTable
+CREATE TABLE "Application" (
+ "id" TEXT NOT NULL,
+ "stage" "ApplicationStage" NOT NULL DEFAULT 'SHORTLISTED',
+ "type" "CandidateType" NOT NULL DEFAULT 'NEW',
+ "interviewResult" "InterviewOutcome" NOT NULL DEFAULT 'PENDING',
+ "interviewWaived" BOOLEAN NOT NULL DEFAULT false,
+ "rejectedReason" TEXT,
+ "rejectedAt" TIMESTAMP(3),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "requisitionId" TEXT NOT NULL,
+ "crewMemberId" TEXT NOT NULL,
+
+ CONSTRAINT "Application_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ApplicationGate" (
+ "id" TEXT NOT NULL,
+ "applicationId" TEXT NOT NULL,
+ "gate" "ApplicationGateType" NOT NULL,
+ "result" "GateResult" NOT NULL DEFAULT 'PENDING',
+ "note" TEXT,
+ "decidedById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ApplicationGate_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "ReferenceCheck" (
+ "id" TEXT NOT NULL,
+ "applicationId" TEXT NOT NULL,
+ "refereeName" TEXT NOT NULL,
+ "refereeContact" TEXT,
+ "outcome" TEXT,
+ "note" TEXT,
+ "recordedById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ReferenceCheck_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "SalaryStructure" (
+ "id" TEXT NOT NULL,
+ "applicationId" TEXT NOT NULL,
+ "rateBasis" "SalaryRateBasis" NOT NULL DEFAULT 'MONTHLY',
+ "basic" DECIMAL(12,2) NOT NULL,
+ "victualingPerDay" DECIMAL(12,2) NOT NULL DEFAULT 0,
+ "allowances" JSONB,
+ "currency" TEXT NOT NULL DEFAULT 'INR',
+ "effectiveFrom" TIMESTAMP(3),
+ "effectiveTo" TIMESTAMP(3),
+ "approvedById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "SalaryStructure_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "BankDetail" (
+ "id" TEXT NOT NULL,
+ "crewMemberId" TEXT NOT NULL,
+ "accountName" TEXT,
+ "accountNumber" TEXT,
+ "ifsc" TEXT,
+ "bankName" TEXT,
+ "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
+ "verifiedById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "BankDetail_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "EpfDetail" (
+ "id" TEXT NOT NULL,
+ "crewMemberId" TEXT NOT NULL,
+ "uan" TEXT,
+ "aadhaarLast4" TEXT,
+ "pfNumber" TEXT,
+ "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
+ "verifiedById" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "EpfDetail_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Application_requisitionId_crewMemberId_key" ON "Application"("requisitionId", "crewMemberId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "ApplicationGate_applicationId_gate_key" ON "ApplicationGate"("applicationId", "gate");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "BankDetail_crewMemberId_key" ON "BankDetail"("crewMemberId");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "EpfDetail_crewMemberId_key" ON "EpfDetail"("crewMemberId");
+
+-- AddForeignKey
+ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Application" ADD CONSTRAINT "Application_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Application" ADD CONSTRAINT "Application_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ApplicationGate" ADD CONSTRAINT "ApplicationGate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "ReferenceCheck" ADD CONSTRAINT "ReferenceCheck_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "BankDetail" ADD CONSTRAINT "BankDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "EpfDetail" ADD CONSTRAINT "EpfDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma
index b268cc5..c4b1e56 100644
--- a/App/prisma/schema.prisma
+++ b/App/prisma/schema.prisma
@@ -133,6 +133,64 @@ enum CrewActionType {
RELIEF_CANCELLED
CANDIDATE_ADDED
CANDIDATE_UPDATED
+ APPLICATION_CREATED
+ GATE_PASSED
+ GATE_FAILED
+ REFERENCE_RECORDED
+ SALARY_AGREED
+ SALARY_APPROVED
+ CANDIDATE_PROPOSED
+ INTERVIEW_RECORDED
+ WAIVER_REQUESTED
+ WAIVER_APPROVED
+ CANDIDATE_SELECTED
+ APPLICATION_REJECTED
+}
+
+// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
+// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
+// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
+// REJECTED is the branch reachable from any active stage.
+enum ApplicationStage {
+ SHORTLISTED
+ COMPETENCY_AND_REFERENCES
+ DOC_VERIFICATION
+ SALARY_AGREEMENT
+ PROPOSED
+ INTERVIEW
+ SELECTED
+ REJECTED
+ ONBOARDED
+}
+
+// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
+// Manager-decided gates that surface in the central Approvals queue (§8.13).
+enum ApplicationGateType {
+ COMPETENCY_REFERENCE
+ DOCUMENT
+ SALARY
+ INTERVIEW
+ WAIVER
+ SELECTION
+}
+
+enum GateResult {
+ PENDING
+ VERIFIED
+ REJECTED
+}
+
+// MPO's recorded interview outcome (Manager then approves selection).
+enum InterviewOutcome {
+ PENDING
+ ACCEPTED
+ REJECTED
+}
+
+// Salary capture basis — the other is derived (R10/A4). Effective-dated.
+enum SalaryRateBasis {
+ MONTHLY
+ DAILY
}
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
@@ -563,7 +621,8 @@ model Requisition {
// The site relief request this requisition was converted from, if any.
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
- actions CrewAction[]
+ actions CrewAction[]
+ applications Application[]
}
// A foreseen-gap flag from a site (site staff), pending office conversion into a
@@ -609,6 +668,8 @@ model CrewAction {
requisition Requisition? @relation(fields: [requisitionId], references: [id])
crewMemberId String?
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
+ applicationId String?
+ application Application? @relation(fields: [applicationId], references: [id])
}
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
@@ -639,5 +700,116 @@ model CrewMember {
appliedRankId String?
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
- actions CrewAction[]
+ actions CrewAction[]
+ applications Application[]
+ bankDetail BankDetail?
+ epfDetail EpfDetail?
+}
+
+// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
+
+// A candidate's application against one requisition — the gated pipeline spine
+// (spec §5.1/§8.4–8.5). One application per (requisition, candidate).
+model Application {
+ id String @id @default(cuid())
+ stage ApplicationStage @default(SHORTLISTED)
+ type CandidateType @default(NEW)
+ interviewResult InterviewOutcome @default(PENDING)
+ interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
+ rejectedReason String?
+ rejectedAt DateTime?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ requisitionId String
+ requisition Requisition @relation(fields: [requisitionId], references: [id])
+ crewMemberId String
+ crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
+
+ gates ApplicationGate[]
+ referenceChecks ReferenceCheck[]
+ salaryStructures SalaryStructure[]
+ actions CrewAction[]
+
+ @@unique([requisitionId, crewMemberId])
+}
+
+// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
+// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
+// denormalised actor id — the audited actor lives on the CrewAction.
+model ApplicationGate {
+ id String @id @default(cuid())
+ applicationId String
+ application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
+ gate ApplicationGateType
+ result GateResult @default(PENDING)
+ note String?
+ decidedById String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([applicationId, gate])
+}
+
+// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
+model ReferenceCheck {
+ id String @id @default(cuid())
+ applicationId String
+ application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
+ refereeName String
+ refereeContact String?
+ outcome String? // free-text / "positive" | "negative"
+ note String?
+ recordedById String?
+ createdAt DateTime @default(now())
+}
+
+// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
+// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
+// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
+model SalaryStructure {
+ id String @id @default(cuid())
+ applicationId String
+ application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
+ rateBasis SalaryRateBasis @default(MONTHLY)
+ basic Decimal @db.Decimal(12, 2)
+ victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
+ allowances Json?
+ currency String @default("INR")
+ effectiveFrom DateTime?
+ effectiveTo DateTime?
+ approvedById String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
+// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
+// plainly for now behind the crewing flag.
+model BankDetail {
+ id String @id @default(cuid())
+ crewMemberId String @unique
+ crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
+ accountName String?
+ accountNumber String?
+ ifsc String?
+ bankName String?
+ verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
+ verifiedById String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
+model EpfDetail {
+ id String @id @default(cuid())
+ crewMemberId String @unique
+ crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
+ uan String?
+ aadhaarLast4 String?
+ pfNumber String?
+ verificationStatus GateResult @default(PENDING)
+ verifiedById String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
diff --git a/App/tests/integration/applications.test.ts b/App/tests/integration/applications.test.ts
new file mode 100644
index 0000000..5299dfe
--- /dev/null
+++ b/App/tests/integration/applications.test.ts
@@ -0,0 +1,209 @@
+/**
+ * Integration tests for the Crewing Phase 3b recruitment pipeline actions.
+ * The Application/Gate/Salary/Bank/EPF tables are introduced in this phase, so
+ * afterEach wipes the crewing lifecycle tables wholesale.
+ */
+import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
+
+vi.mock("@/auth", () => ({ auth: vi.fn() }));
+vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
+vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
+vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
+
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import {
+ addApplication,
+ advanceStage,
+ recordReferenceCheck,
+ verifyDocuments,
+ agreeSalary,
+ approveSalary,
+ recordInterviewResult,
+ requestInterviewWaiver,
+ approveInterviewWaiver,
+ selectCandidate,
+ rejectApplication,
+} from "@/app/(portal)/crewing/applications/actions";
+import { makeSession, getSeedUser, fd } from "./helpers";
+import type { ApplicationStage, Role } from "@prisma/client";
+
+let managerId: string;
+let manningId: string;
+let siteStaffId: string;
+let rankId: string;
+let vesselId: string;
+
+const SS_EMAIL = "sitestaff@itapp.local";
+const as = (userId: string, role: Role) =>
+ vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role));
+
+let seq = 0;
+async function freshRequisition() {
+ seq += 1;
+ return db.requisition.create({ data: { code: `REQ-T${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "OPEN" } });
+}
+async function freshCandidate(type: "NEW" | "EX_HAND" = "NEW") {
+ return db.crewMember.create({ data: { name: type === "EX_HAND" ? "Ex Hand" : "New Cand", type, status: type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", source: type === "EX_HAND" ? "EX_HAND" : "CAREERS", appliedRankId: rankId } });
+}
+async function newApplication(type: "NEW" | "EX_HAND" = "NEW") {
+ const [req, cand] = await Promise.all([freshRequisition(), freshCandidate(type)]);
+ as(managerId, "MANAGER");
+ const res = await addApplication(fd({ requisitionId: req.id, crewMemberId: cand.id }));
+ if (!("ok" in res)) throw new Error("addApplication failed");
+ return { applicationId: res.id!, requisitionId: req.id, crewMemberId: cand.id };
+}
+const setStage = (id: string, stage: ApplicationStage) => db.application.update({ where: { id }, data: { stage } });
+
+beforeAll(async () => {
+ managerId = (await getSeedUser("manager@pelagia.local")).id;
+ manningId = (await getSeedUser("manning@pelagia.local")).id;
+ const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITAPP-SS", email: SS_EMAIL, name: "SS App", role: "SITE_STAFF" } });
+ siteStaffId = ss.id;
+ rankId = (await db.rank.findFirstOrThrow()).id;
+ vesselId = (await db.vessel.findFirstOrThrow()).id;
+});
+
+afterEach(async () => {
+ await db.crewAction.deleteMany({});
+ await db.salaryStructure.deleteMany({});
+ await db.applicationGate.deleteMany({});
+ await db.referenceCheck.deleteMany({});
+ await db.application.deleteMany({});
+ await db.bankDetail.deleteMany({});
+ await db.epfDetail.deleteMany({});
+ await db.requisition.deleteMany({});
+ await db.crewMember.deleteMany({});
+ vi.clearAllMocks();
+});
+
+afterAll(async () => {
+ await db.user.deleteMany({ where: { email: SS_EMAIL } });
+});
+
+describe("addApplication", () => {
+ it("creates a SHORTLISTED application and moves the requisition into SHORTLISTING", async () => {
+ const { applicationId, requisitionId } = await newApplication();
+ const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
+ expect(app.stage).toBe("SHORTLISTED");
+ expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SHORTLISTING");
+ });
+
+ it("rejects a duplicate candidate on the same requisition", async () => {
+ const { requisitionId, crewMemberId } = await newApplication();
+ as(managerId, "MANAGER");
+ const res = await addApplication(fd({ requisitionId, crewMemberId }));
+ expect("error" in res).toBe(true);
+ });
+});
+
+describe("happy path to PROPOSED", () => {
+ it("walks shortlist → competency → docs(+bank/EPF) → salary → manager approval", async () => {
+ const { applicationId, crewMemberId } = await newApplication();
+ as(manningId, "MANNING");
+ expect("ok" in (await advanceStage(applicationId, "start_competency"))).toBe(true);
+ await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
+ expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
+ expect("ok" in (await verifyDocuments(fd({ applicationId, accountNumber: "123456", ifsc: "HDFC0001", uan: "UAN99" })))).toBe(true);
+
+ // Bank/EPF captured at the docs gate
+ expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId } })).accountNumber).toBe("123456");
+ expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId } })).uan).toBe("UAN99");
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
+
+ // MPO agrees salary → SALARY gate pending
+ await agreeSalary(fd({ applicationId, rateBasis: "MONTHLY", basic: "45000" }));
+ const gate = await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SALARY" } });
+ expect(gate.result).toBe("PENDING");
+
+ // MPO cannot approve salary
+ as(manningId, "MANNING");
+ expect(await approveSalary(applicationId)).toEqual({ error: "Unauthorized" });
+
+ // Manager approves → PROPOSED, structure approved
+ as(managerId, "MANAGER");
+ expect("ok" in (await approveSalary(applicationId))).toBe(true);
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("PROPOSED");
+ expect((await db.salaryStructure.findFirstOrThrow({ where: { applicationId } })).approvedById).toBe(managerId);
+ });
+});
+
+describe("interview → selection", () => {
+ it("MPO records pass → Manager selects → SELECTED + requisition SELECTED", async () => {
+ const { applicationId, requisitionId } = await newApplication();
+ await setStage(applicationId, "INTERVIEW");
+
+ as(manningId, "MANNING");
+ expect("ok" in (await recordInterviewResult(applicationId, true))).toBe(true);
+ expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SELECTION" } })).result).toBe("PENDING");
+
+ // MPO cannot select
+ expect(await selectCandidate(applicationId)).toEqual({ error: "Unauthorized" });
+
+ as(managerId, "MANAGER");
+ expect("ok" in (await selectCandidate(applicationId))).toBe(true);
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
+ expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SELECTED");
+ });
+
+ it("a failed interview rejects the application", async () => {
+ const { applicationId } = await newApplication();
+ await setStage(applicationId, "INTERVIEW");
+ as(manningId, "MANNING");
+ await recordInterviewResult(applicationId, false, "Did not meet the bar");
+ const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
+ expect(app.stage).toBe("REJECTED");
+ expect(app.rejectedReason).toBe("Did not meet the bar");
+ });
+
+ it("cannot select before an interview result or waiver", async () => {
+ const { applicationId } = await newApplication();
+ await setStage(applicationId, "INTERVIEW");
+ as(managerId, "MANAGER");
+ const res = await selectCandidate(applicationId);
+ expect("error" in res).toBe(true);
+ });
+});
+
+describe("interview waiver (ex-hands, R2)", () => {
+ it("MPO requests, Manager approves, then selection works without an interview", async () => {
+ const { applicationId } = await newApplication("EX_HAND");
+ await setStage(applicationId, "INTERVIEW");
+
+ as(manningId, "MANNING");
+ expect("ok" in (await requestInterviewWaiver(applicationId, "20 yrs with us"))).toBe(true);
+ expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "WAIVER" } })).result).toBe("PENDING");
+
+ as(managerId, "MANAGER");
+ expect("ok" in (await approveInterviewWaiver(applicationId))).toBe(true);
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).interviewWaived).toBe(true);
+
+ expect("ok" in (await selectCandidate(applicationId))).toBe(true);
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
+ });
+
+ it("is refused for a non-ex-hand candidate", async () => {
+ const { applicationId } = await newApplication("NEW");
+ await setStage(applicationId, "INTERVIEW");
+ as(manningId, "MANNING");
+ const res = await requestInterviewWaiver(applicationId);
+ expect("error" in res).toBe(true);
+ });
+});
+
+describe("rejection", () => {
+ it("MPO rejects from a mid stage", async () => {
+ const { applicationId } = await newApplication();
+ await setStage(applicationId, "DOC_VERIFICATION");
+ as(manningId, "MANNING");
+ expect("ok" in (await rejectApplication(applicationId, "Docs not in order"))).toBe(true);
+ expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("REJECTED");
+ });
+
+ it("site staff cannot drive the pipeline", async () => {
+ const { applicationId } = await newApplication();
+ as(siteStaffId, "SITE_STAFF");
+ expect(await advanceStage(applicationId, "start_competency")).toEqual({ error: "Unauthorized" });
+ expect(await rejectApplication(applicationId, "x")).toEqual({ error: "Unauthorized" });
+ });
+});
diff --git a/App/tests/unit/application-pipeline.test.ts b/App/tests/unit/application-pipeline.test.ts
new file mode 100644
index 0000000..d0fc3c0
--- /dev/null
+++ b/App/tests/unit/application-pipeline.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect } from "vitest";
+import {
+ BOARD_STAGES,
+ canPerformAction,
+ canReject,
+ getAvailableActions,
+ getTransition,
+} from "@/lib/application-pipeline";
+
+// The gated 7-stage recruitment pipeline (Crewing-Implementation-Spec §5.1).
+describe("Application pipeline state machine", () => {
+ it("has the 7 board stages in order", () => {
+ expect(BOARD_STAGES).toEqual([
+ "SHORTLISTED",
+ "COMPETENCY_AND_REFERENCES",
+ "DOC_VERIFICATION",
+ "SALARY_AGREEMENT",
+ "PROPOSED",
+ "INTERVIEW",
+ "SELECTED",
+ ]);
+ });
+
+ describe("sourcing advances (MPO/Manager)", () => {
+ it("MPO walks the early stages", () => {
+ expect(getTransition("SHORTLISTED", "start_competency")?.to).toBe("COMPETENCY_AND_REFERENCES");
+ expect(canPerformAction("SHORTLISTED", "start_competency", "MANNING")).toBe(true);
+ expect(getTransition("COMPETENCY_AND_REFERENCES", "verify_competency")?.to).toBe("DOC_VERIFICATION");
+ expect(getTransition("DOC_VERIFICATION", "verify_docs")?.to).toBe("SALARY_AGREEMENT");
+ expect(getTransition("PROPOSED", "propose_accepted")?.to).toBe("INTERVIEW");
+ });
+ });
+
+ describe("Manager-gated advances (spec §6)", () => {
+ it("salary approval is Manager-only", () => {
+ expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANAGER")).toBe(true);
+ expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "SUPERUSER")).toBe(true);
+ expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANNING")).toBe(false);
+ expect(getTransition("SALARY_AGREEMENT", "approve_salary")?.to).toBe("PROPOSED");
+ });
+
+ it("selection is Manager-only", () => {
+ expect(canPerformAction("INTERVIEW", "select", "MANAGER")).toBe(true);
+ expect(canPerformAction("INTERVIEW", "select", "MANNING")).toBe(false);
+ expect(getTransition("INTERVIEW", "select")?.to).toBe("SELECTED");
+ });
+ });
+
+ it("rejects actions on the wrong stage", () => {
+ expect(getTransition("SHORTLISTED", "select")).toBeNull();
+ expect(getTransition("SELECTED", "approve_salary")).toBeNull();
+ });
+
+ it("offers MPO only sourcing actions, Manager the gated ones", () => {
+ expect(getAvailableActions("SALARY_AGREEMENT", "MANNING")).toHaveLength(0);
+ expect(getAvailableActions("SALARY_AGREEMENT", "MANAGER")).toEqual(["approve_salary"]);
+ expect(getAvailableActions("SHORTLISTED", "SITE_STAFF")).toHaveLength(0);
+ });
+
+ describe("rejection (orthogonal)", () => {
+ it("MPO/Manager can reject from any active stage", () => {
+ expect(canReject("COMPETENCY_AND_REFERENCES", "MANNING")).toBe(true);
+ expect(canReject("INTERVIEW", "MANAGER")).toBe(true);
+ });
+ it("cannot reject once selected/onboarded/already rejected", () => {
+ expect(canReject("SELECTED", "MANAGER")).toBe(false);
+ expect(canReject("ONBOARDED", "MANAGER")).toBe(false);
+ expect(canReject("REJECTED", "MANAGER")).toBe(false);
+ });
+ it("site staff cannot reject", () => {
+ expect(canReject("SHORTLISTED", "SITE_STAFF")).toBe(false);
+ });
+ });
+});