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>
169 lines
8.5 KiB
TypeScript
169 lines
8.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|