feat(crewing): Phase 3c — onboarding (flagged)

Final slice of Phase 3 (stacked on 3b pipeline). The onboarding transaction that
turns a SELECTED candidate into active crew, per Crewing-Implementation-Spec
§8.5/§9/§11. Behind NEXT_PUBLIC_CREWING_ENABLED; production unchanged.

What's in
- Schema (crewing_onboarding migration): CrewAssignment + AssignmentStatus
  (ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4); ContractLetter
  (salaryRestricted); SalaryStructure += assignmentId; CrewActionType +=
  CREW_ONBOARDED. Employee numbers CRW-xxxx via lib/employee-number.ts.
- Action (onboardCandidate, onboard_crew): one transaction off a SELECTED
  application — assign employeeId, create CrewAssignment(ACTIVE, signOnDate),
  bind the approved SalaryStructure (assignmentId + effectiveFrom), Application →
  ONBOARDED, Requisition → FILLED, CrewMember → EMPLOYEE (+ currentRank); contract
  letter stored after. Guards flag + permission + SELECTED state.
- Screen: the SELECTED action card's "Onboard to crew" modal (joining date,
  contract upload, starts-automatically chips); the CRW- number shows on the
  ONBOARDED card.

Tests & docs
- Integration: onboarding.test.ts (5) — full transaction, requisition FILLED +
  salary binding, joining-date + SELECTED-only guards, permission gating, sequential
  CRW- ids. type-check clean; full unit (234) + integration (168) green.
- CLAUDE.md updated with the Phase 3c surface.

Deferred: SITE_STAFF login creation for management ranks (grantsLogin) — a
follow-up; attendance/experience/PPE records begin in Phase 4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 19:12:53 +05:30
parent 3ec3a2b4ef
commit c82efa71af
8 changed files with 417 additions and 3 deletions

View file

@ -151,6 +151,13 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **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.
**Phase 3c — Onboarding (Epic D; spec §8.5/§9/§11):**
- **Models:** `CrewAssignment` (a tour of duty, `AssignmentStatus` ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) and `ContractLetter` (`salaryRestricted`). `SalaryStructure` gained `assignmentId` (bound at onboarding). `CrewActionType += CREW_ONBOARDED`. Employee numbers `CRW-xxxx` via `lib/employee-number.ts`.
- **Action** (`onboardCandidate`, `onboard_crew`): one transaction off a `SELECTED` application — assign `employeeId`, create `CrewAssignment(ACTIVE, signOnDate)`, bind the approved `SalaryStructure` (`assignmentId` + `effectiveFrom`), `Application → ONBOARDED`, `Requisition → FILLED`, `CrewMember → EMPLOYEE` (+ `currentRank`); contract letter stored after. Onboarded crew leave the Candidates pool (the Crew directory is Phase 4).
- **Screen:** the SELECTED action card's **Onboard to crew** modal (joining date, contract upload, starts-automatically chips); the assigned `CRW-` number shows on the ONBOARDED card.
- **Deferred:** SITE_STAFF **login creation** for management ranks (grantsLogin) is a follow-up; attendance/experience/PPE records (the "starts automatically" chips) begin in Phase 4.
### 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`.

View file

