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:
parent
37b1debc9d
commit
aac31c6755
15 changed files with 979 additions and 13 deletions
|
|
@ -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.9–8.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`.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
46
App/app/(portal)/crewing/attendance/actions.ts
Normal file
46
App/app/(portal)/crewing/attendance/actions.ts
Normal 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 };
|
||||
}
|
||||
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal file
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
App/app/(portal)/crewing/attendance/page.tsx
Normal file
46
App/app/(portal)/crewing/attendance/page.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
139
App/app/(portal)/crewing/leave/actions.ts
Normal file
139
App/app/(portal)/crewing/leave/actions.ts
Normal 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 };
|
||||
}
|
||||
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal file
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
App/app/(portal)/crewing/leave/page.tsx
Normal file
52
App/app/(portal)/crewing/leave/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
44
App/lib/leave-clash.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
140
App/tests/integration/leave-attendance.test.ts
Normal file
140
App/tests/integration/leave-attendance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue