pelagia-portal/App/app/(portal)/crewing/crew/[id]/page.tsx
Hardik c14a22588e feat(crewing): Phase 5b — appraisal (flagged)
Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across
three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on
5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5.

What's in
- Schema: Appraisal (on CrewAssignment) + AppraisalStatus
  (DRAFT/SUBMITTED/MPO_VERIFIED/MANAGER_APPROVED/REJECTED); CrewActionType +=
  APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED. Migration crewing_appraisal.
- State machine lib/appraisal-state-machine.ts: verify (SUBMITTED→MPO_VERIFIED,
  MPO/Manager), approve (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject.
- Actions (crewing/appraisals/actions.ts): raiseAppraisal (raise_appraisal — PM/
  site staff), verifyAppraisal (verify_appraisal — MPO), approveAppraisal
  (approve_appraisal — Manager); reject paths require remarks; notifications
  APPRAISAL_FOR_VERIFICATION / APPRAISAL_FOR_APPROVAL.
- Three surfaces (§8.14): PM raises + tracks status on the crew-profile Appraisals
  tab; MPO verifies in the Verification queue (Appraisals section); Manager approves
  in the central /approvals queue (Appraisal kind).

Tests & docs
- Unit: appraisal-state-machine.test.ts (4). Integration: appraisal.test.ts (4) —
  raise→verify→approve happy path, MPO reject, permission gating (MPO can't raise,
  site staff can't verify, MPO can't approve). type-check clean; full unit (245) +
  integration (205) green (verified with RESEND_API_KEY unset).
- CLAUDE.md updated — completes Phase 5 (I + H).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:09:32 +05:30

113 lines
4.8 KiB
TypeScript

import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canViewSalary, bankEpfValue } from "@/lib/crew-pii";
import { redirect, notFound } from "next/navigation";
import { CrewProfile } from "./crew-profile";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew profile" };
export default async function CrewProfilePage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_crew_records")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: {
currentRank: { select: { name: true } },
documents: { orderBy: { createdAt: "desc" } },
bankDetail: true,
epfDetail: true,
nextOfKin: { orderBy: { createdAt: "asc" } },
ppeIssues: { orderBy: { issuedDate: "desc" } },
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
assignments: {
where: { status: { not: "SIGNED_OFF" } },
orderBy: { signOnDate: "desc" },
take: 1,
include: {
vessel: { select: { name: true } },
site: { select: { name: true } },
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
},
},
},
});
if (!c) notFound();
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
const assignment = c.assignments[0] ?? null;
const showSalary = canViewSalary(role);
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
const appraisals = await db.appraisal.findMany({
where: { assignment: { crewMemberId: c.id } },
orderBy: { createdAt: "desc" },
select: { id: true, period: true, status: true, comments: true, ratings: true },
});
return (
<CrewProfile
crew={{
id: c.id,
name: c.name,
employeeId: c.employeeId ?? "—",
rank: c.currentRank?.name ?? "—",
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
status: assignment?.status ?? null,
}}
documents={c.documents.map((d) => ({
id: d.id,
docType: d.docType,
number: d.number,
issueDate: d.issueDate?.toISOString() ?? null,
expiryDate: d.expiryDate?.toISOString() ?? null,
verificationStatus: d.verificationStatus,
hasFile: Boolean(d.fileKey),
}))}
bank={{
accountName: c.bankDetail?.accountName ?? null,
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
ifsc: c.bankDetail?.ifsc ?? null,
bankName: c.bankDetail?.bankName ?? null,
}}
epf={{
uan: c.epfDetail?.uan ?? null,
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
pfNumber: c.epfDetail?.pfNumber ?? null,
}}
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
paystatus={{
showSalary,
salary: showSalary && currentSalary
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
: null,
}}
ranks={ranks}
perms={{
editRecords: hasPermission(role, "upload_crew_records"),
issuePpe: hasPermission(role, "issue_ppe"),
}}
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
appraisals={appraisals.map((a) => ({
id: a.id,
period: a.period,
status: a.status,
comments: a.comments,
ratings: (a.ratings ?? null) as { competence: number | null; conduct: number | null; safety: number | null } | null,
}))}
appraisalCtx={{ assignmentId: assignment?.id ?? null, canRaise: hasPermission(role, "raise_appraisal") && Boolean(assignment) }}
/>
);
}