Merge pull request 'feat(crewing): Phase 5b — appraisal (flagged)' (#74) from feat/crewing-appraisal into feat/crewing-verification
Reviewed-on: #74
This commit is contained in:
commit
58f6ff03af
14 changed files with 537 additions and 11 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal file
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
40
App/lib/appraisal-state-machine.ts
Normal file
40
App/lib/appraisal-state-machine.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
App/tests/integration/appraisal.test.ts
Normal file
108
App/tests/integration/appraisal.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
30
App/tests/unit/appraisal-state-machine.test.ts
Normal file
30
App/tests/unit/appraisal-state-machine.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue