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>
163 lines
9.1 KiB
TypeScript
163 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|