feat(crewing): resolve self-contained deferred follow-ups (flagged)
Clears the self-contained deferrals tracked across phases. Stacks on 5b appraisal. Behind NEXT_PUBLIC_CREWING_ENABLED. - SITE_STAFF login on onboard/placement (Epic D follow-up): lib/crew-login.ts maybeCreateSiteStaffLogin creates a passwordless SITE_STAFF User (sharing the CRW- employee no., siteId = the assignment's site) when a grantsLogin rank is onboarded (onboardCandidate) or placed (placeCrew) and the crew member has an email. No-op otherwise. - Own-site scoping (Epic E follow-up, §8.7): User.siteId added (migration crewing_followups); the Crew directory filters a SITE_STAFF user with a home site to crew whose active assignment is at that site (graceful when unset). The link is set at login creation. - PPE / next-of-kin verify gates (Epic F/I follow-up): PpeIssue/NextOfKin gained verificationStatus + verifiedById; verifyPpe / verifyNextOfKin (verify_site_records, MPO) + queue sections in /crewing/verification. Tests & docs - Integration: crewing-followups.test.ts (6) — login created/skipped by rank+email (+ siteId set), PPE/NoK verify + reject-reason + already-decided guard + gating. type-check clean; full unit (245) + integration (211) green (RESEND_API_KEY unset). - CLAUDE.md updated. Part of Epic D (#78), Epic E (#79), Epic F (#80), Epic I (#83). Still deferred (not self-contained): public careers API (A2); Pay-status pay rows (Phase 6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c14a22588e
commit
df3b4bdc97
11 changed files with 318 additions and 27 deletions
|
|
@ -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).
|
- **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).
|
- 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
|
### 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`.
|
`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`.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { generateEmployeeId } from "@/lib/employee-number";
|
import { generateEmployeeId } from "@/lib/employee-number";
|
||||||
|
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||||
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
|
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
@ -156,6 +157,8 @@ export async function placeCrew(formData: FormData): Promise<ActionResult> {
|
||||||
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
|
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
|
||||||
await tx.crewMember.update({ where: { id: crew.id }, data });
|
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 } } });
|
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);
|
revalidatePath(PATH);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from "@/lib/application-pipeline";
|
} from "@/lib/application-pipeline";
|
||||||
import { getManagerRecipients } from "@/lib/requisition-service";
|
import { getManagerRecipients } from "@/lib/requisition-service";
|
||||||
import { generateEmployeeId } from "@/lib/employee-number";
|
import { generateEmployeeId } from "@/lib/employee-number";
|
||||||
|
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||||
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
import { notifyCrew } from "@/lib/notifier";
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
import { SalaryRateBasis } from "@prisma/client";
|
import { SalaryRateBasis } from "@prisma/client";
|
||||||
|
|
@ -556,7 +557,7 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
|
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
|
||||||
crewMember: { select: { id: true } },
|
crewMember: { select: { id: true, name: true, email: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!app) return { error: "Application not found" };
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
@ -593,6 +594,8 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
||||||
where: { id: app.crewMember.id },
|
where: { id: app.crewMember.id },
|
||||||
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
|
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
|
||||||
});
|
});
|
||||||
|
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
|
||||||
|
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
|
||||||
return { assignmentId: assignment.id, employeeId };
|
return { assignmentId: assignment.id, employeeId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,18 @@ export default async function CrewPage() {
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
|
||||||
|
|
||||||
// NOTE: site-staff "own site only" scoping (§8.7) needs a User↔Site link that
|
// Own-site scoping (§8.7): a site-staff user with a home site sees only crew whose
|
||||||
// isn't modelled yet — deferred to a follow-up; for now all active crew show.
|
// active assignment is at that site. Without a home site they remain unscoped.
|
||||||
|
let siteScopeId: string | null = null;
|
||||||
|
if (session.user.role === "SITE_STAFF") {
|
||||||
|
siteScopeId = (await db.user.findUnique({ where: { id: session.user.id }, select: { siteId: true } }))?.siteId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const crew = await db.crewMember.findMany({
|
const crew = await db.crewMember.findMany({
|
||||||
where: { status: "EMPLOYEE" },
|
where: {
|
||||||
|
status: "EMPLOYEE",
|
||||||
|
...(siteScopeId ? { assignments: { some: { status: { not: "SIGNED_OFF" }, siteId: siteScopeId } } } : {}),
|
||||||
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
include: {
|
include: {
|
||||||
currentRank: { select: { name: true } },
|
currentRank: { select: { name: true } },
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,53 @@ export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf",
|
||||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PPE / next-of-kin verification (MPO) ───────────────────────────────────────
|
||||||
|
|
||||||
|
async function verifyRecord(
|
||||||
|
load: () => Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>,
|
||||||
|
set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise<unknown>,
|
||||||
|
recordLabel: string,
|
||||||
|
approve: boolean,
|
||||||
|
remarks: string | undefined,
|
||||||
|
userId: string
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default async function VerificationPage() {
|
||||||
const canAppraisals = hasPermission(role, "verify_appraisal");
|
const canAppraisals = hasPermission(role, "verify_appraisal");
|
||||||
if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard");
|
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
|
canDocs
|
||||||
? db.seafarerDocument.findMany({
|
? db.seafarerDocument.findMany({
|
||||||
where: { verificationStatus: "PENDING" },
|
where: { verificationStatus: "PENDING" },
|
||||||
|
|
@ -47,6 +47,12 @@ export default async function VerificationPage() {
|
||||||
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
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 (
|
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 }))}
|
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 }))}
|
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 }))}
|
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}
|
canDocs={canDocs}
|
||||||
canBankEpf={canBankEpf}
|
canBankEpf={canBankEpf}
|
||||||
canAppraisals={canAppraisals}
|
canAppraisals={canAppraisals}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { SeafarerDocType } from "@prisma/client";
|
import type { SeafarerDocType } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
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 { 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 label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
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 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 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 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 }> }) {
|
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -99,6 +102,42 @@ export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canB
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canDocs && (
|
||||||
|
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ppe.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDocs && (
|
||||||
|
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{nok.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{canBankEpf && (
|
{canBankEpf && (
|
||||||
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
|
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
|
||||||
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
|
|
||||||
37
App/lib/crew-login.ts
Normal file
37
App/lib/crew-login.ts
Normal file
|
|
@ -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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -318,6 +318,10 @@ model User {
|
||||||
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
|
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
|
||||||
reliefRequested ReliefRequest[] @relation("ReliefRequester")
|
reliefRequested ReliefRequest[] @relation("ReliefRequester")
|
||||||
crewActions CrewAction[]
|
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 {
|
model SuperUserRequest {
|
||||||
|
|
@ -349,6 +353,7 @@ model Site {
|
||||||
requisitions Requisition[]
|
requisitions Requisition[]
|
||||||
reliefRequests ReliefRequest[]
|
reliefRequests ReliefRequest[]
|
||||||
assignments CrewAssignment[]
|
assignments CrewAssignment[]
|
||||||
|
staff User[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vessel {
|
model Vessel {
|
||||||
|
|
@ -1040,15 +1045,17 @@ model SeafarerDocument {
|
||||||
|
|
||||||
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
|
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
|
||||||
model NextOfKin {
|
model NextOfKin {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
crewMemberId String
|
crewMemberId String
|
||||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
relationship String?
|
relationship String?
|
||||||
phone String?
|
phone String?
|
||||||
address String?
|
address String?
|
||||||
isEmergency Boolean @default(false)
|
isEmergency Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
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
|
// 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`
|
// 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.
|
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
|
||||||
model PpeIssue {
|
model PpeIssue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
crewMemberId String
|
crewMemberId String
|
||||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||||
item PpeItem
|
item PpeItem
|
||||||
size String?
|
size String?
|
||||||
quantity Int @default(1)
|
quantity Int @default(1)
|
||||||
issuedDate DateTime @default(now())
|
issuedDate DateTime @default(now())
|
||||||
returnedDate DateTime?
|
returnedDate DateTime?
|
||||||
issuedById String?
|
issuedById String?
|
||||||
comment String?
|
comment String?
|
||||||
createdAt DateTime @default(now())
|
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
|
||||||
|
verifiedById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
App/tests/integration/crewing-followups.test.ts
Normal file
115
App/tests/integration/crewing-followups.test.ts
Normal file
|
|
@ -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<unknown>).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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue