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>
46 lines
2 KiB
TypeScript
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 };
|
|
}
|