pelagia-portal/App/app/(portal)/crewing/leave/leave-manager.tsx
Hardik aac31c6755 feat(crewing): Phase 4b — leave & attendance (flagged)
Second slice of Phase 4 (stacked on 4a crew records). Leave (site-applied,
Manager-decided) with clash auto-backfill, and the daily attendance calendar,
per Crewing-Implementation-Spec §5.3/§8.9–8.10. Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Schema (crewing_leave_attendance migration): LeaveRequest (LeaveType,
  LeaveStatus) + Attendance (AttendanceStatus, unique per assignment+date) on
  CrewAssignment; CrewActionType += LEAVE_APPLIED/LEAVE_DECIDED/ATTENDANCE_RECORDED.
- Leave (R1): site staff apply on behalf (apply_leave); Manager decides
  (decide_leave) → assignment ON_LEAVE; MPO has no leave role. Leave approvals also
  surface in the central /approvals queue (§8.13 Leave kind). Notification
  LEAVE_FOR_APPROVAL.
- Clash auto-backfill (R6): lib/leave-clash.ts, required strength = 1 — approving a
  leave that leaves the vessel with zero active same-rank cover auto-raises a LEAVE
  requisition via the Phase-2 autoRaiseRequisition.
- Attendance (R5): daily month calendar; site staff record (record_attendance),
  Manager views (view_attendance) but cannot edit, MPO neither. saveAttendance
  bulk-upserts dirty cells.
- Screens: /crewing/leave (apply-on-behalf + Manager Approve/Decline) and
  /crewing/attendance (tap-to-cycle calendar + Save). Leave + Attendance added to
  the flag-gated nav (Manager + Site staff).

Tests & docs
- Integration: leave-attendance.test.ts (7) — apply/decide, clash auto-raise (and
  no-raise when cover remains), MPO/Manager attendance lockout, permission gating.
  type-check clean; full unit (240) + integration (182) green.
- CLAUDE.md updated with the Phase 4b surface.

Deferred: the 6-month leave-planner timeline (lightweight list for now); hours/
overtime attendance (A7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:07:15 +05:30

163 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}