From aac31c6755fc7f8219df25972f8ad9a8a514c6ed Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 21:07:15 +0530 Subject: [PATCH] =?UTF-8?q?feat(crewing):=20Phase=204b=20=E2=80=94=20leave?= =?UTF-8?q?=20&=20attendance=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 9 + .../(portal)/approvals/crewing-approvals.tsx | 22 ++- App/app/(portal)/approvals/page.tsx | 27 ++- .../(portal)/crewing/attendance/actions.ts | 46 +++++ .../attendance/attendance-calendar.tsx | 169 ++++++++++++++++++ App/app/(portal)/crewing/attendance/page.tsx | 46 +++++ App/app/(portal)/crewing/leave/actions.ts | 139 ++++++++++++++ .../(portal)/crewing/leave/leave-manager.tsx | 163 +++++++++++++++++ App/app/(portal)/crewing/leave/page.tsx | 52 ++++++ App/components/layout/sidebar.tsx | 4 + App/lib/leave-clash.ts | 44 +++++ App/lib/notifier.ts | 4 +- .../migration.sql | 61 +++++++ App/prisma/schema.prisma | 66 +++++++ .../integration/leave-attendance.test.ts | 140 +++++++++++++++ 15 files changed, 979 insertions(+), 13 deletions(-) create mode 100644 App/app/(portal)/crewing/attendance/actions.ts create mode 100644 App/app/(portal)/crewing/attendance/attendance-calendar.tsx create mode 100644 App/app/(portal)/crewing/attendance/page.tsx create mode 100644 App/app/(portal)/crewing/leave/actions.ts create mode 100644 App/app/(portal)/crewing/leave/leave-manager.tsx create mode 100644 App/app/(portal)/crewing/leave/page.tsx create mode 100644 App/lib/leave-clash.ts create mode 100644 App/prisma/migrations/20260622152712_crewing_leave_attendance/migration.sql create mode 100644 App/tests/integration/leave-attendance.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 3906218..f2679e9 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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`. diff --git a/App/app/(portal)/approvals/crewing-approvals.tsx b/App/app/(portal)/approvals/crewing-approvals.tsx index 22ff719..55096f6 100644 --- a/App/app/(portal)/approvals/crewing-approvals.tsx +++ b/App/app/(portal)/approvals/crewing-approvals.tsx @@ -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 = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" }; -const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const; +const KIND_LABEL: Record = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave" }; +const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning" } as const; const approveFn: Record Promise<{ ok: true } | { error: string }>> = { SALARY: approveSalary, SELECTION: selectCandidate, WAIVER: approveInterviewWaiver, + LEAVE: (id) => decideLeave(id, true), }; const returnFn: Record 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 }) { {KIND_LABEL[item.kind]} - {item.candidateName} + {item.candidateName} {item.rank} · {item.requisitionCode} {item.detail} @@ -104,7 +108,7 @@ export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) { - {items.map((item) => )} + {items.map((item) => )} diff --git a/App/app/(portal)/approvals/page.tsx b/App/app/(portal)/approvals/page.tsx index 27faa98..6b3e85f 100644 --- a/App/app/(portal)/approvals/page.tsx +++ b/App/app/(portal)/approvals/page.tsx @@ -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 (
@@ -183,7 +204,7 @@ export default async function ApprovalsPage({ searchParams }: Props) { )} - {showCrewing && crewItems.length > 0 && } + {showCrewing && allCrewItems.length > 0 && }
); } diff --git a/App/app/(portal)/crewing/attendance/actions.ts b/App/app/(portal)/crewing/attendance/actions.ts new file mode 100644 index 0000000..a7f3890 --- /dev/null +++ b/App/app/(portal)/crewing/attendance/actions.ts @@ -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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/attendance/attendance-calendar.tsx b/App/app/(portal)/crewing/attendance/attendance-calendar.tsx new file mode 100644 index 0000000..8f6339b --- /dev/null +++ b/App/app/(portal)/crewing/attendance/attendance-calendar.tsx @@ -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 }; + +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 = { + 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 = { 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>>({}); + 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 ( +
+

Attendance

+

No active crew to mark attendance for.

+
+ ); + } + + return ( +
+
+

Attendance

+ {canEdit && ( + + )} +
+ +
+ +
+ + {MONTHS[m]} {y} + +
+
+ + {unmarkedToDate > 0 && ( +
{unmarkedToDate} day{unmarkedToDate === 1 ? "" : "s"} still need marking.
+ )} + +
+ {([["Present", summary.present], ["Absent", summary.absent], ["On leave", summary.leave]] as const).map(([k, v]) => ( +
+

{v}

+

{k}

+
+ ))} +
+ +
+
+ {["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map((d) =>
{d}
)} +
+
+ {Array.from({ length: firstWeekday }).map((_, i) =>
)} + {days.map((d) => { + const date = iso(y, m, d); + const s = statusOf(date); + return ( + + ); + })} +
+
+ Present + Absent + Leave + Half day +
+
+ {!canEdit &&

View only — attendance is marked by site staff.

} +
+ ); +} diff --git a/App/app/(portal)/crewing/attendance/page.tsx b/App/app/(portal)/crewing/attendance/page.tsx new file mode 100644 index 0000000..fa00beb --- /dev/null +++ b/App/app/(portal)/crewing/attendance/page.tsx @@ -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 ( + ({ + 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")} + /> + ); +} diff --git a/App/app/(portal)/crewing/leave/actions.ts b/App/app/(portal)/crewing/leave/actions.ts new file mode 100644 index 0000000..227f8f1 --- /dev/null +++ b/App/app/(portal)/crewing/leave/actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/leave/leave-manager.tsx b/App/app/(portal)/crewing/leave/leave-manager.tsx new file mode 100644 index 0000000..8e60a2b --- /dev/null +++ b/App/app/(portal)/crewing/leave/leave-manager.tsx @@ -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 = { + 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 ( +
+
+
+

Leave

+

Site staff apply on behalf of crew · the Manager approves.

+
+ {canApply && } +
+ +
+ + + + + + + + + + + + + {requests.length === 0 ? ( + + ) : requests.map((r) => ( + router.refresh()} /> + ))} + +
CrewRank / locationTypeDatesStatus
No leave requests.
+
+ + setOpen(false)}> +
+
+ + +
+
+
+ + +
+
+
+ + setF({ ...f, fromDate: e.target.value })} required /> +
+
+ + setF({ ...f, toDate: e.target.value })} required /> +
+
+ {duration > 0 &&

{duration} day{duration === 1 ? "" : "s"} of leave.

} +
+ + setF({ ...f, reason: e.target.value })} placeholder="Optional" /> +
+ {error &&

{error}

} +
+ + +
+
+
+
+ ); +} + +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 ( + + {r.crewName} + {r.rank} · {r.location} + {label(r.type)} + {fmt(r.fromDate)} – {fmt(r.toDate)} + {label(r.status)} + + {r.status === "APPLIED" && (canDecide ? ( +
+ + +
+ ) : Awaiting manager)} + {error &&

{error}

} + setDeclineOpen(false)}> +
+