feat(crewing): Phase 4c — sign-off & experience (flagged)

Final slice of Phase 4 (the Epic K piece deferred from Phase 2). Ends a tour of
duty and returns the crew member to the candidate pool as an ex-hand. Per
Crewing-Implementation-Spec §5.3. Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Schema: CrewActionType += CREW_SIGNED_OFF (migration crewing_signoff).
- signOffCrew(assignmentId, date, remarks) (crewing/crew/actions.ts, sign_off_crew):
  one transaction — assignment → SIGNED_OFF (+ signOffDate); append an internal
  ExperienceRecord (rank, on/off dates, computed durationMonths); flip the SAME
  CrewMember EMPLOYEE → EX_HAND (type/source EX_HAND), so they reappear in
  Candidates as a returning hand; CrewAction CREW_SIGNED_OFF; then auto-raise a
  SIGN_OFF backfill requisition via autoRaiseRequisition.
- Screen: a "Sign off" button on the crew-profile header (sign_off_crew holders —
  site staff / MPO / Manager); on success redirects to the Crew directory.

Tests & docs
- Integration: signoff.test.ts (3) — SIGNED_OFF + experience + EX_HAND + SIGN_OFF
  backfill, already-signed-off guard, permission gating. type-check clean; full
  unit (241) + integration (195) green.
- CLAUDE.md updated — completes Phase 4 (E/F/G + K).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 21:34:29 +05:30
parent bb5f4126b0
commit 4e71863c57
7 changed files with 223 additions and 5 deletions

View file

@ -180,6 +180,12 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Crew strength** (`/admin/crew-strength`): CRUD over `VesselRankRequirement` (the `minStrength` that drives R6 leave-clash detection).
- Both links sit under **Administration** (flag-gated, Manager/Admin/SuperUser).
**Phase 4c — Sign-off & experience (Epic K; spec §5.3):** completes Phase 4 (and the Epic K piece deferred from Phase 2).
- **`signOffCrew(assignmentId, date, remarks)`** (`crewing/crew/actions.ts`, `sign_off_crew`): one transaction — assignment → `SIGNED_OFF` (+ `signOffDate`), append an internal `ExperienceRecord` (rank, on/off dates, computed `durationMonths`), flip the **same `CrewMember`** `EMPLOYEE → EX_HAND` (so they return to the Candidates pool as a returning hand), `CrewAction CREW_SIGNED_OFF`; then auto-raise a `SIGN_OFF` backfill requisition via `autoRaiseRequisition`. (`CrewActionType += CREW_SIGNED_OFF`.)
- **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`).
- This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications).
### 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`.

View file

