feat(crewing): Phase 3b — recruitment pipeline (flagged)
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:
parent
be6db075dc
commit
3ec3a2b4ef
17 changed files with 2189 additions and 9 deletions
|
|
@ -143,6 +143,14 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
|
|||
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
|
||||
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
|
||||
|
||||
**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):**
|
||||
|
||||
- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`.
|
||||
- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns.
|
||||
- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`).
|
||||
- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail.
|
||||
- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue.
|
||||
|
||||
### GST Calculation
|
||||
|
||||
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
|
||||
|
|
|
|||
113
App/app/(portal)/approvals/crewing-approvals.tsx
Normal file
113
App/app/(portal)/approvals/crewing-approvals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { ApprovalsSearch } from "./approvals-search";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -49,6 +51,49 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
|||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
|
||||
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
|
||||
const role = session.user.role;
|
||||
const showCrewing =
|
||||
CREWING_ENABLED &&
|
||||
(hasPermission(role, "approve_salary_structure") ||
|
||||
hasPermission(role, "select_candidate") ||
|
||||
hasPermission(role, "approve_interview_waiver"));
|
||||
|
||||
const crewGates = showCrewing
|
||||
? await db.applicationGate.findMany({
|
||||
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
application: {
|
||||
include: {
|
||||
crewMember: { select: { name: true } },
|
||||
requisition: { select: { code: true, rank: { select: { name: true } } } },
|
||||
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
|
||||
const sal = g.application.salaryStructures[0];
|
||||
const detail =
|
||||
g.gate === "SALARY" && sal
|
||||
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
|
||||
: g.gate === "WAIVER"
|
||||
? "Returning crew — interview waiver"
|
||||
: "Interview cleared";
|
||||
return {
|
||||
applicationId: g.applicationId,
|
||||
kind: g.gate as CrewApprovalKind,
|
||||
candidateName: g.application.crewMember.name,
|
||||
rank: g.application.requisition.rank.name,
|
||||
requisitionCode: g.application.requisition.code,
|
||||
detail,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
|
|
@ -137,6 +182,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCrewing && crewItems.length > 0 && <CrewingApprovals items={crewItems} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
142
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal file
142
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
537
App/app/(portal)/crewing/applications/actions.ts
Normal file
537
App/app/(portal)/crewing/applications/actions.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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 & 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 & 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 & 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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal file
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ export default async function RequisitionDetailPage({
|
|||
site: { select: { name: true } },
|
||||
raisedBy: { select: { name: true } },
|
||||
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
|
||||
_count: { select: { applications: true } },
|
||||
},
|
||||
});
|
||||
if (!req) notFound();
|
||||
|
|
@ -69,7 +70,15 @@ export default async function RequisitionDetailPage({
|
|||
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
|
||||
</p>
|
||||
</div>
|
||||
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
|
||||
<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} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{req.autoRaised && (
|
||||
|
|
@ -108,15 +117,20 @@ export default async function RequisitionDetailPage({
|
|||
)}
|
||||
</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="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
|
||||
</div>
|
||||
<p className="px-4 py-12 text-center text-sm text-neutral-400">
|
||||
The recruitment pipeline arrives in a later phase. Candidates attached to this
|
||||
requisition will appear here.
|
||||
</p>
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
|
||||
<p className="text-sm text-neutral-500 mt-0.5 mb-4">
|
||||
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
|
||||
</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>
|
||||
|
|
|
|||
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal file
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
99
App/lib/application-pipeline.ts
Normal file
99
App/lib/application-pipeline.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -28,7 +28,11 @@ export type NotificationEvent =
|
|||
export type CrewNotificationEvent =
|
||||
| "REQUISITION_RAISED"
|
||||
| "RELIEF_REQUESTED"
|
||||
| "RELIEF_CONVERTED";
|
||||
| "RELIEF_CONVERTED"
|
||||
| "CANDIDATE_PROPOSED"
|
||||
| "SALARY_FOR_APPROVAL"
|
||||
| "SELECTION_FOR_APPROVAL"
|
||||
| "WAIVER_REQUESTED";
|
||||
|
||||
interface NotifyParams {
|
||||
event: NotificationEvent;
|
||||
|
|
@ -425,6 +429,10 @@ const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
|
|||
REQUISITION_RAISED: "View Requisition",
|
||||
RELIEF_REQUESTED: "View Requisitions",
|
||||
RELIEF_CONVERTED: "View Requisition",
|
||||
CANDIDATE_PROPOSED: "View Candidate",
|
||||
SALARY_FOR_APPROVAL: "Review Salary",
|
||||
SELECTION_FOR_APPROVAL: "Review Selection",
|
||||
WAIVER_REQUESTED: "Review Waiver",
|
||||
};
|
||||
|
||||
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -133,6 +133,64 @@ enum CrewActionType {
|
|||
RELIEF_CANCELLED
|
||||
CANDIDATE_ADDED
|
||||
CANDIDATE_UPDATED
|
||||
APPLICATION_CREATED
|
||||
GATE_PASSED
|
||||
GATE_FAILED
|
||||
REFERENCE_RECORDED
|
||||
SALARY_AGREED
|
||||
SALARY_APPROVED
|
||||
CANDIDATE_PROPOSED
|
||||
INTERVIEW_RECORDED
|
||||
WAIVER_REQUESTED
|
||||
WAIVER_APPROVED
|
||||
CANDIDATE_SELECTED
|
||||
APPLICATION_REJECTED
|
||||
}
|
||||
|
||||
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
|
||||
// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
|
||||
// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
|
||||
// REJECTED is the branch reachable from any active stage.
|
||||
enum ApplicationStage {
|
||||
SHORTLISTED
|
||||
COMPETENCY_AND_REFERENCES
|
||||
DOC_VERIFICATION
|
||||
SALARY_AGREEMENT
|
||||
PROPOSED
|
||||
INTERVIEW
|
||||
SELECTED
|
||||
REJECTED
|
||||
ONBOARDED
|
||||
}
|
||||
|
||||
// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
|
||||
// Manager-decided gates that surface in the central Approvals queue (§8.13).
|
||||
enum ApplicationGateType {
|
||||
COMPETENCY_REFERENCE
|
||||
DOCUMENT
|
||||
SALARY
|
||||
INTERVIEW
|
||||
WAIVER
|
||||
SELECTION
|
||||
}
|
||||
|
||||
enum GateResult {
|
||||
PENDING
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// MPO's recorded interview outcome (Manager then approves selection).
|
||||
enum InterviewOutcome {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// Salary capture basis — the other is derived (R10/A4). Effective-dated.
|
||||
enum SalaryRateBasis {
|
||||
MONTHLY
|
||||
DAILY
|
||||
}
|
||||
|
||||
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
|
||||
|
|
@ -563,7 +621,8 @@ model Requisition {
|
|||
// The site relief request this requisition was converted from, if any.
|
||||
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
|
||||
|
||||
actions CrewAction[]
|
||||
actions CrewAction[]
|
||||
applications Application[]
|
||||
}
|
||||
|
||||
// A foreseen-gap flag from a site (site staff), pending office conversion into a
|
||||
|
|
@ -609,6 +668,8 @@ model CrewAction {
|
|||
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
||||
crewMemberId String?
|
||||
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
|
||||
applicationId String?
|
||||
application Application? @relation(fields: [applicationId], references: [id])
|
||||
}
|
||||
|
||||
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
|
||||
|
|
@ -639,5 +700,116 @@ model CrewMember {
|
|||
appliedRankId String?
|
||||
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
|
||||
|
||||
actions CrewAction[]
|
||||
actions CrewAction[]
|
||||
applications Application[]
|
||||
bankDetail BankDetail?
|
||||
epfDetail EpfDetail?
|
||||
}
|
||||
|
||||
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
|
||||
|
||||
// A candidate's application against one requisition — the gated pipeline spine
|
||||
// (spec §5.1/§8.4–8.5). One application per (requisition, candidate).
|
||||
model Application {
|
||||
id String @id @default(cuid())
|
||||
stage ApplicationStage @default(SHORTLISTED)
|
||||
type CandidateType @default(NEW)
|
||||
interviewResult InterviewOutcome @default(PENDING)
|
||||
interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
|
||||
rejectedReason String?
|
||||
rejectedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
requisitionId String
|
||||
requisition Requisition @relation(fields: [requisitionId], references: [id])
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
|
||||
|
||||
gates ApplicationGate[]
|
||||
referenceChecks ReferenceCheck[]
|
||||
salaryStructures SalaryStructure[]
|
||||
actions CrewAction[]
|
||||
|
||||
@@unique([requisitionId, crewMemberId])
|
||||
}
|
||||
|
||||
// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
|
||||
// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
|
||||
// denormalised actor id — the audited actor lives on the CrewAction.
|
||||
model ApplicationGate {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
gate ApplicationGateType
|
||||
result GateResult @default(PENDING)
|
||||
note String?
|
||||
decidedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([applicationId, gate])
|
||||
}
|
||||
|
||||
// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
|
||||
model ReferenceCheck {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
refereeName String
|
||||
refereeContact String?
|
||||
outcome String? // free-text / "positive" | "negative"
|
||||
note String?
|
||||
recordedById String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
|
||||
// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
|
||||
// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
|
||||
model SalaryStructure {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
rateBasis SalaryRateBasis @default(MONTHLY)
|
||||
basic Decimal @db.Decimal(12, 2)
|
||||
victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
|
||||
allowances Json?
|
||||
currency String @default("INR")
|
||||
effectiveFrom DateTime?
|
||||
effectiveTo DateTime?
|
||||
approvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
|
||||
// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
|
||||
// plainly for now behind the crewing flag.
|
||||
model BankDetail {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String @unique
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
accountName String?
|
||||
accountNumber String?
|
||||
ifsc String?
|
||||
bankName String?
|
||||
verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
|
||||
model EpfDetail {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String @unique
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
uan String?
|
||||
aadhaarLast4 String?
|
||||
pfNumber String?
|
||||
verificationStatus GateResult @default(PENDING)
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
|
|
|||
209
App/tests/integration/applications.test.ts
Normal file
209
App/tests/integration/applications.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
74
App/tests/unit/application-pipeline.test.ts
Normal file
74
App/tests/unit/application-pipeline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue