feat(crewing): Phase 3b — recruitment pipeline (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s

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) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 18:49:12 +05:30
parent be6db075dc
commit 3ec3a2b4ef
17 changed files with 2189 additions and 9 deletions

View file

@ -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). - **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. - **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.48.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 ### 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`. `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`.

View file

@ -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<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary,
SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver,
};
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => 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 (
<tr className="hover:bg-neutral-50">
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3">
<Link href={`/crewing/applications/${item.applicationId}`} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
<form onSubmit={doReturn} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Return</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
return (
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.applicationId}`} item={item} />)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { ApprovalsSearch } from "./approvals-search"; 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 { Suspense } from "react";
import type { Metadata } from "next"; 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 } }), 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 ( return (
<div> <div>
<div className="mb-4"> <div className="mb-4">
@ -137,6 +182,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</div> </div>
</> </>
)} )}
{showCrewing && crewItems.length > 0 && <CrewingApprovals items={crewItems} />}
</div> </div>
); );
} }

View file

@ -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 (
<div className="max-w-4xl">
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Pipeline · {app.requisition.code}
</Link>
<div className="mb-6 flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
{app.crewMember.type === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
<p className="text-sm text-neutral-500 -mt-4 mb-6">
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
</p>
{/* 7-step stepper */}
<div className="mb-6 flex flex-wrap gap-2">
{STAGE_ORDER.map((s, i) => {
const done = curIdx > i || app.stage === "ONBOARDED";
const current = curIdx === i;
return (
<div key={s} className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
)}>
{done && <Check className="h-3 w-3" />}
{STAGE_LABEL[s]}
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Adaptive action card */}
<ApplicationActionCard
id={app.id}
stage={app.stage}
isExHand={app.crewMember.type === "EX_HAND"}
interviewResult={app.interviewResult}
interviewWaived={app.interviewWaived}
rejectedReason={app.rejectedReason}
salaryPending={salaryPending}
waiverPending={waiverPending}
selectionPending={selectionPending}
salary={proposed ? {
rateBasis: proposed.rateBasis,
basic: Number(proposed.basic),
victualingPerDay: Number(proposed.victualingPerDay),
currency: proposed.currency,
approved: Boolean(proposed.approvedById),
} : null}
perms={{
manage: hasPermission(role, "manage_candidates"),
recordReference: hasPermission(role, "record_reference_check"),
recordInterview: hasPermission(role, "record_interview_result"),
requestWaiver: hasPermission(role, "request_interview_waiver"),
approveSalary: hasPermission(role, "approve_salary_structure"),
approveWaiver: hasPermission(role, "approve_interview_waiver"),
select: hasPermission(role, "select_candidate"),
}}
/>
{/* Profile */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{([
["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]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{app.crewMember.type === "EX_HAND" && (
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
Returning crew prior docs/bank/tour on file; interview may be waived with Manager approval.
</div>
)}
<div className="px-4 py-3 border-t border-neutral-100">
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
View full candidate profile
</Link>
</div>
</div>
</div>
</div>
);
}

View file

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

View file

@ -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 (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
</div>
<div className="p-4 space-y-3">{children}</div>
</div>
);
}
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 (
<>
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Rejecting…" : "Reject"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function Err({ msg }: { msg: string }) {
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : 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<string, string>, extra?: Record<string, string>) {
const fd = new FormData();
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
return fd;
}
const footer = (
<>
<Err msg={error} />
{canReject && (
<div className="flex justify-end pt-1">
<RejectButton id={p.id} />
</div>
)}
</>
);
switch (p.stage) {
case "SHORTLISTED":
return (
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
Start competency &amp; references
</button>
)}
{footer}
</Card>
);
case "COMPETENCY_AND_REFERENCES":
return (
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
{p.perms.recordReference && (
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
Save reference
</button>
</div>
)}
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
Verify &amp; continue to documents
</button>
)}
{footer}
</Card>
);
case "DOC_VERIFICATION":
return (
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
Verify &amp; continue to salary
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
)}
{footer}
</Card>
);
case "SALARY_AGREEMENT":
if (p.salaryPending) {
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
<p className="text-sm text-neutral-600">
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
</p>
{p.perms.approveSalary ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
)}
{footer}
</Card>
);
}
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
<option value="MONTHLY">Per month</option>
<option value="DAILY">Per day</option>
</select>
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
Agree salary &amp; send for approval
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
)}
{footer}
</Card>
);
case "PROPOSED":
return (
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
Candidate accepted schedule interview
</button>
)}
{footer}
</Card>
);
case "INTERVIEW":
return (
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
{/* Interview result row */}
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
</div>
)}
{/* Waiver (ex-hand) */}
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver Manager</button>
)}
{p.waiverPending && (
p.perms.approveWaiver ? (
<div className="flex items-center gap-2">
<span className="text-sm text-warning-700">Waiver requested.</span>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
)
)}
{/* Selection row */}
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
p.perms.select ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve select</button>
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
</div>
) : (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} awaiting Manager selection.</p>
)
)}
{footer}
</Card>
);
case "SELECTED":
return (
<Card title="Selected" sub="Ready to onboard.">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
<button className={PRIMARY} disabled title="Onboarding arrives in the next phase (3c)">Onboard to crew (next phase)</button>
</Card>
);
case "REJECTED":
return (
<Card title="Rejected">
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
</Card>
);
default:
return (
<Card title="Onboarded">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">This candidate has been onboarded.</p>
</Card>
);
}
}
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 (
<>
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -0,0 +1,47 @@
import type { ApplicationStage } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
// 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<ApplicationStage, string> = {
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<ApplicationStage, Variant> = {
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);
}

View file

@ -33,6 +33,7 @@ export default async function RequisitionDetailPage({
site: { select: { name: true } }, site: { select: { name: true } },
raisedBy: { select: { name: true } }, raisedBy: { select: { name: true } },
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } }, sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
_count: { select: { applications: true } },
}, },
}); });
if (!req) notFound(); if (!req) notFound();
@ -69,8 +70,16 @@ export default async function RequisitionDetailPage({
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago <span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<Link
href={`/crewing/requisitions/${req.id}/pipeline`}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Open pipeline
</Link>
{canWithdraw && <WithdrawRequisitionButton id={req.id} />} {canWithdraw && <WithdrawRequisitionButton id={req.id} />}
</div> </div>
</div>
{req.autoRaised && ( {req.autoRaised && (
<div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800"> <div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800">
@ -108,15 +117,20 @@ export default async function RequisitionDetailPage({
)} )}
</div> </div>
{/* Candidates — populated by the recruitment pipeline (Phase 3) */} {/* Candidates — the recruitment pipeline (Phase 3b) */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50"> <div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2> <h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
</div> </div>
<p className="px-4 py-12 text-center text-sm text-neutral-400"> <div className="px-4 py-8 text-center">
The recruitment pipeline arrives in a later phase. Candidates attached to this <p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
requisition will appear here. <p className="text-sm text-neutral-500 mt-0.5 mb-4">
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
</p> </p>
<Link href={`/crewing/requisitions/${req.id}/pipeline`} className="text-sm font-medium text-primary-600 hover:underline">
Open recruitment pipeline
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -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 (
<PipelineBoard
requisition={{
id: requisition.id,
code: requisition.code,
rank: requisition.rank.name,
location: requisition.vessel?.name ?? requisition.site?.name ?? "—",
status: requisition.status,
}}
applications={applications.map((a) => ({
id: a.id,
stage: a.stage,
crewName: a.crewMember.name,
isExHand: a.crewMember.type === "EX_HAND",
experienceMonths: a.crewMember.experienceMonths,
}))}
pool={pool}
canManage={canManage}
/>
);
}

View file

@ -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 (
<div>
<Link href={`/crewing/requisitions/${requisition.id}`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Requisition
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">{requisition.rank} {requisition.location}</h1>
<p className="text-sm text-neutral-500 mt-0.5">Recruitment pipeline · <span className="font-mono">{requisition.code}</span> · {applications.length} candidate{applications.length === 1 ? "" : "s"}</p>
</div>
{canManage && (
<button onClick={() => setOpen(true)} className="rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-50">
+ Add candidate
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-4">
{STAGE_ORDER.map((s) => {
const cards = byStage(s);
return (
<div key={s} className="w-56 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">{STAGE_LABEL[s]}</span>
<span className="text-xs text-neutral-400">{cards.length}</span>
</div>
<div className="space-y-2 min-h-[60px] rounded-lg bg-neutral-50 p-2">
{cards.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="block rounded-md border border-neutral-200 bg-white p-3 hover:border-primary-300 hover:shadow-sm transition">
<p className="text-sm font-medium text-neutral-900">{a.crewName}</p>
<p className="text-xs text-neutral-500 mt-0.5">
{Math.floor(a.experienceMonths / 12)} yrs
{a.isExHand && <span className="ml-1 text-purple-600">· ex-hand</span>}
</p>
</Link>
))}
{cards.length === 0 && <p className="text-center text-xs text-neutral-300 py-2"></p>}
</div>
</div>
);
})}
</div>
{rejected.length > 0 && (
<div className="mt-6">
<p className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Rejected ({rejected.length})</p>
<div className="flex flex-wrap gap-2">
{rejected.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-50">
{a.crewName}
</Link>
))}
</div>
</div>
)}
<AdminDialog title="Add candidate to pipeline" open={open} onClose={() => setOpen(false)}>
<form onSubmit={add} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Candidate</label>
<select className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" value={crewMemberId} onChange={(e) => setCrewMemberId(e.target.value)} required>
<option value=""> Select from the pool </option>
{pool.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.type === "EX_HAND" ? " (ex-hand)" : ""}</option>
))}
</select>
{pool.length === 0 && <p className="mt-1 text-xs text-neutral-400">No available candidates. Add candidates from the Candidates page first.</p>}
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !crewMemberId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Adding…" : "Add to pipeline"}</button>
</div>
</form>
</AdminDialog>
</div>
);
}

View file

@ -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<Record<ApplicationAction, Transition>>;
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
const TRANSITIONS: Partial<Record<ApplicationStage, TransitionMap>> = {
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);
}

View file

@ -28,7 +28,11 @@ export type NotificationEvent =
export type CrewNotificationEvent = export type CrewNotificationEvent =
| "REQUISITION_RAISED" | "REQUISITION_RAISED"
| "RELIEF_REQUESTED" | "RELIEF_REQUESTED"
| "RELIEF_CONVERTED"; | "RELIEF_CONVERTED"
| "CANDIDATE_PROPOSED"
| "SALARY_FOR_APPROVAL"
| "SELECTION_FOR_APPROVAL"
| "WAIVER_REQUESTED";
interface NotifyParams { interface NotifyParams {
event: NotificationEvent; event: NotificationEvent;
@ -425,6 +429,10 @@ const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
REQUISITION_RAISED: "View Requisition", REQUISITION_RAISED: "View Requisition",
RELIEF_REQUESTED: "View Requisitions", RELIEF_REQUESTED: "View Requisitions",
RELIEF_CONVERTED: "View Requisition", 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) { export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {

View file

@ -82,6 +82,13 @@ export function getMpoRecipients(): Promise<User[]> {
}); });
} }
/** Manager recipients — for the approval gates (salary / selection / waiver). */
export function getManagerRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
});
}
/** /**
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then * 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 * notifies the office. Sign-off, end-of-contract and the leave-clash detector

View file

@ -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;

View file

@ -133,6 +133,64 @@ enum CrewActionType {
RELIEF_CANCELLED RELIEF_CANCELLED
CANDIDATE_ADDED CANDIDATE_ADDED
CANDIDATE_UPDATED 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) ────────────────────────────────── // ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
@ -564,6 +622,7 @@ model Requisition {
sourceReliefRequest ReliefRequest? @relation("ReliefConversion") sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
actions CrewAction[] actions CrewAction[]
applications Application[]
} }
// A foreseen-gap flag from a site (site staff), pending office conversion into a // 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]) requisition Requisition? @relation(fields: [requisitionId], references: [id])
crewMemberId String? crewMemberId String?
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id]) 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 // The talent-pool spine (Phase 3a, Epic B). One row per person, created the
@ -640,4 +701,115 @@ model CrewMember {
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id]) 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.48.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
} }

View file

@ -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<unknown>).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" });
});
});

View file

@ -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);
});
});
});