@ -6,10 +6,11 @@ import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { cn } from "@/lib/utils";
import {
uploadDocument, deleteDocument, saveBankEpf,
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew,
} 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";
@ -37,6 +38,7 @@ type Props = {
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
ranks: { id: string; name: string }[];
perms: { editRecords: boolean; issuePpe: boolean };
signOff: { assignmentId: string | null; canSignOff: boolean };
};
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status"] as const;
@ -53,10 +55,13 @@ export function CrewProfile(p: Props) {
<ArrowLeft className="h-4 w-4" /> Crew
</Link>
<div className="mb-1 flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
<div className="mb-1 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
</div>
{p.signOff.canSignOff && p.signOff.assignmentId && <SignOffButton assignmentId={p.signOff.assignmentId} crewName={p.crew.name} />}
</div>
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
@ -321,3 +326,45 @@ function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
</Section>
);
}
function SignOffButton({ assignmentId, crewName }: { assignmentId: string; crewName: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [date, setDate] = useState("");
const [remarks, setRemarks] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await signOffCrew(assignmentId, date, remarks);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); router.push("/crewing/crew"); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50">Sign off</button>
<AdminDialog title={`Sign off ${crewName}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-neutral-600">Ends this tour: the assignment closes, a tour record is added to Experience, and the crew member returns to the Candidates pool as an ex-hand. A backfill requisition is auto-raised.</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Sign-off date *</label>
<input type="date" className={INPUT} value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Remarks</label>
<input className={INPUT} value={remarks} onChange={(e) => setRemarks(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 || !date} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Signing off…" : "Sign off"}</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -93,6 +93,7 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id
editRecords: hasPermission(role, "upload_crew_records"),
issuePpe: hasPermission(role, "issue_ppe"),
}}
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
/>
);
}

View file

@ -5,10 +5,17 @@ import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { autoRaiseRequisition } from "@/lib/requisition-service";
import { SeafarerDocType, PpeItem } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
// Whole months between two dates (floored), min 0 — for the experience record.
function monthsBetween(from: Date, to: Date): number {
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0);
return Math.max(0, months);
}
type ActionResult = { ok: true; id?: string } | { error: string };
const crewPath = (id: string) => `/crewing/crew/${id}`;
@ -252,3 +259,58 @@ export async function addExperience(formData: FormData): Promise<ActionResult> {
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Sign off (Phase 4c, Epic K) ────────────────────────────────────────────────
// Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD,
// flip the crew member back to EX_HAND (so they return to the Candidates pool), and
// auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper).
export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise<ActionResult> {
const g = await guard("sign_off_crew");
if ("error" in g) return g;
if (!signOffDate) return { error: "A sign-off date is required" };
const assignment = await db.crewAssignment.findUnique({
where: { id: assignmentId },
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
});
if (!assignment) return { error: "Assignment not found" };
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" };
const off = new Date(signOffDate);
await db.$transaction(async (tx) => {
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
await tx.experienceRecord.create({
data: {
crewMemberId: assignment.crewMemberId,
rankId: assignment.rankId,
vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null,
fromDate: assignment.signOnDate,
toDate: off,
durationMonths: monthsBetween(assignment.signOnDate, off),
source: "internal",
},
});
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
await tx.crewMember.update({
where: { id: assignment.crewMemberId },
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
});
await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
});
});
// The seat is now vacant → auto-raise a backfill requisition (spec §5.3).
await autoRaiseRequisition({
rankId: assignment.rankId,
vesselId: assignment.vesselId,
siteId: assignment.siteId,
reason: "SIGN_OFF",
});
revalidatePath(crewPath(assignment.crewMemberId));
revalidatePath("/crewing/crew");
return { ok: true };
}

View file

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_SIGNED_OFF';

View file

@ -154,6 +154,7 @@ enum CrewActionType {
LEAVE_APPLIED
LEAVE_DECIDED
ATTENDANCE_RECORDED
CREW_SIGNED_OFF
}
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────

View file

@ -0,0 +1,99 @@
/**
* Integration tests for Crewing Phase 4c sign-off (Epic K): assignment SIGNED_OFF,
* experience record appended, crew member flipped to EX_HAND, and a SIGN_OFF
* backfill requisition auto-raised on the same CrewMember entity.
*/
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { signOffCrew } from "@/app/(portal)/crewing/crew/actions";
import { makeSession, getSeedUser } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let accountsId: string;
let siteStaffId: string;
let rankId: string;
let vesselId: string;
const SS_EMAIL = "sitestaff@itso.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function activeCrew() {
const c = await db.crewMember.create({ data: { name: "On Tour", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-S${Date.now() % 100000}`, currentRankId: rankId } });
const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
return { crewId: c.id, assignmentId: a.id };
}
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITSO-SS", email: SS_EMAIL, name: "SS SO", role: "SITE_STAFF" } });
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
vesselId = (await db.vessel.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.experienceRecord.deleteMany({});
await db.crewAssignment.deleteMany({});
await db.requisition.deleteMany({});
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("signOffCrew", () => {
it("signs off → SIGNED_OFF + experience record + EX_HAND + backfill requisition", async () => {
const { crewId, assignmentId } = await activeCrew();
as(siteStaffId, "SITE_STAFF");
const res = await signOffCrew(assignmentId, "2026-07-01", "End of contract");
expect("ok" in res && res.ok).toBe(true);
const a = await db.crewAssignment.findUniqueOrThrow({ where: { id: assignmentId } });
expect(a.status).toBe("SIGNED_OFF");
expect(a.signOffDate).not.toBeNull();
// Same entity flipped back to the candidate pool as an ex-hand.
const c = await db.crewMember.findUniqueOrThrow({ where: { id: crewId } });
expect(c.status).toBe("EX_HAND");
expect(c.type).toBe("EX_HAND");
expect(c.employeeId).not.toBeNull(); // history retained
const exp = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: crewId } });
expect(exp.source).toBe("internal");
expect(exp.rankId).toBe(rankId);
expect(exp.durationMonths).toBe(6); // Jan→Jul
const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } });
expect(req.reason).toBe("SIGN_OFF");
expect(req.rankId).toBe(rankId);
expect(req.vesselId).toBe(vesselId);
});
it("refuses to sign off an already signed-off assignment", async () => {
const { assignmentId } = await activeCrew();
as(managerId, "MANAGER");
await signOffCrew(assignmentId, "2026-07-01");
const res = await signOffCrew(assignmentId, "2026-08-01");
expect("error" in res).toBe(true);
});
it("is rejected for a role without sign_off_crew (accounts)", async () => {
const { assignmentId } = await activeCrew();
as(accountsId, "ACCOUNTS");
expect(await signOffCrew(assignmentId, "2026-07-01")).toEqual({ error: "Unauthorized" });
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
});
});