@ -89,6 +89,7 @@ export default async function ApplicationDetailPage({ params }: { params: Promis
salaryPending={salaryPending}
waiverPending={waiverPending}
selectionPending={selectionPending}
employeeNo={app.crewMember.employeeId}
salary={proposed ? {
rateBasis: proposed.rateBasis,
basic: Number(proposed.basic),
@ -104,6 +105,7 @@ export default async function ApplicationDetailPage({ params }: { params: Promis
approveSalary: hasPermission(role, "approve_salary_structure"),
approveWaiver: hasPermission(role, "approve_interview_waiver"),
select: hasPermission(role, "select_candidate"),
onboard: hasPermission(role, "onboard_crew"),
}}
/>

View file

@ -10,7 +10,9 @@ import {
getTransition,
type ApplicationAction,
} from "@/lib/application-pipeline";
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
import { getManagerRecipients } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client";
import type { Role } from "@prisma/client";
@ -535,3 +537,75 @@ export async function rejectApplication(id: string, reason: string): Promise<Act
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
}
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
// One transaction off a SELECTED application: assign the employee number, create
// the ACTIVE assignment, bind the approved salary, flip the application to
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
// Login-account creation for management ranks is a deferred follow-up.
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("onboard_crew");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const joiningStr = formData.get("joiningDate") as string;
if (!joiningStr) return { error: "A joining date is required" };
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
crewMember: { select: { id: true } },
},
});
if (!app) return { error: "Application not found" };
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
const joiningDate = new Date(joiningStr);
const result = await db.$transaction(async (tx) => {
const employeeId = await generateEmployeeId(tx);
const assignment = await tx.crewAssignment.create({
data: {
status: "ACTIVE",
signOnDate: joiningDate,
crewMemberId: app.crewMember.id,
rankId: app.requisition.rankId,
vesselId: app.requisition.vesselId,
siteId: app.requisition.siteId,
requisitionId: app.requisition.id,
},
});
// Bind the Manager-approved salary structure to the new assignment.
await tx.salaryStructure.updateMany({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
});
await tx.application.update({
where: { id },
data: { stage: "ONBOARDED", actions: { create: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
});
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
});
await tx.crewMember.update({
where: { id: app.crewMember.id },
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
});
return { assignmentId: assignment.id, employeeId };
});
// Contract letter (optional) — stored after the core transaction.
const file = formData.get("contract");
if (file instanceof File && file.size > 0) {
const key = buildStorageKey("contract", result.assignmentId, file.name);
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
await db.contractLetter.create({
data: { assignmentId: result.assignmentId, fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" },
});
}
revalidateApp(id, app.requisition.id);
return { ok: true, id: result.employeeId };
}

View file

