From c14a22588e69be6ceb41a2ee0554eec23a63634c Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 22:09:32 +0530 Subject: [PATCH] =?UTF-8?q?feat(crewing):=20Phase=205b=20=E2=80=94=20appra?= =?UTF-8?q?isal=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 8 + .../(portal)/approvals/crewing-approvals.tsx | 9 +- App/app/(portal)/approvals/page.tsx | 22 ++- .../(portal)/crewing/appraisals/actions.ts | 146 ++++++++++++++++++ .../crewing/crew/[id]/crew-profile.tsx | 57 ++++++- App/app/(portal)/crewing/crew/[id]/page.tsx | 14 ++ .../(portal)/crewing/verification/page.tsx | 14 +- .../verification/verification-manager.tsx | 23 ++- App/lib/appraisal-state-machine.ts | 40 +++++ App/lib/notifier.ts | 6 +- .../migration.sql | 36 +++++ App/prisma/schema.prisma | 35 +++++ App/tests/integration/appraisal.test.ts | 108 +++++++++++++ .../unit/appraisal-state-machine.test.ts | 30 ++++ 14 files changed, 537 insertions(+), 11 deletions(-) create mode 100644 App/app/(portal)/crewing/appraisals/actions.ts create mode 100644 App/lib/appraisal-state-machine.ts create mode 100644 App/prisma/migrations/20260622163217_crewing_appraisal/migration.sql create mode 100644 App/tests/integration/appraisal.test.ts create mode 100644 App/tests/unit/appraisal-state-machine.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 9ae7198..623bfb4 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -192,6 +192,14 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat - **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7). - **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models). +**Phase 5b — Appraisal (Epic H; spec §5.4/§8.14):** completes Phase 5. + +- **Model:** `Appraisal` (on `CrewAssignment`) + `AppraisalStatus` (`DRAFT → SUBMITTED → MPO_VERIFIED → MANAGER_APPROVED`; `→ REJECTED`). `ratings` is a small JSON (competence/conduct/safety). `CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED`. +- **State machine** `lib/appraisal-state-machine.ts`: `verify` (SUBMITTED→MPO_VERIFIED, MPO/Manager) and `approve` (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. +- **Actions** (`crewing/appraisals/actions.ts`): `raiseAppraisal` (`raise_appraisal` — PM/site staff; creates `SUBMITTED`), `verifyAppraisal` (`verify_appraisal` — MPO), `approveAppraisal` (`approve_appraisal` — Manager); reject paths require remarks; notifications `APPRAISAL_FOR_VERIFICATION` / `APPRAISAL_FOR_APPROVAL`. +- **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). + ### 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)/approvals/crewing-approvals.tsx b/App/app/(portal)/approvals/crewing-approvals.tsx index 55096f6..a8ec5f3 100644 --- a/App/app/(portal)/approvals/crewing-approvals.tsx +++ b/App/app/(portal)/approvals/crewing-approvals.tsx @@ -14,8 +14,9 @@ import { declineInterviewWaiver, } from "../crewing/applications/actions"; import { decideLeave } from "../crewing/leave/actions"; +import { approveAppraisal } from "../crewing/appraisals/actions"; -export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE"; +export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE" | "APPRAISAL"; export type CrewApprovalItem = { id: string; // applicationId, or leaveRequestId for LEAVE @@ -27,20 +28,22 @@ export type CrewApprovalItem = { link: string; }; -const KIND_LABEL: Record = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave" }; -const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning" } as const; +const KIND_LABEL: Record = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave", APPRAISAL: "Appraisal" }; +const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning", APPRAISAL: "default" } as const; const approveFn: Record Promise<{ ok: true } | { error: string }>> = { SALARY: approveSalary, SELECTION: selectCandidate, WAIVER: approveInterviewWaiver, LEAVE: (id) => decideLeave(id, true), + APPRAISAL: (id) => approveAppraisal(id, true), }; const returnFn: Record Promise<{ ok: true } | { error: string }>> = { SALARY: returnSalary, SELECTION: returnSelection, WAIVER: declineInterviewWaiver, LEAVE: (id, reason) => decideLeave(id, false, reason), + APPRAISAL: (id, reason) => approveAppraisal(id, false, reason), }; function Row({ item }: { item: CrewApprovalItem }) { diff --git a/App/app/(portal)/approvals/page.tsx b/App/app/(portal)/approvals/page.tsx index 6b3e85f..8d0465a 100644 --- a/App/app/(portal)/approvals/page.tsx +++ b/App/app/(portal)/approvals/page.tsx @@ -59,7 +59,8 @@ export default async function ApprovalsPage({ searchParams }: Props) { (hasPermission(role, "approve_salary_structure") || hasPermission(role, "select_candidate") || hasPermission(role, "approve_interview_waiver") || - hasPermission(role, "decide_leave")); + hasPermission(role, "decide_leave") || + hasPermission(role, "approve_appraisal")); const crewGates = showCrewing ? await db.applicationGate.findMany({ @@ -113,7 +114,24 @@ export default async function ApprovalsPage({ searchParams }: Props) { })) : []; - const allCrewItems = [...crewItems, ...leaveItems]; + // MPO-verified appraisals awaiting Manager approval (§8.13/§8.14). + const appraisalItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "approve_appraisal")) + ? (await db.appraisal.findMany({ + where: { status: "MPO_VERIFIED" }, + orderBy: { createdAt: "asc" }, + include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } }, + })).map((a) => ({ + id: a.id, + kind: "APPRAISAL" as CrewApprovalKind, + candidateName: a.assignment.crewMember.name, + rank: a.assignment.rank.name, + requisitionCode: a.period, + detail: "MPO-verified appraisal", + link: "/approvals", + })) + : []; + + const allCrewItems = [...crewItems, ...leaveItems, ...appraisalItems]; return (
diff --git a/App/app/(portal)/crewing/appraisals/actions.ts b/App/app/(portal)/crewing/appraisals/actions.ts new file mode 100644 index 0000000..d2ca675 --- /dev/null +++ b/App/app/(portal)/crewing/appraisals/actions.ts @@ -0,0 +1,146 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission, type Permission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { canPerformAction, canReject } from "@/lib/appraisal-state-machine"; +import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service"; +import { notifyCrew } from "@/lib/notifier"; +import type { Role } from "@prisma/client"; +import { z } from "zod"; +import { revalidatePath } from "next/cache"; + +type ActionResult = { ok: true; id?: string } | { error: string }; + +async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> { + if (!CREWING_ENABLED) return { error: "Crewing is not enabled" }; + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" }; + return { userId: session.user.id, role: session.user.role }; +} + +function loadAppraisal(id: string) { + return db.appraisal.findUnique({ + where: { id }, + include: { assignment: { include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } } } }, + }); +} + +function revalidate(crewMemberId: string) { + revalidatePath(`/crewing/crew/${crewMemberId}`); + revalidatePath("/crewing/verification"); + revalidatePath("/approvals"); +} + +// ── Raise an appraisal (PM / site staff) ─────────────────────────────────────── + +const raiseSchema = z.object({ + assignmentId: z.string().min(1, "Crew assignment is required"), + period: z.string().trim().min(1, "Period is required"), + comments: z.string().optional(), + competence: z.coerce.number().int().min(1).max(5).optional(), + conduct: z.coerce.number().int().min(1).max(5).optional(), + safety: z.coerce.number().int().min(1).max(5).optional(), +}); + +export async function raiseAppraisal(formData: FormData): Promise { + const g = await guard("raise_appraisal"); + if ("error" in g) return g; + + const parsed = raiseSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const d = parsed.data; + + const assignment = await db.crewAssignment.findUnique({ + where: { id: d.assignmentId }, + include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } }, + }); + if (!assignment) return { error: "Crew assignment not found" }; + + const appraisal = await db.appraisal.create({ + data: { + assignmentId: d.assignmentId, + period: d.period, + comments: d.comments ?? null, + ratings: { competence: d.competence ?? null, conduct: d.conduct ?? null, safety: d.safety ?? null }, + status: "SUBMITTED", + addedById: g.userId, + }, + }); + await db.crewAction.create({ data: { actionType: "APPRAISAL_SUBMITTED", actorId: g.userId, crewMemberId: assignment.crewMember.id } }); + + const mpos = await getMpoRecipients(); + await notifyCrew({ + event: "APPRAISAL_FOR_VERIFICATION", + recipients: mpos, + subject: `Appraisal to verify — ${assignment.crewMember.name}`, + body: `An appraisal for ${assignment.crewMember.name} (${assignment.rank.name}, ${d.period}) awaits MPO verification.`, + link: "/crewing/verification", + }); + + revalidate(assignment.crewMember.id); + return { ok: true, id: appraisal.id }; +} + +// ── Verify (MPO) ─────────────────────────────────────────────────────────────── + +export async function verifyAppraisal(id: string, approve: boolean, remarks?: string): Promise { + const g = await guard("verify_appraisal"); + if ("error" in g) return g; + + const a = await loadAppraisal(id); + if (!a) return { error: "Appraisal not found" }; + + if (!approve) { + if (!canReject(a.status)) return { error: `Cannot reject from ${a.status}` }; + if (!remarks?.trim()) return { error: "A reason is required to reject" }; + await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } }); + await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } }); + revalidate(a.assignment.crewMember.id); + return { ok: true }; + } + + if (!canPerformAction(a.status, "verify", g.role)) return { error: `Cannot verify from ${a.status}` }; + await db.appraisal.update({ where: { id }, data: { status: "MPO_VERIFIED", verifiedById: g.userId } }); + await db.crewAction.create({ data: { actionType: "APPRAISAL_VERIFIED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } }); + + const managers = await getManagerRecipients(); + await notifyCrew({ + event: "APPRAISAL_FOR_APPROVAL", + recipients: managers, + subject: `Appraisal for approval — ${a.assignment.crewMember.name}`, + body: `${a.assignment.crewMember.name}'s appraisal (${a.assignment.rank.name}, ${a.period}) has been MPO-verified and awaits your approval.`, + link: "/approvals", + }); + + revalidate(a.assignment.crewMember.id); + return { ok: true }; +} + +// ── Approve (Manager) ────────────────────────────────────────────────────────── + +export async function approveAppraisal(id: string, approve: boolean, remarks?: string): Promise { + const g = await guard("approve_appraisal"); + if ("error" in g) return g; + + const a = await loadAppraisal(id); + if (!a) return { error: "Appraisal not found" }; + + if (!approve) { + if (!canReject(a.status)) return { error: `Cannot return from ${a.status}` }; + if (!remarks?.trim()) return { error: "A reason is required to return" }; + await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } }); + await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } }); + revalidate(a.assignment.crewMember.id); + return { ok: true }; + } + + if (!canPerformAction(a.status, "approve", g.role)) return { error: `Cannot approve from ${a.status}` }; + await db.appraisal.update({ where: { id }, data: { status: "MANAGER_APPROVED", approvedById: g.userId } }); + await db.crewAction.create({ data: { actionType: "APPRAISAL_APPROVED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } }); + + revalidate(a.assignment.crewMember.id); + return { ok: true }; +} diff --git a/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx index 17b409f..b39024e 100644 --- a/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx +++ b/App/app/(portal)/crewing/crew/[id]/crew-profile.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft } from "lucide-react"; -import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis } from "@prisma/client"; +import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis, AppraisalStatus } from "@prisma/client"; import { Badge } from "@/components/ui/badge"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { cn } from "@/lib/utils"; @@ -12,6 +12,7 @@ import { uploadDocument, deleteDocument, saveBankEpf, addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew, } from "../actions"; +import { raiseAppraisal } from "../../appraisals/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"; const BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"; @@ -39,11 +40,19 @@ type Props = { ranks: { id: string; name: string }[]; perms: { editRecords: boolean; issuePpe: boolean }; signOff: { assignmentId: string | null; canSignOff: boolean }; + appraisals: Appr[]; + appraisalCtx: { assignmentId: string | null; canRaise: boolean }; }; -const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status"] as const; +type Appr = { id: string; period: string; status: AppraisalStatus; comments: string | null; ratings: { competence: number | null; conduct: number | null; safety: number | null } | null }; + +const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status", "Appraisals"] as const; type Tab = (typeof TABS)[number]; +const APPRAISAL_VARIANT: Record = { + DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger", +}; + export function CrewProfile(p: Props) { const [tab, setTab] = useState("Documents"); const router = useRouter(); @@ -79,10 +88,54 @@ export function CrewProfile(p: Props) { {tab === "PPE" && } {tab === "Experience" && } {tab === "Pay status" && } + {tab === "Appraisals" && }
); } +function Appraisals({ rows, ctx, onDone }: { rows: Appr[]; ctx: { assignmentId: string | null; canRaise: boolean }; onDone: () => void }) { + const { pending, error, run } = useRun(onDone); + const [f, setF] = useState({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" }); + + function submit(e: React.FormEvent) { + e.preventDefault(); + if (!ctx.assignmentId) return; + const fd = new FormData(); + fd.set("assignmentId", ctx.assignmentId); + Object.entries(f).forEach(([k, v]) => v && fd.set(k, v)); + run(() => raiseAppraisal(fd), () => setF({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" })); + } + + return ( +
+ {rows.length === 0 ?

No appraisals.

: rows.map((a) => ( +
+
+

{a.period} {a.status.replace(/_/g, " ").toLowerCase()}

+

+ {a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"} + {a.comments ? ` · ${a.comments}` : ""} +

+
+
+ ))} + {ctx.canRaise && ctx.assignmentId && ( +
+ setF({ ...f, period: e.target.value })} required /> + setF({ ...f, comments: e.target.value })} /> + {(["competence", "conduct", "safety"] as const).map((k) => ( + + ))} +
+
+ )} + {!ctx.canRaise &&

Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.

} +
+ ); +} + function Section({ children }: { children: React.ReactNode }) { return
{children}
; } diff --git a/App/app/(portal)/crewing/crew/[id]/page.tsx b/App/app/(portal)/crewing/crew/[id]/page.tsx index ce2e661..43a4250 100644 --- a/App/app/(portal)/crewing/crew/[id]/page.tsx +++ b/App/app/(portal)/crewing/crew/[id]/page.tsx @@ -49,6 +49,12 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id 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 ( ({ + 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) }} /> ); } diff --git a/App/app/(portal)/crewing/verification/page.tsx b/App/app/(portal)/crewing/verification/page.tsx index 60372ac..58a9e6f 100644 --- a/App/app/(portal)/crewing/verification/page.tsx +++ b/App/app/(portal)/crewing/verification/page.tsx @@ -16,9 +16,10 @@ export default async function VerificationPage() { const role = session.user.role; const canDocs = hasPermission(role, "verify_site_records"); const canBankEpf = hasPermission(role, "verify_bank_epf"); - if (!canDocs && !canBankEpf) redirect("/dashboard"); + const canAppraisals = hasPermission(role, "verify_appraisal"); + if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard"); - const [docs, bank, epf] = await Promise.all([ + const [docs, bank, epf, appraisals] = await Promise.all([ canDocs ? db.seafarerDocument.findMany({ where: { verificationStatus: "PENDING" }, @@ -39,6 +40,13 @@ export default async function VerificationPage() { canBankEpf ? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } }) : [], + canAppraisals + ? db.appraisal.findMany({ + where: { status: "SUBMITTED" }, + orderBy: { createdAt: "asc" }, + include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } }, + }) + : [], ]); return ( @@ -57,8 +65,10 @@ 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 }))} 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 6588bef..fb3592b 100644 --- a/App/app/(portal)/crewing/verification/verification-manager.tsx +++ b/App/app/(portal)/crewing/verification/verification-manager.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import type { SeafarerDocType } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { verifyDocument, verifyBankEpf } from "./actions"; +import { verifyAppraisal } from "../appraisals/actions"; 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() : "—"); @@ -13,6 +14,7 @@ const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Dat type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string }; 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 }; function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) { const router = useRouter(); @@ -69,7 +71,7 @@ function Card({ title, sub, empty, children }: { title: string; sub: string; emp ); } -export function VerificationManager({ docs, bank, epf, canDocs, canBankEpf }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; canDocs: boolean; canBankEpf: boolean }) { +export function VerificationManager({ docs, bank, epf, appraisals, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) { return (
@@ -134,6 +136,25 @@ export function VerificationManager({ docs, bank, epf, canDocs, canBankEpf }: { )} + + {canAppraisals && ( + + + CrewRankPeriodComments + + + {appraisals.map((a) => ( + + {a.crewName} + {a.rank} + {a.period} + {a.comments ?? "—"} + verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /> + + ))} + + + )}
); } diff --git a/App/lib/appraisal-state-machine.ts b/App/lib/appraisal-state-machine.ts new file mode 100644 index 0000000..8b23f12 --- /dev/null +++ b/App/lib/appraisal-state-machine.ts @@ -0,0 +1,40 @@ +import type { AppraisalStatus, Role } from "@prisma/client"; + +// Appraisal lifecycle (Crewing-Implementation-Spec §5.4) — mirrors the other +// crewing state machines. A PM raises the appraisal directly into SUBMITTED; this +// machine governs the two review advances. Rejection is orthogonal (handled in +// the actions: an MPO or Manager declines → REJECTED with remarks). + +export type AppraisalAction = "verify" | "approve"; + +interface Transition { + to: AppraisalStatus; + allowedRoles: Role[]; +} + +const VERIFY_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"]; // verify_appraisal +const APPROVE_ROLES: Role[] = ["MANAGER", "SUPERUSER"]; // approve_appraisal + +const TRANSITIONS: Partial>>> = { + SUBMITTED: { + verify: { to: "MPO_VERIFIED", allowedRoles: VERIFY_ROLES }, + }, + MPO_VERIFIED: { + approve: { to: "MANAGER_APPROVED", allowedRoles: APPROVE_ROLES }, + }, +}; + +export function getTransition(from: AppraisalStatus, action: AppraisalAction): Transition | null { + return TRANSITIONS[from]?.[action] ?? null; +} + +export function canPerformAction(from: AppraisalStatus, action: AppraisalAction, role: Role): boolean { + return getTransition(from, action)?.allowedRoles.includes(role) ?? false; +} + +// A review may be declined while the appraisal is still in flight. +const REJECTABLE_FROM: AppraisalStatus[] = ["SUBMITTED", "MPO_VERIFIED"]; + +export function canReject(from: AppraisalStatus): boolean { + return REJECTABLE_FROM.includes(from); +} diff --git a/App/lib/notifier.ts b/App/lib/notifier.ts index ef5e4a4..05b6bbd 100644 --- a/App/lib/notifier.ts +++ b/App/lib/notifier.ts @@ -36,7 +36,9 @@ export type CrewNotificationEvent = | "SALARY_FOR_APPROVAL" | "SELECTION_FOR_APPROVAL" | "WAIVER_REQUESTED" - | "LEAVE_FOR_APPROVAL"; + | "LEAVE_FOR_APPROVAL" + | "APPRAISAL_FOR_VERIFICATION" + | "APPRAISAL_FOR_APPROVAL"; interface NotifyParams { event: NotificationEvent; @@ -438,6 +440,8 @@ const CREW_ACTION_LABEL: Record = { SELECTION_FOR_APPROVAL: "Review Selection", WAIVER_REQUESTED: "Review Waiver", LEAVE_FOR_APPROVAL: "Review Leave", + APPRAISAL_FOR_VERIFICATION: "Verify Appraisal", + APPRAISAL_FOR_APPROVAL: "Review Appraisal", }; export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) { diff --git a/App/prisma/migrations/20260622163217_crewing_appraisal/migration.sql b/App/prisma/migrations/20260622163217_crewing_appraisal/migration.sql new file mode 100644 index 0000000..d96f802 --- /dev/null +++ b/App/prisma/migrations/20260622163217_crewing_appraisal/migration.sql @@ -0,0 +1,36 @@ +-- CreateEnum +CREATE TYPE "AppraisalStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'MPO_VERIFIED', 'MANAGER_APPROVED', 'REJECTED'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_SUBMITTED'; +ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_VERIFIED'; +ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_APPROVED'; +ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_REJECTED'; + +-- CreateTable +CREATE TABLE "Appraisal" ( + "id" TEXT NOT NULL, + "assignmentId" TEXT NOT NULL, + "period" TEXT NOT NULL, + "ratings" JSONB, + "comments" TEXT, + "status" "AppraisalStatus" NOT NULL DEFAULT 'SUBMITTED', + "rejectedReason" TEXT, + "addedById" TEXT NOT NULL, + "verifiedById" TEXT, + "approvedById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Appraisal_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Appraisal" ADD CONSTRAINT "Appraisal_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index bf15089..801316e 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -157,6 +157,22 @@ enum CrewActionType { CREW_SIGNED_OFF RECORD_VERIFIED RECORD_REJECTED + APPRAISAL_SUBMITTED + APPRAISAL_VERIFIED + APPRAISAL_APPROVED + APPRAISAL_REJECTED +} + +// ─── Crewing appraisal (Phase 5b, Epic H) ─────────────────────────────────── +// Appraisal lifecycle (Crewing-Implementation-Spec §5.4/§8.14): a PM raises +// (→ SUBMITTED), the MPO verifies (→ MPO_VERIFIED), the Manager approves +// (→ MANAGER_APPROVED); → REJECTED with remarks from either review. +enum AppraisalStatus { + DRAFT + SUBMITTED + MPO_VERIFIED + MANAGER_APPROVED + REJECTED } // ─── Crewing leave & attendance (Phase 4b, Epic G) ────────────────────────── @@ -919,6 +935,25 @@ model CrewAssignment { contractLetter ContractLetter? leaveRequests LeaveRequest[] attendance Attendance[] + appraisals Appraisal[] +} + +// A periodic appraisal on a tour of duty (Phase 5b). Actor ids are denormalised +// strings — the audited actor lives on the CrewAction. +model Appraisal { + id String @id @default(cuid()) + assignmentId String + assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + period String // e.g. "2026" or "2026-Q2" + ratings Json? + comments String? + status AppraisalStatus @default(SUBMITTED) + rejectedReason String? + addedById String + verifiedById String? + approvedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } // Leave applied by the Site In-charge on a crew member's assignment, decided by diff --git a/App/tests/integration/appraisal.test.ts b/App/tests/integration/appraisal.test.ts new file mode 100644 index 0000000..2ffaae1 --- /dev/null +++ b/App/tests/integration/appraisal.test.ts @@ -0,0 +1,108 @@ +/** + * Integration tests for Crewing Phase 5b appraisal: the + * raise (PM) → verify (MPO) → approve (Manager) lifecycle, with rejection paths + * and role gating per §5.4/§6. + */ +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 { raiseAppraisal, verifyAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions"; +import { makeSession, getSeedUser, fd } from "./helpers"; +import type { Role } from "@prisma/client"; + +let managerId: string; +let manningId: string; +let siteStaffId: string; +let rankId: string; +let vesselId: string; + +const SS_EMAIL = "sitestaff@itapp2.local"; +const as = (userId: string, role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); + +async function assignment() { + const c = await db.crewMember.create({ data: { name: "Appraisee", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } }); + 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 }; +} + +async function raise(assignmentId: string) { + as(siteStaffId, "SITE_STAFF"); // PM / site staff raise (raise_appraisal) + const res = await raiseAppraisal(fd({ assignmentId, period: "2026", competence: "4", conduct: "5", safety: "4", comments: "Solid" })); + if (!("ok" in res)) throw new Error("raise failed"); + return res.id!; +} + +beforeAll(async () => { + managerId = (await getSeedUser("manager@pelagia.local")).id; + manningId = (await getSeedUser("manning@pelagia.local")).id; + const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITAPP2-SS", email: SS_EMAIL, name: "SS App2", 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.appraisal.deleteMany({}); + await db.crewAssignment.deleteMany({}); + await db.crewMember.deleteMany({}); + vi.clearAllMocks(); +}); + +afterAll(async () => { + await db.user.deleteMany({ where: { email: SS_EMAIL } }); +}); + +describe("appraisal lifecycle", () => { + it("raise → verify (MPO) → approve (Manager)", async () => { + const { assignmentId } = await assignment(); + const id = await raise(assignmentId); + expect((await db.appraisal.findUniqueOrThrow({ where: { id } })).status).toBe("SUBMITTED"); + + as(manningId, "MANNING"); + expect("ok" in (await verifyAppraisal(id, true))).toBe(true); + const verified = await db.appraisal.findUniqueOrThrow({ where: { id } }); + expect(verified.status).toBe("MPO_VERIFIED"); + expect(verified.verifiedById).toBe(manningId); + + // MPO cannot approve + expect(await approveAppraisal(id, true)).not.toHaveProperty("ok"); + + as(managerId, "MANAGER"); + expect("ok" in (await approveAppraisal(id, true))).toBe(true); + const approved = await db.appraisal.findUniqueOrThrow({ where: { id } }); + expect(approved.status).toBe("MANAGER_APPROVED"); + expect(approved.approvedById).toBe(managerId); + }); + + it("MPO rejects with remarks", async () => { + const { assignmentId } = await assignment(); + const id = await raise(assignmentId); + as(manningId, "MANNING"); + expect("error" in (await verifyAppraisal(id, false))).toBe(true); // remarks required + expect("ok" in (await verifyAppraisal(id, false, "Incomplete"))).toBe(true); + const a = await db.appraisal.findUniqueOrThrow({ where: { id } }); + expect(a.status).toBe("REJECTED"); + expect(a.rejectedReason).toBe("Incomplete"); + }); + + it("raise is rejected for a role without raise_appraisal (MPO)", async () => { + const { assignmentId } = await assignment(); + as(manningId, "MANNING"); // MPO does not hold raise_appraisal + expect(await raiseAppraisal(fd({ assignmentId, period: "2026" }))).toEqual({ error: "Unauthorized" }); + }); + + it("verify is rejected for a role without verify_appraisal (site staff)", async () => { + const { assignmentId } = await assignment(); + const id = await raise(assignmentId); + as(siteStaffId, "SITE_STAFF"); + expect(await verifyAppraisal(id, true)).toEqual({ error: "Unauthorized" }); + }); +}); diff --git a/App/tests/unit/appraisal-state-machine.test.ts b/App/tests/unit/appraisal-state-machine.test.ts new file mode 100644 index 0000000..0e0739b --- /dev/null +++ b/App/tests/unit/appraisal-state-machine.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { getTransition, canPerformAction, canReject } from "@/lib/appraisal-state-machine"; + +// Appraisal lifecycle (Crewing-Implementation-Spec §5.4). +describe("Appraisal state machine", () => { + it("MPO verifies a SUBMITTED appraisal", () => { + expect(getTransition("SUBMITTED", "verify")?.to).toBe("MPO_VERIFIED"); + expect(canPerformAction("SUBMITTED", "verify", "MANNING")).toBe(true); + expect(canPerformAction("SUBMITTED", "verify", "MANAGER")).toBe(true); + expect(canPerformAction("SUBMITTED", "verify", "SITE_STAFF")).toBe(false); + }); + + it("Manager approves an MPO_VERIFIED appraisal (not the MPO)", () => { + expect(getTransition("MPO_VERIFIED", "approve")?.to).toBe("MANAGER_APPROVED"); + expect(canPerformAction("MPO_VERIFIED", "approve", "MANAGER")).toBe(true); + expect(canPerformAction("MPO_VERIFIED", "approve", "MANNING")).toBe(false); + }); + + it("rejects out-of-order actions", () => { + expect(getTransition("SUBMITTED", "approve")).toBeNull(); + expect(getTransition("MANAGER_APPROVED", "verify")).toBeNull(); + }); + + it("is rejectable only while in review", () => { + expect(canReject("SUBMITTED")).toBe(true); + expect(canReject("MPO_VERIFIED")).toBe(true); + expect(canReject("MANAGER_APPROVED")).toBe(false); + expect(canReject("REJECTED")).toBe(false); + }); +});