diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 1238bf5..5889bfb 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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`. diff --git a/App/app/(portal)/crewing/applications/[id]/page.tsx b/App/app/(portal)/crewing/applications/[id]/page.tsx index 2fdb825..288efff 100644 --- a/App/app/(portal)/crewing/applications/[id]/page.tsx +++ b/App/app/(portal)/crewing/applications/[id]/page.tsx @@ -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"), }} /> diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts index f63a20d..6b214bf 100644 --- a/App/app/(portal)/crewing/applications/actions.ts +++ b/App/app/(portal)/crewing/applications/actions.ts @@ -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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/applications/application-action-card.tsx b/App/app/(portal)/crewing/applications/application-action-card.tsx index 0bfe368..943bbff 100644 --- a/App/app/(portal)/crewing/applications/application-action-card.tsx +++ b/App/app/(portal)/crewing/applications/application-action-card.tsx @@ -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 (

Candidate selected.

- + {p.perms.onboard && }
); @@ -310,12 +313,62 @@ export function ApplicationActionCard(p: ActionCardProps) { default: return ( -

This candidate has been onboarded.

+

+ Onboarded to crew{p.employeeNo ? <> · {p.employeeNo} : null}. +

); } } +function OnboardButton({ id }: { id: string }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [joiningDate, setJoiningDate] = useState(""); + const [contract, setContract] = useState(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 ( + <> + + setOpen(false)}> +
+
+ + setJoiningDate(e.target.value)} required /> +
+
+ + setContract(e.target.files?.[0] ?? null)} /> +
+
+

Starts automatically on confirm

+

Employee number · salary & victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)

+
+ {error &&

{error}

} +
+ + +
+
+
+ + ); +} + function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) { const router = useRouter(); const [open, setOpen] = useState(false); diff --git a/App/lib/employee-number.ts b/App/lib/employee-number.ts new file mode 100644 index 0000000..27ef3cc --- /dev/null +++ b/App/lib/employee-number.ts @@ -0,0 +1,29 @@ +/** + * Crew employee-number generator. Format: CRW-, 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 { + 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}`; +} diff --git a/App/prisma/migrations/20260622133759_crewing_onboarding/migration.sql b/App/prisma/migrations/20260622133759_crewing_onboarding/migration.sql new file mode 100644 index 0000000..2a42334 --- /dev/null +++ b/App/prisma/migrations/20260622133759_crewing_onboarding/migration.sql @@ -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; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index c4b1e56..1233d8f 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -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()) +} diff --git a/App/tests/integration/onboarding.test.ts b/App/tests/integration/onboarding.test.ts new file mode 100644 index 0000000..4e833ff --- /dev/null +++ b/App/tests/integration/onboarding.test.ts @@ -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).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); + }); +});