diff --git a/App/CLAUDE.md b/App/CLAUDE.md index f2679e9..010799f 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -170,7 +170,7 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **Models:** `LeaveRequest` (`LeaveType`, `LeaveStatus`) and `Attendance` (`AttendanceStatus`, `@@unique([assignmentId, date])`) hang off `CrewAssignment`. `CrewActionType += LEAVE_APPLIED / LEAVE_DECIDED / ATTENDANCE_RECORDED`. - **Leave (R1):** **Site staff apply on behalf** (`apply_leave`); the **Manager decides** (`decide_leave`) — the **MPO has no leave role**. On approval the assignment goes `ON_LEAVE`. Leave approvals also surface in the central `/approvals` queue (§8.13 "Leave" kind, inline Approve/Decline). Notification `LEAVE_FOR_APPROVAL`. -- **Clash auto-backfill (R6):** `lib/leave-clash.ts` treats **required strength = 1** — approving a leave that would leave the vessel with **zero** active same-rank cover over the window auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. (Configurable per-vessel strength is a future refinement.) +- **Clash auto-backfill (R6, Option A):** `VesselRankRequirement{vesselId, rankId, minStrength}` configures required crew strength per rank per vessel. `lib/leave-clash.ts` flags a clash when approving a leave would drop the **active same-rank cover over the window below `minStrength`** (default **1** when unconfigured) → auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. The requirement is managed by the office (`manage_crew`). - **Attendance (R5):** daily month calendar, **site staff record** (`record_attendance`), **Manager views** (`view_attendance`) but cannot edit, **MPO has neither**. `saveAttendance(assignmentId, marks)` bulk-upserts the dirty cells. - **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only). - **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred. diff --git a/App/app/(portal)/crewing/leave/actions.ts b/App/app/(portal)/crewing/leave/actions.ts index 227f8f1..0363b85 100644 --- a/App/app/(portal)/crewing/leave/actions.ts +++ b/App/app/(portal)/crewing/leave/actions.ts @@ -4,7 +4,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission, type Permission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; -import { leaveLeavesNoCover } from "@/lib/leave-clash"; +import { leaveCausesClash } from "@/lib/leave-clash"; import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service"; import { notifyCrew } from "@/lib/notifier"; import { LeaveType } from "@prisma/client"; @@ -114,7 +114,7 @@ export async function decideLeave(id: string, approve: boolean, note?: string): await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } }); await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } }); await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } }); - const clash = await leaveLeavesNoCover(tx, { + const clash = await leaveCausesClash(tx, { assignmentId: leave.assignment.id, rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, diff --git a/App/lib/leave-clash.ts b/App/lib/leave-clash.ts index d5172d9..bda2801 100644 --- a/App/lib/leave-clash.ts +++ b/App/lib/leave-clash.ts @@ -1,10 +1,10 @@ import type { Prisma } from "@prisma/client"; -// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6). Required strength -// is treated as 1: approving a leave is a clash when it would leave the vessel -// with ZERO active same-rank cover over the leave window — i.e. every other -// not-signed-off crew member of that rank on the vessel is either absent or on an -// approved leave that overlaps the window. A clash auto-raises a LEAVE requisition. +// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6 — Option A). +// Approving a leave is a clash when the remaining ACTIVE same-rank cover on the +// vessel over the leave window would fall BELOW the rank's required strength for +// that vessel (VesselRankRequirement.minStrength, default 1 when unconfigured). +// A clash auto-raises a LEAVE requisition. interface ClashInput { assignmentId: string; @@ -14,31 +14,41 @@ interface ClashInput { toDate: Date; } -export async function leaveLeavesNoCover( +export async function leaveCausesClash( tx: Prisma.TransactionClient, { assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput ): Promise { // No vessel cost axis → no rank-cover check. if (!vesselId) return false; + const requirement = await tx.vesselRankRequirement.findUnique({ + where: { vesselId_rankId: { vesselId, rankId } }, + select: { minStrength: true }, + }); + const requiredStrength = requirement?.minStrength ?? 1; + if (requiredStrength <= 0) return false; + + // Other not-signed-off same-rank crew on the vessel (excludes the one going on leave). const others = await tx.crewAssignment.findMany({ where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } }, select: { id: true }, }); - // This crew member was the only same-rank cover on the vessel. - if (others.length === 0) return true; - const otherIds = others.map((o) => o.id); - const overlapping = await tx.leaveRequest.findMany({ - where: { - assignmentId: { in: otherIds }, - status: "APPROVED", - fromDate: { lte: toDate }, - toDate: { gte: fromDate }, - }, - select: { assignmentId: true }, - }); - const out = new Set(overlapping.map((l) => l.assignmentId)); - const remainingCover = otherIds.filter((id) => !out.has(id)).length; - return remainingCover === 0; + let remainingCover = 0; + if (others.length > 0) { + const otherIds = others.map((o) => o.id); + const overlapping = await tx.leaveRequest.findMany({ + where: { + assignmentId: { in: otherIds }, + status: "APPROVED", + fromDate: { lte: toDate }, + toDate: { gte: fromDate }, + }, + select: { assignmentId: true }, + }); + const out = new Set(overlapping.map((l) => l.assignmentId)); + remainingCover = otherIds.filter((id) => !out.has(id)).length; + } + + return remainingCover < requiredStrength; } diff --git a/App/prisma/migrations/20260622154155_crewing_vessel_rank_requirement/migration.sql b/App/prisma/migrations/20260622154155_crewing_vessel_rank_requirement/migration.sql new file mode 100644 index 0000000..312bd06 --- /dev/null +++ b/App/prisma/migrations/20260622154155_crewing_vessel_rank_requirement/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "VesselRankRequirement" ( + "id" TEXT NOT NULL, + "vesselId" TEXT NOT NULL, + "rankId" TEXT NOT NULL, + "minStrength" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VesselRankRequirement_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "VesselRankRequirement_vesselId_rankId_key" ON "VesselRankRequirement"("vesselId", "rankId"); + +-- AddForeignKey +ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index b525f50..65b32b2 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -338,10 +338,11 @@ model Vessel { code String @unique isActive Boolean @default(true) - purchaseOrders PurchaseOrder[] - requisitions Requisition[] - reliefRequests ReliefRequest[] - assignments CrewAssignment[] + purchaseOrders PurchaseOrder[] + requisitions Requisition[] + reliefRequests ReliefRequest[] + assignments CrewAssignment[] + rankRequirements VesselRankRequirement[] } model Company { @@ -626,13 +627,14 @@ model Rank { parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id]) children Rank[] @relation("RankHierarchy") - docRequirements RankDocRequirement[] - requisitions Requisition[] - reliefRequests ReliefRequest[] - crewCurrentRank CrewMember[] @relation("CrewCurrentRank") - crewAppliedRank CrewMember[] @relation("CrewAppliedRank") - assignments CrewAssignment[] - experienceRecords ExperienceRecord[] + docRequirements RankDocRequirement[] + requisitions Requisition[] + reliefRequests ReliefRequest[] + crewCurrentRank CrewMember[] @relation("CrewCurrentRank") + crewAppliedRank CrewMember[] @relation("CrewAppliedRank") + assignments CrewAssignment[] + experienceRecords ExperienceRecord[] + vesselRequirements VesselRankRequirement[] } // Which documents a rank is required (or conditionally required) to hold. @@ -950,6 +952,23 @@ model Attendance { @@unique([assignmentId, date]) } +// Required crew strength per rank, per vessel (Phase 4b, Option A). Drives +// leave-clash detection (§5.3, R6): approving a leave is a clash when the active +// same-rank cover over the window would fall below this. Managed by the office +// (manage_crew). Absent a row, the clash check falls back to a strength of 1. +model VesselRankRequirement { + id String @id @default(cuid()) + vesselId String + vessel Vessel @relation(fields: [vesselId], references: [id], onDelete: Cascade) + rankId String + rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade) + minStrength Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([vesselId, rankId]) +} + // The signed contract for an assignment. `salaryRestricted` hides salary from // site staff on the crew profile (Phase 4 display gating). model ContractLetter { diff --git a/App/tests/integration/leave-attendance.test.ts b/App/tests/integration/leave-attendance.test.ts index 38a2a1a..b3075e7 100644 --- a/App/tests/integration/leave-attendance.test.ts +++ b/App/tests/integration/leave-attendance.test.ts @@ -54,6 +54,7 @@ afterEach(async () => { await db.leaveRequest.deleteMany({}); await db.crewAssignment.deleteMany({}); await db.requisition.deleteMany({}); + await db.vesselRankRequirement.deleteMany({}); await db.crewMember.deleteMany({}); vi.clearAllMocks(); }); @@ -107,7 +108,7 @@ describe("clash auto-backfill (required strength = 1)", () => { expect(req!.vesselId).toBe(vesselId); }); - it("does NOT auto-raise when another active same-rank crew remains", async () => { + it("does NOT auto-raise when another active same-rank crew remains (default strength 1)", async () => { const a = await makeAssignment("Going On Leave"); await makeAssignment("Stays Active"); // same rank + vessel, active const leaveId = await applyAndGetId(a.id); @@ -115,6 +116,17 @@ describe("clash auto-backfill (required strength = 1)", () => { await decideLeave(leaveId, true); expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0); }); + + it("auto-raises when a configured required strength exceeds the remaining cover (Option A)", async () => { + // Require 2 of this rank on the vessel; with one remaining after leave → clash. + await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } }); + const a = await makeAssignment("Going On Leave"); + await makeAssignment("Stays Active"); + const leaveId = await applyAndGetId(a.id); + as(managerId, "MANAGER"); + await decideLeave(leaveId, true); + expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(1); + }); }); describe("attendance", () => {