pelagia-portal/App/app/(portal)/crewing/leave/actions.ts
Hardik aac31c6755 feat(crewing): Phase 4b — leave & attendance (flagged)
Second slice of Phase 4 (stacked on 4a crew records). Leave (site-applied,
Manager-decided) with clash auto-backfill, and the daily attendance calendar,
per Crewing-Implementation-Spec §5.3/§8.9–8.10. Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Schema (crewing_leave_attendance migration): LeaveRequest (LeaveType,
  LeaveStatus) + Attendance (AttendanceStatus, unique per assignment+date) on
  CrewAssignment; CrewActionType += LEAVE_APPLIED/LEAVE_DECIDED/ATTENDANCE_RECORDED.
- Leave (R1): site staff apply on behalf (apply_leave); Manager decides
  (decide_leave) → assignment ON_LEAVE; MPO has no leave role. Leave approvals also
  surface in the central /approvals queue (§8.13 Leave kind). Notification
  LEAVE_FOR_APPROVAL.
- Clash auto-backfill (R6): lib/leave-clash.ts, required strength = 1 — approving a
  leave that leaves the vessel with zero active same-rank cover auto-raises a LEAVE
  requisition via the Phase-2 autoRaiseRequisition.
- Attendance (R5): daily month calendar; site staff record (record_attendance),
  Manager views (view_attendance) but cannot edit, MPO neither. saveAttendance
  bulk-upserts dirty cells.
- Screens: /crewing/leave (apply-on-behalf + Manager Approve/Decline) and
  /crewing/attendance (tap-to-cycle calendar + Save). Leave + Attendance added to
  the flag-gated nav (Manager + Site staff).

Tests & docs
- Integration: leave-attendance.test.ts (7) — apply/decide, clash auto-raise (and
  no-raise when cover remains), MPO/Manager attendance lockout, permission gating.
  type-check clean; full unit (240) + integration (182) green.
- CLAUDE.md updated with the Phase 4b surface.

Deferred: the 6-month leave-planner timeline (lightweight list for now); hours/
overtime attendance (A7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:07:15 +05:30

139 lines
5.8 KiB
TypeScript

"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 { leaveLeavesNoCover } from "@/lib/leave-clash";
import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { LeaveType } 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 LEAVE_PATH = "/crewing/leave";
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 };
}
function revalidate() {
revalidatePath(LEAVE_PATH);
revalidatePath("/approvals");
}
// ── Apply for leave (Site staff, on behalf of a crew member) ───────────────────
const applySchema = z
.object({
assignmentId: z.string().min(1, "Crew member is required"),
type: z.nativeEnum(LeaveType).default("ANNUAL"),
fromDate: z.string().min(1, "From date is required"),
toDate: z.string().min(1, "To date is required"),
reason: z.string().optional(),
})
.refine((d) => new Date(d.toDate) >= new Date(d.fromDate), { message: "To date must be on or after the from date" });
export async function applyLeave(formData: FormData): Promise<ActionResult> {
const g = await guard("apply_leave");
if ("error" in g) return g;
const parsed = applySchema.safeParse({
assignmentId: formData.get("assignmentId"),
type: (formData.get("type") as string) || undefined,
fromDate: formData.get("fromDate"),
toDate: formData.get("toDate"),
reason: (formData.get("reason") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const assignment = await db.crewAssignment.findUnique({
where: { id: d.assignmentId },
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
});
if (!assignment) return { error: "Crew assignment not found" };
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has signed off" };
const leave = await db.leaveRequest.create({
data: {
assignmentId: d.assignmentId,
type: d.type,
fromDate: new Date(d.fromDate),
toDate: new Date(d.toDate),
reason: d.reason ?? null,
appliedById: g.userId,
},
});
await db.crewAction.create({ data: { actionType: "LEAVE_APPLIED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "LEAVE_FOR_APPROVAL",
recipients: managers,
subject: `Leave for approval — ${assignment.crewMember.name}`,
body: `${assignment.crewMember.name} (${assignment.rank.name}) has a leave request from ${d.fromDate} to ${d.toDate} awaiting your decision.`,
link: LEAVE_PATH,
});
revalidate();
return { ok: true, id: leave.id };
}
// ── Decide leave (Manager) ─────────────────────────────────────────────────────
// On approval the assignment goes ON_LEAVE and a clash check runs; if it would
// leave the vessel with no same-rank cover, a LEAVE requisition is auto-raised.
export async function decideLeave(id: string, approve: boolean, note?: string): Promise<ActionResult> {
const g = await guard("decide_leave");
if ("error" in g) return g;
const leave = await db.leaveRequest.findUnique({
where: { id },
include: { assignment: { select: { id: true, crewMemberId: true, rankId: true, vesselId: true, siteId: true } } },
});
if (!leave) return { error: "Leave request not found" };
if (leave.status !== "APPLIED") return { error: `This leave request is already ${leave.status}` };
if (!approve && !note?.trim()) return { error: "A reason is required to decline" };
if (!approve) {
await db.leaveRequest.update({ where: { id }, data: { status: "REJECTED", decidedById: g.userId, decidedAt: new Date(), reason: note?.trim() || leave.reason } });
await db.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, note: note?.trim() || null, metadata: { decision: "REJECTED" } } });
revalidate();
return { ok: true };
}
const { clash } = await db.$transaction(async (tx) => {
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, {
assignmentId: leave.assignment.id,
rankId: leave.assignment.rankId,
vesselId: leave.assignment.vesselId,
fromDate: leave.fromDate,
toDate: leave.toDate,
});
return { clash };
});
// A detected clash auto-raises a LEAVE requisition (reuses the Phase-2 helper).
if (clash) {
await autoRaiseRequisition({
rankId: leave.assignment.rankId,
vesselId: leave.assignment.vesselId,
siteId: leave.assignment.siteId,
reason: "LEAVE",
});
}
revalidate();
return { ok: true };
}