diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 623bfb4..ec16016 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -200,6 +200,12 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **Three surfaces** (§8.14): the PM raises + sees status on the crew profile **Appraisals** tab; the MPO verifies in the **Verification** queue (Appraisals section); the Manager approves in the central **/approvals** queue (Appraisal kind). - This completes **Phase 5** (I + H). Remaining roadmap: **Phase 6** — payroll (Pay-status tab + Approvals "Wage"), dashboards, notifications (J, M). +**Crewing follow-ups (resolved deferrals):** the self-contained deferrals from earlier phases are now done: +- **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site. +- **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above. +- **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`. +- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll). + ### 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)/admin/crew/actions.ts b/App/app/(portal)/admin/crew/actions.ts index 688fce3..6bdf40b 100644 --- a/App/app/(portal)/admin/crew/actions.ts +++ b/App/app/(portal)/admin/crew/actions.ts @@ -5,6 +5,7 @@ import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; import { generateEmployeeId } from "@/lib/employee-number"; +import { maybeCreateSiteStaffLogin } from "@/lib/crew-login"; import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client"; import { z } from "zod"; import { revalidatePath } from "next/cache"; @@ -156,6 +157,8 @@ export async function placeCrew(formData: FormData): Promise { if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx); await tx.crewMember.update({ where: { id: crew.id }, data }); await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } }); + // Management ranks (grantsLogin) become a SITE_STAFF login on placement. + await maybeCreateSiteStaffLogin(tx, { name: crew.name, email: crew.email, employeeId: data.employeeId ?? crew.employeeId }, d.rankId, d.siteId || null); }); revalidatePath(PATH); diff --git a/App/app/(portal)/crewing/applications/actions.ts b/App/app/(portal)/crewing/applications/actions.ts index 6b214bf..4ea7f32 100644 --- a/App/app/(portal)/crewing/applications/actions.ts +++ b/App/app/(portal)/crewing/applications/actions.ts @@ -12,6 +12,7 @@ import { } from "@/lib/application-pipeline"; import { getManagerRecipients } from "@/lib/requisition-service"; import { generateEmployeeId } from "@/lib/employee-number"; +import { maybeCreateSiteStaffLogin } from "@/lib/crew-login"; import { buildStorageKey, uploadBuffer } from "@/lib/storage"; import { notifyCrew } from "@/lib/notifier"; import { SalaryRateBasis } from "@prisma/client"; @@ -556,7 +557,7 @@ export async function onboardCandidate(formData: FormData): Promise Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>, + set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise, + recordLabel: string, + approve: boolean, + remarks: string | undefined, + userId: string +): Promise { + if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" }; + const rec = await load(); + if (!rec) return { error: "Record not found" }; + if (rec.verificationStatus !== "PENDING") return { error: `This record is already ${rec.verificationStatus.toLowerCase()}` }; + + await set(approve ? "VERIFIED" : "REJECTED", userId); + await db.crewAction.create({ + data: { actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED", actorId: userId, crewMemberId: rec.crewMemberId, note: remarks?.trim() || null, metadata: { record: recordLabel } }, + }); + revalidatePath(PATH); + revalidatePath(`/crewing/crew/${rec.crewMemberId}`); + return { ok: true }; +} + +export async function verifyPpe(id: string, approve: boolean, remarks?: string): Promise { + const g = await guard("verify_site_records"); + if ("error" in g) return g; + return verifyRecord( + () => db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }), + (status, userId) => db.ppeIssue.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }), + "ppe", + approve, + remarks, + g.userId + ); +} + +export async function verifyNextOfKin(id: string, approve: boolean, remarks?: string): Promise { + const g = await guard("verify_site_records"); + if ("error" in g) return g; + return verifyRecord( + () => db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }), + (status, userId) => db.nextOfKin.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }), + "next_of_kin", + approve, + remarks, + g.userId + ); +} diff --git a/App/app/(portal)/crewing/verification/page.tsx b/App/app/(portal)/crewing/verification/page.tsx index 58a9e6f..ff1298d 100644 --- a/App/app/(portal)/crewing/verification/page.tsx +++ b/App/app/(portal)/crewing/verification/page.tsx @@ -19,7 +19,7 @@ export default async function VerificationPage() { const canAppraisals = hasPermission(role, "verify_appraisal"); if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard"); - const [docs, bank, epf, appraisals] = await Promise.all([ + const [docs, bank, epf, appraisals, ppe, nok] = await Promise.all([ canDocs ? db.seafarerDocument.findMany({ where: { verificationStatus: "PENDING" }, @@ -47,6 +47,12 @@ export default async function VerificationPage() { include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } }, }) : [], + canDocs + ? db.ppeIssue.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { issuedDate: "asc" }, include: { crewMember: { select: { name: true } } } }) + : [], + canDocs + ? db.nextOfKin.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } }) + : [], ]); return ( @@ -66,6 +72,8 @@ export default async function VerificationPage() { bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))} epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))} appraisals={appraisals.map((a) => ({ id: a.id, crewName: a.assignment.crewMember.name, rank: a.assignment.rank.name, period: a.period, comments: a.comments }))} + ppe={ppe.map((p) => ({ id: p.id, crewName: p.crewMember.name, item: p.item, size: p.size }))} + nok={nok.map((n) => ({ id: n.id, crewName: n.crewMember.name, name: n.name, relationship: n.relationship }))} canDocs={canDocs} canBankEpf={canBankEpf} canAppraisals={canAppraisals} diff --git a/App/app/(portal)/crewing/verification/verification-manager.tsx b/App/app/(portal)/crewing/verification/verification-manager.tsx index fb3592b..c39e0f3 100644 --- a/App/app/(portal)/crewing/verification/verification-manager.tsx +++ b/App/app/(portal)/crewing/verification/verification-manager.tsx @@ -4,8 +4,9 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import type { SeafarerDocType } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; -import { verifyDocument, verifyBankEpf } from "./actions"; +import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin } from "./actions"; import { verifyAppraisal } from "../appraisals/actions"; +import type { PpeItem } from "@prisma/client"; const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase()); const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—"); @@ -15,6 +16,8 @@ type Doc = { id: string; crewName: string; location: string; docType: SeafarerDo type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null }; type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null }; type Appr = { id: string; crewName: string; rank: string; period: string; comments: string | null }; +type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null }; +type Nok = { id: string; crewName: string; name: string; relationship: string | null }; function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) { const router = useRouter(); @@ -71,7 +74,7 @@ function Card({ title, sub, empty, children }: { title: string; sub: string; emp ); } -export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) { +export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) { return (
@@ -99,6 +102,42 @@ export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canB )} + {canDocs && ( + + + CrewItemSize + + + {ppe.map((r) => ( + + {r.crewName} + {label(r.item)} + {r.size ?? "—"} + verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /> + + ))} + + + )} + + {canDocs && ( + + + CrewContactRelationship + + + {nok.map((r) => ( + + {r.crewName} + {r.name} + {r.relationship ?? "—"} + verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /> + + ))} + + + )} + {canBankEpf && ( diff --git a/App/lib/crew-login.ts b/App/lib/crew-login.ts new file mode 100644 index 0000000..e0b376e --- /dev/null +++ b/App/lib/crew-login.ts @@ -0,0 +1,37 @@ +import type { Prisma } from "@prisma/client"; + +// Promote a crew member to a portal login when their rank grants one (PM / +// Assistant PM / Site In-charge — Rank.grantsLogin, spec §3/§4.1). Called from +// onboarding and direct placement, inside their transaction. Creates a SITE_STAFF +// User with no password (set later via the profile / SSO). No-op when the rank +// doesn't grant a login, the crew member has no email/employee no., or a matching +// user already exists. Returns true when a login was created. + +export async function maybeCreateSiteStaffLogin( + tx: Prisma.TransactionClient, + crew: { name: string; email: string | null; employeeId: string | null }, + rankId: string, + siteId?: string | null +): Promise { + const rank = await tx.rank.findUnique({ where: { id: rankId }, select: { grantsLogin: true } }); + if (!rank?.grantsLogin) return false; + if (!crew.email || !crew.employeeId) return false; + + const existing = await tx.user.findFirst({ + where: { OR: [{ email: crew.email }, { employeeId: crew.employeeId }] }, + select: { id: true }, + }); + if (existing) return false; + + await tx.user.create({ + data: { + employeeId: crew.employeeId, + email: crew.email, + name: crew.name, + role: "SITE_STAFF", + passwordHash: null, + siteId: siteId ?? null, + }, + }); + return true; +} diff --git a/App/prisma/migrations/20260622165121_crewing_followups/migration.sql b/App/prisma/migrations/20260622165121_crewing_followups/migration.sql new file mode 100644 index 0000000..b9494e6 --- /dev/null +++ b/App/prisma/migrations/20260622165121_crewing_followups/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "NextOfKin" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING', +ADD COLUMN "verifiedById" TEXT; + +-- AlterTable +ALTER TABLE "PpeIssue" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING', +ADD COLUMN "verifiedById" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "siteId" TEXT; + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 801316e..cacd927 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -318,6 +318,10 @@ model User { requisitionsRaised Requisition[] @relation("RequisitionRaiser") reliefRequested ReliefRequest[] @relation("ReliefRequester") crewActions CrewAction[] + + // Site-staff home site (Crewing §8.7 own-site scoping). Null = unscoped. + siteId String? + site Site? @relation(fields: [siteId], references: [id]) } model SuperUserRequest { @@ -349,6 +353,7 @@ model Site { requisitions Requisition[] reliefRequests ReliefRequest[] assignments CrewAssignment[] + staff User[] } model Vessel { @@ -1040,15 +1045,17 @@ model SeafarerDocument { // Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row. model NextOfKin { - id String @id @default(cuid()) - crewMemberId String - crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade) - name String - relationship String? - phone String? - address String? - isEmergency Boolean @default(false) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + crewMemberId String + crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade) + name String + relationship String? + phone String? + address String? + isEmergency Boolean @default(false) + verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up) + verifiedById String? + createdAt DateTime @default(now()) } // A tour-of-duty experience row — added manually or auto-appended at sign-off @@ -1070,15 +1077,17 @@ model ExperienceRecord { // PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate` // marks a returned item. Optional ItemInventory draw-down is a later refinement. model PpeIssue { - id String @id @default(cuid()) - crewMemberId String - crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade) - item PpeItem - size String? - quantity Int @default(1) - issuedDate DateTime @default(now()) - returnedDate DateTime? - issuedById String? - comment String? - createdAt DateTime @default(now()) + id String @id @default(cuid()) + crewMemberId String + crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade) + item PpeItem + size String? + quantity Int @default(1) + issuedDate DateTime @default(now()) + returnedDate DateTime? + issuedById String? + comment String? + verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up) + verifiedById String? + createdAt DateTime @default(now()) } diff --git a/App/tests/integration/crewing-followups.test.ts b/App/tests/integration/crewing-followups.test.ts new file mode 100644 index 0000000..8cf572c --- /dev/null +++ b/App/tests/integration/crewing-followups.test.ts @@ -0,0 +1,115 @@ +/** + * Integration tests for the self-contained crewing follow-ups: + * - SITE_STAFF login creation on placement/onboarding (grantsLogin ranks) + * - PPE / next-of-kin verification gates + * (Own-site scoping is exercised via the siteId set on the created login.) + */ +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 { placeCrew } from "@/app/(portal)/admin/crew/actions"; +import { verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { Role } from "@prisma/client"; + +let managerId: string; +let manningId: string; +let accountsId: string; +let loginRankId: string; +let plainRankId: string; +let siteId: string; + +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +const LOGIN_EMAIL = "pmlogin.itfu@example.local"; + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + manningId = (await getSeedUser("manning@pelagia.local")).id; + accountsId = (await getSeedUser("accounts@pelagia.local")).id; + loginRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: true } })).id; + plainRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: false } })).id; + siteId = (await db.site.findFirstOrThrow()).id; +}); + +afterEach(async () => { + await db.crewAction.deleteMany({}); + await db.ppeIssue.deleteMany({}); + await db.nextOfKin.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.crewMember.deleteMany({}); + await db.user.deleteMany({ where: { email: LOGIN_EMAIL } }); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: LOGIN_EMAIL } }); +}); + +describe("SITE_STAFF login on placement (grantsLogin ranks)", () => { + it("creates a SITE_STAFF login (with home site) for a management-rank placement", async () => { + const c = await db.crewMember.create({ data: { name: "New PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } }); + as(managerId, "MANAGER"); + expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true); + + const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } }); + const login = await db.user.findUniqueOrThrow({ where: { email: LOGIN_EMAIL } }); + expect(login.role).toBe("SITE_STAFF"); + expect(login.employeeId).toBe(after.employeeId); // shares the CRW- number + expect(login.passwordHash).toBeNull(); + expect(login.siteId).toBe(siteId); // own-site link set at creation + }); + + it("creates no login for a non-login rank", async () => { + const c = await db.crewMember.create({ data: { name: "Deck Hand", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } }); + as(managerId, "MANAGER"); + await placeCrew(fd({ crewMemberId: c.id, rankId: plainRankId, siteId, signOnDate: "2026-07-01" })); + expect(await db.user.findUnique({ where: { email: LOGIN_EMAIL } })).toBeNull(); + }); + + it("skips the login when the crew member has no email (placement still succeeds)", async () => { + const c = await db.crewMember.create({ data: { name: "No Email PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN" } }); + as(managerId, "MANAGER"); + expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true); + expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE"); + }); +}); + +describe("PPE / next-of-kin verification (MPO)", () => { + async function crewWithRecords() { + const c = await db.crewMember.create({ data: { name: "Verify Me", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "HELMET" } }); + const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse" } }); + return { ppeId: ppe.id, nokId: nok.id }; + } + + it("MPO verifies PPE and next-of-kin", async () => { + const { ppeId, nokId } = await crewWithRecords(); + as(manningId, "MANNING"); + expect("ok" in (await verifyPpe(ppeId, true))).toBe(true); + expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppeId } })).verificationStatus).toBe("VERIFIED"); + expect("ok" in (await verifyNextOfKin(nokId, true))).toBe(true); + expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nokId } })).verificationStatus).toBe("VERIFIED"); + }); + + it("rejection requires a reason; already-decided is guarded", async () => { + const { ppeId } = await crewWithRecords(); + as(manningId, "MANNING"); + expect("error" in (await verifyPpe(ppeId, false))).toBe(true); + expect("ok" in (await verifyPpe(ppeId, false, "Wrong size"))).toBe(true); + expect("error" in (await verifyPpe(ppeId, true))).toBe(true); // already rejected + }); + + it("is rejected for roles without verify_site_records (accounts)", async () => { + const { ppeId } = await crewWithRecords(); + as(accountsId, "ACCOUNTS"); + expect(await verifyPpe(ppeId, true)).toEqual({ error: "Unauthorized" }); + }); +});