pelagia-portal/App/app/(portal)/crewing/attendance/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

46 lines
2 KiB
TypeScript

"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { AttendanceStatus } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const markSchema = z.object({ date: z.string().min(1), status: z.nativeEnum(AttendanceStatus) });
// Bulk-save the dirty cells from the month calendar (Site staff). One upsert per
// (assignment, date); a single ATTENDANCE_RECORDED audit row per save.
export async function saveAttendance(assignmentId: string, marks: { date: string; status: AttendanceStatus }[]): Promise<ActionResult> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "record_attendance")) return { error: "Unauthorized" };
if (!assignmentId) return { error: "Crew member is required" };
const parsed = z.array(markSchema).max(40).safeParse(marks);
if (!parsed.success) return { error: "Invalid attendance data" };
if (parsed.data.length === 0) return { ok: true };
const assignment = await db.crewAssignment.findUnique({ where: { id: assignmentId }, select: { crewMemberId: true } });
if (!assignment) return { error: "Crew assignment not found" };
await db.$transaction(
parsed.data.map((m) =>
db.attendance.upsert({
where: { assignmentId_date: { assignmentId, date: new Date(m.date) } },
update: { status: m.status, recordedById: session.user.id },
create: { assignmentId, date: new Date(m.date), status: m.status, recordedById: session.user.id },
})
)
);
await db.crewAction.create({
data: { actionType: "ATTENDANCE_RECORDED", actorId: session.user.id, crewMemberId: assignment.crewMemberId, metadata: { count: parsed.data.length } },
});
revalidatePath("/crewing/attendance");
return { ok: true };
}