From 4e71863c57a02905ca2fc264a982948d4452af6c Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 21:34:29 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat(crewing):=20Phase=204c=20=E2=80=94=20s?= =?UTF-8?q?ign-off=20&=20experience=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 6 ++ .../crewing/crew/[id]/crew-profile.tsx | 57 ++++++++++- App/app/(portal)/crewing/crew/[id]/page.tsx | 1 + App/app/(portal)/crewing/crew/actions.ts | 62 ++++++++++++ .../migration.sql | 2 + App/prisma/schema.prisma | 1 + App/tests/integration/signoff.test.ts | 99 +++++++++++++++++++ 7 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 App/prisma/migrations/20260622160026_crewing_signoff/migration.sql create mode 100644 App/tests/integration/signoff.test.ts 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)}> +
+

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.

+
+ + setDate(e.target.value)} required /> +
+
+ + setRemarks(e.target.value)} placeholder="Optional" /> +
+ {error &&

{error}

} +
+ + +
+
+
+ + ); +} 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); + }); +}); From 712e040fc28666d5a3b1f24dffefa71f97c3b5f7 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 21:41:36 +0530 Subject: [PATCH 2/2] fix(notifier): don't construct Resend without an API key (CI crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/notifier.ts eagerly did `new Resend(process.env.RESEND_API_KEY)` whenever NODE_ENV !== "development". Resend v4's constructor throws on a missing key, so in any env without RESEND_API_KEY (CI, non-dev test runs) merely importing the module crashed — surfaced by crew-records.test.ts once Phase 4c pulled requisition-service → notifier into the crew actions' import graph. Construct the client only when a key is present; otherwise fall back to console logging (the send branches now gate on `!resend` instead of `isDev`). Verified by running the full integration suite with RESEND_API_KEY unset (195 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/lib/notifier.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/App/lib/notifier.ts b/App/lib/notifier.ts index 417112b..ef5e4a4 100644 --- a/App/lib/notifier.ts +++ b/App/lib/notifier.ts @@ -3,7 +3,10 @@ import { db } from "@/lib/db"; import type { PurchaseOrder, User } from "@prisma/client"; const isDev = process.env.NODE_ENV === "development"; -const resend = isDev ? null : new Resend(process.env.RESEND_API_KEY); +// Construct the Resend client only when a key is actually present — in dev, CI, +// or any env without RESEND_API_KEY we fall back to console logging (the Resend +// v4 constructor throws on a missing key). `canSend` gates the real send path. +const resend = !isDev && process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; const FROM = `${process.env.EMAIL_FROM_NAME ?? "PPMS"} <${process.env.EMAIL_FROM ?? "noreply@ppms.pelagiamarine.com"}>`; const APP_URL = (process.env.NEXTAUTH_URL ?? "https://portal.pelagiamarine.com").replace(/\/$/, ""); @@ -84,13 +87,13 @@ export async function notify({ event, po, recipients, note }: NotifyParams) { const link = buildInAppLink(event, po, recipient); let status = "sent"; - if (isDev) { + if (!resend) { console.log( `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n Link: ${APP_URL}${link}\n` ); } else { try { - const { error } = await resend!.emails.send({ + const { error } = await resend.emails.send({ from: FROM, to: recipient.email, subject, @@ -441,13 +444,13 @@ export async function notifyCrew({ event, recipients, subject, body, link }: Cre await Promise.allSettled( recipients.map(async (recipient) => { let status = "sent"; - if (isDev) { + if (!resend) { console.log( `\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${body}\n Link: ${APP_URL}${link ?? ""}\n` ); } else { try { - const { error } = await resend!.emails.send({ + const { error } = await resend.emails.send({ from: FROM, to: recipient.email, subject,