diff --git a/App/CLAUDE.md b/App/CLAUDE.md
index bb51b0e..b5a26ad 100644
--- a/App/CLAUDE.md
+++ b/App/CLAUDE.md
@@ -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`.
diff --git a/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
index 34c7033..17b409f 100644
--- a/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
+++ b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
@@ -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) {
Crew
-
-
{p.crew.name}
- {p.crew.status === "ACTIVE" &&
Active}
- {p.crew.status === "ON_LEAVE" &&
On leave}
+
+
+
{p.crew.name}
+ {p.crew.status === "ACTIVE" && Active}
+ {p.crew.status === "ON_LEAVE" && On leave}
+
+ {p.signOff.canSignOff && p.signOff.assignmentId &&
}
{p.crew.employeeId} · {p.crew.rank} · {p.crew.location}
@@ -321,3 +326,45 @@ function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
);
}
+
+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 (
+ <>
+
+
setOpen(false)}>
+
+
+ >
+ );
+}
diff --git a/App/app/(portal)/crewing/crew/[id]/page.tsx b/App/app/(portal)/crewing/crew/[id]/page.tsx
index 17d59dc..ce2e661 100644
--- a/App/app/(portal)/crewing/crew/[id]/page.tsx
+++ b/App/app/(portal)/crewing/crew/[id]/page.tsx
@@ -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) }}
/>
);
}
diff --git a/App/app/(portal)/crewing/crew/actions.ts b/App/app/(portal)/crewing/crew/actions.ts
index e4f6826..2a1f7da 100644
--- a/App/app/(portal)/crewing/crew/actions.ts
+++ b/App/app/(portal)/crewing/crew/actions.ts
@@ -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
{
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 {
+ 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 };
+}
diff --git a/App/prisma/migrations/20260622160026_crewing_signoff/migration.sql b/App/prisma/migrations/20260622160026_crewing_signoff/migration.sql
new file mode 100644
index 0000000..d3cb2c5
--- /dev/null
+++ b/App/prisma/migrations/20260622160026_crewing_signoff/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "CrewActionType" ADD VALUE 'CREW_SIGNED_OFF';
diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma
index 65b32b2..e5fa897 100644
--- a/App/prisma/schema.prisma
+++ b/App/prisma/schema.prisma
@@ -154,6 +154,7 @@ enum CrewActionType {
LEAVE_APPLIED
LEAVE_DECIDED
ATTENDANCE_RECORDED
+ CREW_SIGNED_OFF
}
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
diff --git a/App/tests/integration/signoff.test.ts b/App/tests/integration/signoff.test.ts
new file mode 100644
index 0000000..61e6505
--- /dev/null
+++ b/App/tests/integration/signoff.test.ts
@@ -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).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);
+ });
+});