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>
This commit is contained in:
Hardik 2026-06-22 22:09:32 +05:30
parent 8982118eee
commit c14a22588e
14 changed files with 537 additions and 11 deletions

View file

@ -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`.

View file

@ -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<CrewApprovalKind, string> = { 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<CrewApprovalKind, string> = { 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<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary,
SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver,
LEAVE: (id) => decideLeave(id, true),
APPRAISAL: (id) => approveAppraisal(id, true),
};
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => 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 }) {

View file

@ -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 (
<div>

View file

@ -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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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 };
}

View file

@ -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<AppraisalStatus, "outline" | "warning" | "default" | "success" | "danger"> = {
DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger",
};
export function CrewProfile(p: Props) {
const [tab, setTab] = useState<Tab>("Documents");
const router = useRouter();
@ -79,10 +88,54 @@ export function CrewProfile(p: Props) {
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
{tab === "Appraisals" && <Appraisals rows={p.appraisals} ctx={p.appraisalCtx} onDone={refresh} />}
</div>
);
}
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 (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No appraisals.</p> : rows.map((a) => (
<div key={a.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
<div>
<p className="text-sm text-neutral-900">{a.period} <Badge variant={APPRAISAL_VARIANT[a.status]}>{a.status.replace(/_/g, " ").toLowerCase()}</Badge></p>
<p className="text-xs text-neutral-500">
{a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"}
{a.comments ? ` · ${a.comments}` : ""}
</p>
</div>
</div>
))}
{ctx.canRaise && ctx.assignmentId && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Period (e.g. 2026 or 2026-Q2)" value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} required />
<input className={INPUT} placeholder="Comments" value={f.comments} onChange={(e) => setF({ ...f, comments: e.target.value })} />
{(["competence", "conduct", "safety"] as const).map((k) => (
<label key={k} className="text-xs text-neutral-500 capitalize">{k}
<select className={INPUT} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })}>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}</select>
</label>
))}
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.period}>{pending ? "Submitting…" : "Submit appraisal"}</button></div>
</form>
)}
{!ctx.canRaise && <p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.</p>}
</Section>
);
}
function Section({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
}

View file

@ -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 (
<CrewProfile
crew={{
@ -94,6 +100,14 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id
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) }}
/>
);
}

View file

@ -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}
/>
);
}

View file

@ -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 (
<div className="max-w-4xl">
<div className="mb-6">
@ -134,6 +136,25 @@ export function VerificationManager({ docs, bank, epf, canDocs, canBankEpf }: {
</tbody>
</Card>
)}
{canAppraisals && (
<Card title="Appraisals" sub="Verify or reject submitted appraisals (MPO)." empty={appraisals.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">Rank</th><th className="px-4 py-3">Period</th><th className="px-4 py-3">Comments</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{appraisals.map((a) => (
<tr key={a.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">{a.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{a.rank}</td>
<td className="px-4 py-3 text-neutral-700">{a.period}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{a.comments ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
</div>
);
}

View file

@ -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<Record<AppraisalStatus, Partial<Record<AppraisalAction, Transition>>>> = {
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);
}

View file

@ -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<CrewNotificationEvent, string> = {
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) {

View file

@ -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;

View file

@ -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

View file

@ -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<unknown>).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" });
});
});

View file

@ -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);
});
});