@ -17,6 +17,7 @@ import {
selectCandidate,
returnSelection,
rejectApplication,
onboardCandidate,
} from "./actions";
const INPUT =
@ -35,6 +36,7 @@ export type ActionCardProps = {
salaryPending: boolean;
waiverPending: boolean;
selectionPending: boolean;
employeeNo: string | null;
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
perms: {
manage: boolean;
@ -44,6 +46,7 @@ export type ActionCardProps = {
approveSalary: boolean;
approveWaiver: boolean;
select: boolean;
onboard: boolean;
};
};
@ -296,7 +299,7 @@ export function ApplicationActionCard(p: ActionCardProps) {
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>
{p.perms.onboard && <OnboardButton id={p.id} />}
</Card>
);
@ -310,12 +313,62 @@ export function ApplicationActionCard(p: ActionCardProps) {
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>
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
</p>
</Card>
);
}
}
function OnboardButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [joiningDate, setJoiningDate] = useState("");
const [contract, setContract] = useState<File | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("applicationId", id);
fd.set("joiningDate", joiningDate);
if (contract) fd.set("contract", contract);
const res = await onboardCandidate(fd);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button className={PRIMARY} onClick={() => setOpen(true)}>Onboard to crew</button>
<AdminDialog title="Onboard to crew" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
<input type="date" className={INPUT} value={joiningDate} onChange={(e) => setJoiningDate(e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contract letter (optional)</label>
<input type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" onChange={(e) => setContract(e.target.files?.[0] ?? null)} />
</div>
<div className="rounded-md bg-neutral-50 border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600 mb-1">Starts automatically on confirm</p>
<p className="text-xs text-neutral-500">Employee number · salary &amp; victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)</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={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !joiningDate} className={PRIMARY}>{pending ? "Onboarding…" : "Confirm onboarding"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
const [open, setOpen] = useState(false);

View file

@ -0,0 +1,29 @@
/**
* Crew employee-number generator. Format: CRW-<id>, e.g. CRW-1000.
*
* Sequential, floored at 1000, scanning existing CrewMember.employeeId values.
* Assigned at onboarding (Phase 3c). Call inside the onboarding transaction to
* minimise the race window (the unique constraint is the backstop).
*/
import { db } from "@/lib/db";
import type { Prisma } from "@prisma/client";
const PREFIX = "CRW-";
const FLOOR = 999; // first generated id is 1000
export async function generateEmployeeId(
client: Prisma.TransactionClient | typeof db = db
): Promise<string> {
const rows = await client.crewMember.findMany({
where: { employeeId: { startsWith: PREFIX } },
select: { employeeId: true },
});
let maxId = FLOOR;
for (const { employeeId } of rows) {
if (!employeeId) continue;
const n = parseInt(employeeId.slice(PREFIX.length), 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
return `${PREFIX}${maxId + 1}`;
}

View file

@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "AssignmentStatus" AS ENUM ('ACTIVE', 'ON_LEAVE', 'SIGNED_OFF');
-- AlterEnum
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_ONBOARDED';
-- AlterTable
ALTER TABLE "SalaryStructure" ADD COLUMN "assignmentId" TEXT;
-- CreateTable
CREATE TABLE "CrewAssignment" (
"id" TEXT NOT NULL,
"status" "AssignmentStatus" NOT NULL DEFAULT 'ACTIVE',
"signOnDate" TIMESTAMP(3) NOT NULL,
"signOffDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"crewMemberId" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"requisitionId" TEXT,
CONSTRAINT "CrewAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractLetter" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"salaryRestricted" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContractLetter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewAssignment_requisitionId_key" ON "CrewAssignment"("requisitionId");
-- CreateIndex
CREATE UNIQUE INDEX "ContractLetter_assignmentId_key" ON "ContractLetter"("assignmentId");
-- AddForeignKey
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractLetter" ADD CONSTRAINT "ContractLetter_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -145,6 +145,7 @@ enum CrewActionType {
WAIVER_APPROVED
CANDIDATE_SELECTED
APPLICATION_REJECTED
CREW_ONBOARDED
}
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
@ -193,6 +194,14 @@ enum SalaryRateBasis {
DAILY
}
// A crew member's tour of duty (Phase 3c, Epic D). Created at onboarding; the
// leave/sign-off transitions land in Phase 4. See Crewing-Data-Model §4.
enum AssignmentStatus {
ACTIVE
ON_LEAVE
SIGNED_OFF
}
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
// A CrewMember is the talent-pool spine: a row exists from first contact and
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
@ -271,6 +280,7 @@ model Site {
consumption ItemConsumption[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
}
model Vessel {
@ -282,6 +292,7 @@ model Vessel {
purchaseOrders PurchaseOrder[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
assignments CrewAssignment[]
}
model Company {
@ -571,6 +582,7 @@ model Rank {
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
assignments CrewAssignment[]
}
// Which documents a rank is required (or conditionally required) to hold.
@ -623,6 +635,7 @@ model Requisition {
actions CrewAction[]
applications Application[]
assignment CrewAssignment?
}
// A foreseen-gap flag from a site (site staff), pending office conversion into a
@ -704,6 +717,7 @@ model CrewMember {
applications Application[]
bankDetail BankDetail?
epfDetail EpfDetail?
assignments CrewAssignment[]
}
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
@ -781,6 +795,10 @@ model SalaryStructure {
approvedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Bound to the assignment at onboarding (Phase 3c); null while still a proposal.
assignmentId String?
assignment CrewAssignment? @relation(fields: [assignmentId], references: [id])
}
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
@ -813,3 +831,42 @@ model EpfDetail {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Crewing onboarding (Phase 3c: Epic D) ──────────────────────────────────
// A single tour of duty, created at onboarding. Flips the requisition to FILLED
// and the crew member to EMPLOYEE. Leave/sign-off transitions arrive in Phase 4.
model CrewAssignment {
id String @id @default(cuid())
status AssignmentStatus @default(ACTIVE)
signOnDate DateTime
signOffDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
crewMemberId String
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
rankId String
rank Rank @relation(fields: [rankId], references: [id])
vesselId String?
vessel Vessel? @relation(fields: [vesselId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
// The requisition this assignment fills (one assignment per requisition).
requisitionId String? @unique
requisition Requisition? @relation(fields: [requisitionId], references: [id])
salaryStructures SalaryStructure[]
contractLetter ContractLetter?
}
// The signed contract for an assignment. `salaryRestricted` hides salary from
// site staff on the crew profile (Phase 4 display gating).
model ContractLetter {
id String @id @default(cuid())
assignmentId String @unique
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
fileKey String
salaryRestricted Boolean @default(true)
createdAt DateTime @default(now())
}

View file

@ -0,0 +1,129 @@
/**
* Integration tests for the Crewing Phase 3c onboarding action. Onboarding is the
* side-effecting transaction off a SELECTED application (assignment + employeeId +
* salary binding + requisition FILLED + crew EMPLOYEE).
*/
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 { onboardCandidate } from "@/app/(portal)/crewing/applications/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itonb.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
let seq = 0;
async function selectedApplication() {
seq += 1;
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
const cand = await db.crewMember.create({ data: { name: "Selected Sam", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
const app = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
await db.salaryStructure.create({ data: { applicationId: app.id, rateBasis: "MONTHLY", basic: 50000, approvedById: managerId } });
return { appId: app.id, reqId: req.id, candId: cand.id };
}
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITONB-SS", email: SS_EMAIL, name: "SS Onb", role: "SITE_STAFF" } });
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
vesselId = (await db.vessel.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.contractLetter.deleteMany({});
await db.crewAction.deleteMany({});
await db.salaryStructure.deleteMany({});
await db.applicationGate.deleteMany({});
await db.referenceCheck.deleteMany({});
await db.crewAssignment.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("onboardCandidate", () => {
it("onboards a SELECTED candidate end-to-end in one transaction", async () => {
const { appId, reqId, candId } = await selectedApplication();
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
expect("ok" in res && res.ok).toBe(true);
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: candId } });
expect(assignment.status).toBe("ACTIVE");
expect(assignment.requisitionId).toBe(reqId);
expect(assignment.rankId).toBe(rankId);
const cm = await db.crewMember.findUniqueOrThrow({ where: { id: candId } });
expect(cm.status).toBe("EMPLOYEE");
expect(cm.employeeId).toMatch(/^CRW-\d+$/);
expect(cm.currentRankId).toBe(rankId);
expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).stage).toBe("ONBOARDED");
expect((await db.requisition.findUniqueOrThrow({ where: { id: reqId } })).status).toBe("FILLED");
const sal = await db.salaryStructure.findFirstOrThrow({ where: { applicationId: appId } });
expect(sal.assignmentId).toBe(assignment.id);
expect(sal.effectiveFrom).not.toBeNull();
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } });
expect(action.actorId).toBe(managerId);
});
it("requires a joining date", async () => {
const { appId } = await selectedApplication();
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId }));
expect("error" in res).toBe(true);
expect(await db.crewAssignment.count()).toBe(0);
});
it("only onboards from SELECTED", async () => {
const { appId } = await selectedApplication();
await db.application.update({ where: { id: appId }, data: { stage: "INTERVIEW" } });
as(managerId, "MANAGER");
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
expect("error" in res).toBe(true);
expect(await db.crewAssignment.count()).toBe(0);
});
it("is rejected for roles without onboard_crew (site staff, accounts)", async () => {
const { appId } = await selectedApplication();
as(siteStaffId, "SITE_STAFF");
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
as(managerId, "ACCOUNTS");
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
expect(await db.crewAssignment.count()).toBe(0);
});
it("assigns sequential CRW- employee numbers", async () => {
const a = await selectedApplication();
const b = await selectedApplication();
as(managerId, "MANAGER");
await onboardCandidate(fd({ applicationId: a.appId, joiningDate: "2026-07-01" }));
await onboardCandidate(fd({ applicationId: b.appId, joiningDate: "2026-07-02" }));
const ids = (await db.crewMember.findMany({ where: { employeeId: { not: null } }, select: { employeeId: true } })).map((c) => c.employeeId);
expect(new Set(ids).size).toBe(2);
expect(ids.every((i) => /^CRW-\d+$/.test(i!))).toBe(true);
});
});