feat(crewing): land full Crewing module on master (Phases 1–5 + hardening) #93
8 changed files with 417 additions and 3 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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);
|
||||
|
|
|
|||
29
App/lib/employee-number.ts
Normal file
29
App/lib/employee-number.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
129
App/tests/integration/onboarding.test.ts
Normal file
129
App/tests/integration/onboarding.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue