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>
This commit is contained in:
Hardik 2026-06-22 21:07:15 +05:30
parent 37b1debc9d
commit aac31c6755
15 changed files with 979 additions and 13 deletions

View file

@ -166,6 +166,15 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Screens:** `/crewing/crew` (directory — active `EMPLOYEE` crew, search + vessel filter; ex-hands excluded) and `/crewing/crew/[id]` (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). **Crew** added to the flag-gated nav (MGR/MPO/Site/Accounts).
- **Deferred:** site-staff **own-site scoping** (needs a User↔Site link, not modelled — all crew show for now); the records **verify queue** (§8.11, Phase 5); the Pay-status tab shows the salary structure only until wage reports (Phase 6).
**Phase 4b — Leave & attendance (Epic G; spec §5.3/§8.98.10):**
- **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.)
- **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.
### 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`.

View file

@ -13,30 +13,34 @@ import {
approveInterviewWaiver,
declineInterviewWaiver,
} from "../crewing/applications/actions";
import { decideLeave } from "../crewing/leave/actions";
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER";
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE";
export type CrewApprovalItem = {
applicationId: string;
id: string; // applicationId, or leaveRequestId for LEAVE
kind: CrewApprovalKind;
candidateName: string;
rank: string;
requisitionCode: string;
detail: string; // amount for salary, etc.
detail: string;
link: string;
};
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning" } as const;
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary,
SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver,
LEAVE: (id) => decideLeave(id, true),
};
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: returnSalary,
SELECTION: returnSelection,
WAIVER: declineInterviewWaiver,
LEAVE: (id, reason) => decideLeave(id, false, reason),
};
function Row({ item }: { item: CrewApprovalItem }) {
@ -48,14 +52,14 @@ function Row({ item }: { item: CrewApprovalItem }) {
async function approve() {
setPending(true); setError("");
const res = await approveFn[item.kind](item.applicationId);
const res = await approveFn[item.kind](item.id);
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function doReturn(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await returnFn[item.kind](item.applicationId, reason);
const res = await returnFn[item.kind](item.id, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
}
@ -64,7 +68,7 @@ function Row({ item }: { item: CrewApprovalItem }) {
<tr className="hover:bg-neutral-50">
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3">
<Link href={`/crewing/applications/${item.applicationId}`} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
@ -104,7 +108,7 @@ export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.applicationId}`} item={item} />)}
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)}
</tbody>
</table>
</div>

View file

@ -58,7 +58,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver"));
hasPermission(role, "approve_interview_waiver") ||
hasPermission(role, "decide_leave"));
const crewGates = showCrewing
? await db.applicationGate.findMany({
@ -85,15 +86,35 @@ export default async function ApprovalsPage({ searchParams }: Props) {
? "Returning crew — interview waiver"
: "Interview cleared";
return {
applicationId: g.applicationId,
id: g.applicationId,
kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code,
detail,
link: `/crewing/applications/${g.applicationId}`,
};
});
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
? (await db.leaveRequest.findMany({
where: { status: "APPLIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((l) => ({
id: l.id,
kind: "LEAVE" as CrewApprovalKind,
candidateName: l.assignment.crewMember.name,
rank: l.assignment.rank.name,
requisitionCode: `${l.fromDate.toLocaleDateString()}${l.toDate.toLocaleDateString()}`,
detail: l.type.toLowerCase(),
link: "/crewing/leave",
}))
: [];
const allCrewItems = [...crewItems, ...leaveItems];
return (
<div>
<div className="mb-4">
@ -183,7 +204,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</>
)}
{showCrewing && crewItems.length > 0 && <CrewingApprovals items={crewItems} />}
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
</div>
);
}

View file

@ -0,0 +1,46 @@
"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 };
}

View file

@ -0,0 +1,169 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { AttendanceStatus } from "@prisma/client";
import { cn } from "@/lib/utils";
import { saveAttendance } from "./actions";
type Assignment = { id: string; crewName: string; rank: string; location: string; marks: Record<string, AttendanceStatus> };
const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
// Tap cycle (§8.10): Unmarked → Present → Absent → Leave → Half day → Unmarked.
const CYCLE: (AttendanceStatus | null)[] = [null, "PRESENT", "ABSENT", "ON_LEAVE", "HALF_DAY"];
const next = (s: AttendanceStatus | null) => CYCLE[(CYCLE.indexOf(s ?? null) + 1) % CYCLE.length];
const CELL: Record<AttendanceStatus, string> = {
PRESENT: "bg-success-100 text-success-700 border-success-200",
ABSENT: "bg-danger-100 text-danger-700 border-danger-200",
ON_LEAVE: "bg-warning-100 text-warning-700 border-warning-200",
HALF_DAY: "bg-primary-100 text-primary-700 border-primary-200",
SIGN_OFF: "bg-neutral-200 text-neutral-600 border-neutral-300",
};
const ABBR: Record<AttendanceStatus, string> = { PRESENT: "P", ABSENT: "A", ON_LEAVE: "L", HALF_DAY: "½", SIGN_OFF: "S" };
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const iso = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
export function AttendanceCalendar({ assignments, canEdit }: { assignments: Assignment[]; canEdit: boolean }) {
const router = useRouter();
const today = new Date();
const [selectedId, setSelectedId] = useState(assignments[0]?.id ?? "");
const [y, setY] = useState(today.getFullYear());
const [m, setM] = useState(today.getMonth());
const [edits, setEdits] = useState<Record<string, Record<string, AttendanceStatus | null>>>({});
const [pending, setPending] = useState(false);
const selected = assignments.find((a) => a.id === selectedId) ?? null;
const myEdits = edits[selectedId] ?? {};
const statusOf = (date: string): AttendanceStatus | null => {
if (date in myEdits) return myEdits[date];
return selected?.marks[date] ?? null;
};
const daysInMonth = new Date(y, m + 1, 0).getDate();
const firstWeekday = new Date(y, m, 1).getDay();
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
const summary = useMemo(() => {
let present = 0, absent = 0, leave = 0;
for (const d of days) {
const s = (date => (date in myEdits ? myEdits[date] : selected?.marks[date] ?? null))(iso(y, m, d));
if (s === "PRESENT") present++; else if (s === "ABSENT") absent++; else if (s === "ON_LEAVE") leave++;
}
return { present, absent, leave };
}, [days, myEdits, selected, y, m]);
const unmarkedToDate = useMemo(() => {
const isCurrentOrPast = y < today.getFullYear() || (y === today.getFullYear() && m <= today.getMonth());
if (!isCurrentOrPast) return 0;
const lastDay = (y === today.getFullYear() && m === today.getMonth()) ? today.getDate() : daysInMonth;
let n = 0;
for (let d = 1; d <= lastDay; d++) if (statusOf(iso(y, m, d)) === null) n++;
return n;
}, [y, m, daysInMonth, myEdits, selected]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = Object.keys(myEdits).length > 0;
function cycleDay(date: string) {
if (!canEdit) return;
setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] ?? {}), [date]: next(statusOf(date)) } }));
}
function shiftMonth(delta: number) {
const nm = m + delta;
if (nm < 0) { setM(11); setY(y - 1); } else if (nm > 11) { setM(0); setY(y + 1); } else setM(nm);
}
async function save() {
setPending(true);
// Null edits (cleared cells) are skipped — clearing a saved mark isn't supported here.
const marks = Object.entries(myEdits).filter(([, s]) => s !== null).map(([date, status]) => ({ date, status: status as AttendanceStatus }));
const res = await saveAttendance(selectedId, marks);
setPending(false);
if ("ok" in res) { setEdits((e) => ({ ...e, [selectedId]: {} })); router.refresh(); }
}
if (assignments.length === 0) {
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-2">Attendance</h1>
<p className="text-neutral-400">No active crew to mark attendance for.</p>
</div>
);
}
return (
<div className="max-w-3xl">
<div className="mb-5 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Attendance</h1>
{canEdit && (
<button onClick={save} disabled={!dirty || pending} className={cn("rounded-lg px-4 py-2 text-sm font-semibold text-white", dirty ? "bg-primary-600 hover:bg-primary-700" : "bg-neutral-300", "disabled:opacity-60")}>
{pending ? "Saving…" : "Save"}
</button>
)}
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select className={INPUT} value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
</select>
<div className="flex items-center gap-2">
<button onClick={() => shiftMonth(-1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronLeft className="h-4 w-4" /></button>
<span className="text-sm font-medium text-neutral-800 w-36 text-center">{MONTHS[m]} {y}</span>
<button onClick={() => shiftMonth(1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronRight className="h-4 w-4" /></button>
</div>
</div>
{unmarkedToDate > 0 && (
<div className="mb-4 rounded-lg border border-warning-200 bg-warning-50 px-4 py-2 text-sm text-warning-800">{unmarkedToDate} day{unmarkedToDate === 1 ? "" : "s"} still need marking.</div>
)}
<div className="mb-4 grid grid-cols-3 gap-3">
{([["Present", summary.present], ["Absent", summary.absent], ["On leave", summary.leave]] as const).map(([k, v]) => (
<div key={k} className="rounded-lg border border-neutral-200 bg-white p-3 text-center">
<p className="text-2xl font-semibold text-neutral-900">{v}</p>
<p className="text-xs text-neutral-500">{k}</p>
</div>
))}
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<div className="grid grid-cols-7 gap-1 mb-1 text-center text-xs font-medium text-neutral-400">
{["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map((d) => <div key={d}>{d}</div>)}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstWeekday }).map((_, i) => <div key={`pad${i}`} />)}
{days.map((d) => {
const date = iso(y, m, d);
const s = statusOf(date);
return (
<button
key={d}
onClick={() => cycleDay(date)}
disabled={!canEdit}
className={cn(
"aspect-square rounded-md border text-sm flex flex-col items-center justify-center",
s ? CELL[s] : "border-dashed border-neutral-200 text-neutral-400",
canEdit ? "hover:ring-2 hover:ring-primary-200 cursor-pointer" : "cursor-default"
)}
>
<span className="text-[11px] leading-none">{d}</span>
{s && <span className="text-xs font-semibold leading-none mt-0.5">{ABBR[s]}</span>}
</button>
);
})}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-neutral-500">
<span><span className="inline-block w-3 h-3 rounded bg-success-100 border border-success-200 align-middle" /> Present</span>
<span><span className="inline-block w-3 h-3 rounded bg-danger-100 border border-danger-200 align-middle" /> Absent</span>
<span><span className="inline-block w-3 h-3 rounded bg-warning-100 border border-warning-200 align-middle" /> Leave</span>
<span><span className="inline-block w-3 h-3 rounded bg-primary-100 border border-primary-200 align-middle" /> Half day</span>
</div>
</div>
{!canEdit && <p className="mt-3 text-xs text-neutral-400">View only attendance is marked by site staff.</p>}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { AttendanceCalendar } from "./attendance-calendar";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Attendance" };
export default async function AttendancePage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_attendance")) redirect("/dashboard"); // MPO has no attendance (R5)
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 4);
const assignments = await db.crewAssignment.findMany({
where: { status: { not: "SIGNED_OFF" } },
orderBy: { crewMember: { name: "asc" } },
include: {
crewMember: { select: { name: true } },
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
attendance: { where: { date: { gte: cutoff } }, select: { date: true, status: true } },
},
});
return (
<AttendanceCalendar
assignments={assignments.map((a) => ({
id: a.id,
crewName: a.crewMember.name,
rank: a.rank.name,
location: a.vessel?.name ?? a.site?.name ?? "—",
marks: Object.fromEntries(a.attendance.map((m) => [m.date.toISOString().slice(0, 10), m.status])),
}))}
canEdit={hasPermission(role, "record_attendance")}
/>
);
}

View file

@ -0,0 +1,139 @@
"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 };
}

View file

@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { LeaveStatus, LeaveType } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { applyLeave, decideLeave } from "./actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const LEAVE_TYPES: LeaveType[] = ["ANNUAL", "MEDICAL", "EMERGENCY", "UNPAID", "OTHER"];
const fmt = (iso: string) => new Date(iso).toLocaleDateString();
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
type Assignment = { id: string; crewName: string; rank: string; location: string };
type Request = { id: string; crewName: string; rank: string; location: string; type: LeaveType; status: LeaveStatus; fromDate: string; toDate: string; reason: string | null };
const STATUS_VARIANT: Record<LeaveStatus, "warning" | "success" | "danger" | "secondary"> = {
APPLIED: "warning", APPROVED: "success", REJECTED: "danger", CANCELLED: "secondary",
};
export function LeaveManager({ assignments, requests, canApply, canDecide }: { assignments: Assignment[]; requests: Request[]; canApply: boolean; canDecide: boolean }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [f, setF] = useState({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" });
const duration = f.fromDate && f.toDate ? Math.max(0, Math.round((new Date(f.toDate).getTime() - new Date(f.fromDate).getTime()) / 86400000) + 1) : 0;
async function submitApply(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
const res = await applyLeave(fd);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); setF({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" }); router.refresh(); }
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Leave</h1>
<p className="text-sm text-neutral-500 mt-0.5">Site staff apply on behalf of crew · the Manager approves.</p>
</div>
{canApply && <button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">Apply for leave</button>}
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th>
<th className="px-4 py-3">Rank / location</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Dates</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{requests.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-neutral-400">No leave requests.</td></tr>
) : requests.map((r) => (
<DecisionRow key={r.id} r={r} canDecide={canDecide} onDone={() => router.refresh()} />
))}
</tbody>
</table>
</div>
<AdminDialog title="Apply for leave" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submitApply} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Crew member *</label>
<select className={INPUT} value={f.assignmentId} onChange={(e) => setF({ ...f, assignmentId: e.target.value })} required>
<option value=""> Select crew </option>
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Type</label>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
{LEAVE_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
</select>
</div>
<div></div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">From *</label>
<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">To *</label>
<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} required />
</div>
</div>
{duration > 0 && <p className="text-xs text-neutral-500 bg-neutral-50 rounded-md px-3 py-2">{duration} day{duration === 1 ? "" : "s"} of leave.</p>}
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<input className={INPUT} value={f.reason} onChange={(e) => setF({ ...f, reason: e.target.value })} placeholder="Optional" />
</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="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.assignmentId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Applying…" : "Apply"}</button>
</div>
</form>
</AdminDialog>
</div>
);
}
function DecisionRow({ r, canDecide, onDone }: { r: Request; canDecide: boolean; onDone: () => void }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [declineOpen, setDeclineOpen] = useState(false);
const [reason, setReason] = useState("");
async function approve() {
setPending(true); setError("");
const res = await decideLeave(r.id, true);
setPending(false);
if ("error" in res) setError(res.error); else onDone();
}
async function decline(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await decideLeave(r.id, false, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setDeclineOpen(false); onDone(); }
}
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{r.rank} · {r.location}</td>
<td className="px-4 py-3 text-neutral-600">{label(r.type)}</td>
<td className="px-4 py-3 text-neutral-600">{fmt(r.fromDate)} {fmt(r.toDate)}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[r.status]}>{label(r.status)}</Badge></td>
<td className="px-4 py-3 text-right">
{r.status === "APPLIED" && (canDecide ? (
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setDeclineOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Decline</button>
</div>
) : <span className="text-xs text-neutral-400">Awaiting manager</span>)}
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title="Decline leave" open={declineOpen} onClose={() => setDeclineOpen(false)}>
<form onSubmit={decline} className="space-y-4 text-left">
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setDeclineOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Decline</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}

View file

@ -0,0 +1,52 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { LeaveManager } from "./leave-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Leave" };
export default async function LeavePage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
const canApply = hasPermission(role, "apply_leave");
const canDecide = hasPermission(role, "decide_leave");
if (!canApply && !canDecide) redirect("/dashboard"); // MPO has no leave screen (R1)
const [assignments, requests] = await Promise.all([
db.crewAssignment.findMany({
where: { status: { not: "SIGNED_OFF" } },
orderBy: { crewMember: { name: "asc" } },
include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
}),
db.leaveRequest.findMany({
orderBy: { createdAt: "desc" },
take: 100,
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } } },
}),
]);
return (
<LeaveManager
assignments={assignments.map((a) => ({ id: a.id, crewName: a.crewMember.name, rank: a.rank.name, location: a.vessel?.name ?? a.site?.name ?? "—" }))}
requests={requests.map((r) => ({
id: r.id,
crewName: r.assignment.crewMember.name,
rank: r.assignment.rank.name,
location: r.assignment.vessel?.name ?? r.assignment.site?.name ?? "—",
type: r.type,
status: r.status,
fromDate: r.fromDate.toISOString(),
toDate: r.toDate.toISOString(),
reason: r.reason,
}))}
canApply={canApply}
canDecide={canDecide}
/>
);
}

View file

@ -28,6 +28,8 @@ import {
ClipboardList,
UserSearch,
Contact,
CalendarDays,
CalendarCheck,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -81,6 +83,8 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
]
: [];

44
App/lib/leave-clash.ts Normal file
View file

@ -0,0 +1,44 @@
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.
interface ClashInput {
assignmentId: string;
rankId: string;
vesselId: string | null;
fromDate: Date;
toDate: Date;
}
export async function leaveLeavesNoCover(
tx: Prisma.TransactionClient,
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
): Promise<boolean> {
// No vessel cost axis → no rank-cover check.
if (!vesselId) return false;
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;
}

View file

@ -32,7 +32,8 @@ export type CrewNotificationEvent =
| "CANDIDATE_PROPOSED"
| "SALARY_FOR_APPROVAL"
| "SELECTION_FOR_APPROVAL"
| "WAIVER_REQUESTED";
| "WAIVER_REQUESTED"
| "LEAVE_FOR_APPROVAL";
interface NotifyParams {
event: NotificationEvent;
@ -433,6 +434,7 @@ const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
SALARY_FOR_APPROVAL: "Review Salary",
SELECTION_FOR_APPROVAL: "Review Selection",
WAIVER_REQUESTED: "Review Waiver",
LEAVE_FOR_APPROVAL: "Review Leave",
};
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {

View file

@ -0,0 +1,61 @@
-- CreateEnum
CREATE TYPE "LeaveType" AS ENUM ('ANNUAL', 'MEDICAL', 'EMERGENCY', 'UNPAID', 'OTHER');
-- CreateEnum
CREATE TYPE "LeaveStatus" AS ENUM ('APPLIED', 'APPROVED', 'REJECTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "AttendanceStatus" AS ENUM ('PRESENT', 'ABSENT', 'HALF_DAY', 'ON_LEAVE', 'SIGN_OFF');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'LEAVE_APPLIED';
ALTER TYPE "CrewActionType" ADD VALUE 'LEAVE_DECIDED';
ALTER TYPE "CrewActionType" ADD VALUE 'ATTENDANCE_RECORDED';
-- CreateTable
CREATE TABLE "LeaveRequest" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"type" "LeaveType" NOT NULL DEFAULT 'ANNUAL',
"fromDate" TIMESTAMP(3) NOT NULL,
"toDate" TIMESTAMP(3) NOT NULL,
"reason" TEXT,
"status" "LeaveStatus" NOT NULL DEFAULT 'APPLIED',
"appliedById" TEXT NOT NULL,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attendance" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"date" DATE NOT NULL,
"status" "AttendanceStatus" NOT NULL,
"note" TEXT,
"recordedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Attendance_assignmentId_date_key" ON "Attendance"("assignmentId", "date");
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -151,6 +151,36 @@ enum CrewActionType {
PPE_ISSUED
PPE_RETURNED
EXPERIENCE_ADDED
LEAVE_APPLIED
LEAVE_DECIDED
ATTENDANCE_RECORDED
}
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
// Leave is applied by the Site In-charge on a crew member and decided by the
// Manager (the MPO has no leave role — R1). See Crewing-Data-Model §1/§4.
enum LeaveType {
ANNUAL
MEDICAL
EMERGENCY
UNPAID
OTHER
}
enum LeaveStatus {
APPLIED
APPROVED
REJECTED
CANCELLED
}
// Daily attendance (§8.10). v1 is the daily model; hours/overtime is deferred (A7).
enum AttendanceStatus {
PRESENT
ABSENT
HALF_DAY
ON_LEAVE
SIGN_OFF
}
// PPE kit items issued to crew (Phase 4a, Epic F). See Crewing-Data-Model §1.
@ -882,6 +912,42 @@ model CrewAssignment {
salaryStructures SalaryStructure[]
contractLetter ContractLetter?
leaveRequests LeaveRequest[]
attendance Attendance[]
}
// Leave applied by the Site In-charge on a crew member's assignment, decided by
// the Manager (§8.9, R1). Actor ids are denormalised strings — the audited actor
// lives on the CrewAction.
model LeaveRequest {
id String @id @default(cuid())
assignmentId String
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
type LeaveType @default(ANNUAL)
fromDate DateTime
toDate DateTime
reason String?
status LeaveStatus @default(APPLIED)
appliedById String
decidedById String?
decidedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// One attendance mark per assignment per day (§8.10). Site staff + Manager only.
model Attendance {
id String @id @default(cuid())
assignmentId String
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
date DateTime @db.Date
status AttendanceStatus
note String?
recordedById String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assignmentId, date])
}
// The signed contract for an assignment. `salaryRestricted` hides salary from

View file

@ -0,0 +1,140 @@
/**
* Integration tests for Crewing Phase 4b leave & attendance: apply/decide leave
* (Manager), the clash auto-backfill (required strength = 1), and attendance
* recording with MPO/Manager lockout.
*/
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 { applyLeave, decideLeave } from "@/app/(portal)/crewing/leave/actions";
import { saveAttendance } from "@/app/(portal)/crewing/attendance/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let manningId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itla.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function makeAssignment(name: string, rId = rankId) {
const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
return db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId } });
}
async function applyAndGetId(assignmentId: string, from = "2026-07-01", to = "2026-07-10") {
as(siteStaffId, "SITE_STAFF");
const res = await applyLeave(fd({ assignmentId, type: "ANNUAL", fromDate: from, toDate: to }));
if (!("ok" in res)) throw new Error("applyLeave failed");
return res.id!;
}
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
manningId = (await getSeedUser("manning@pelagia.local")).id;
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITLA-SS", email: SS_EMAIL, name: "SS LA", role: "SITE_STAFF" } });
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
vesselId = (await db.vessel.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.attendance.deleteMany({});
await db.leaveRequest.deleteMany({});
await db.crewAssignment.deleteMany({});
await db.requisition.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("apply / decide leave", () => {
it("site staff apply, Manager approves → assignment ON_LEAVE", async () => {
const a = await makeAssignment("Solo Crew");
const leaveId = await applyAndGetId(a.id);
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPLIED");
as(managerId, "MANAGER");
expect("ok" in (await decideLeave(leaveId, true))).toBe(true);
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPROVED");
expect((await db.crewAssignment.findUniqueOrThrow({ where: { id: a.id } })).status).toBe("ON_LEAVE");
});
it("apply is rejected for the MPO (no apply_leave)", async () => {
const a = await makeAssignment("X");
as(manningId, "MANNING");
expect(await applyLeave(fd({ assignmentId: a.id, fromDate: "2026-07-01", toDate: "2026-07-02" }))).toEqual({ error: "Unauthorized" });
});
it("decline requires a reason and is Manager-only", async () => {
const a = await makeAssignment("Y");
const leaveId = await applyAndGetId(a.id);
as(managerId, "MANAGER");
expect("error" in (await decideLeave(leaveId, false, " "))).toBe(true);
as(siteStaffId, "SITE_STAFF");
expect(await decideLeave(leaveId, false, "no")).toEqual({ error: "Unauthorized" });
as(managerId, "MANAGER");
expect("ok" in (await decideLeave(leaveId, false, "Operational needs"))).toBe(true);
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("REJECTED");
});
});
describe("clash auto-backfill (required strength = 1)", () => {
it("auto-raises a LEAVE requisition when the only same-rank cover goes on leave", async () => {
const a = await makeAssignment("Only One");
const leaveId = await applyAndGetId(a.id);
as(managerId, "MANAGER");
await decideLeave(leaveId, true);
const req = await db.requisition.findFirst({ where: { autoRaised: true } });
expect(req).not.toBeNull();
expect(req!.reason).toBe("LEAVE");
expect(req!.rankId).toBe(rankId);
expect(req!.vesselId).toBe(vesselId);
});
it("does NOT auto-raise when another active same-rank crew remains", async () => {
const a = await makeAssignment("Going On Leave");
await makeAssignment("Stays Active"); // same rank + vessel, active
const leaveId = await applyAndGetId(a.id);
as(managerId, "MANAGER");
await decideLeave(leaveId, true);
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
});
});
describe("attendance", () => {
it("site staff record attendance (upsert)", async () => {
const a = await makeAssignment("Marked");
as(siteStaffId, "SITE_STAFF");
expect("ok" in (await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }, { date: "2026-07-02", status: "ABSENT" }]))).toBe(true);
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2);
// Re-saving the same day updates rather than duplicating.
await saveAttendance(a.id, [{ date: "2026-07-01", status: "HALF_DAY" }]);
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2);
expect((await db.attendance.findFirstOrThrow({ where: { assignmentId: a.id, status: "HALF_DAY" } })).status).toBe("HALF_DAY");
});
it("the MPO and the Manager cannot record attendance (R5/§6)", async () => {
const a = await makeAssignment("NoMark");
as(manningId, "MANNING");
expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" });
as(managerId, "MANAGER");
expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" });
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(0);
});
});