pelagia-portal/App/app/(portal)/crewing/attendance/attendance-calendar.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

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