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:
parent
bb5f4126b0
commit
4e71863c57
7 changed files with 223 additions and 5 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_SIGNED_OFF';
|
||||
|
|
@ -154,6 +154,7 @@ enum CrewActionType {
|
|||
LEAVE_APPLIED
|
||||
LEAVE_DECIDED
|
||||
ATTENDANCE_RECORDED
|
||||
CREW_SIGNED_OFF
|
||||
}
|
||||
|
||||
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
|
||||
|
|
|
|||
99
App/tests/integration/signoff.test.ts
Normal file
99
App/tests/integration/signoff.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue