pelagia-portal/App/app/(portal)/approvals/crewing-approvals.tsx
Hardik c14a22588e
All checks were successful
PR checks / checks (pull_request) Successful in 40s
PR checks / integration (pull_request) Successful in 30s
feat(crewing): Phase 5b — appraisal (flagged)
Final slice of Phase 5. The appraisal lifecycle raise → verify → approve across
three role-gated surfaces, per Crewing-Implementation-Spec §5.4/§8.14. Stacks on
5a verification. Behind NEXT_PUBLIC_CREWING_ENABLED. Completes Phase 5.

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

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

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

120 lines
5.6 KiB
TypeScript

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import {
approveSalary,
returnSalary,
selectCandidate,
returnSelection,
approveInterviewWaiver,
declineInterviewWaiver,
} from "../crewing/applications/actions";
import { decideLeave } from "../crewing/leave/actions";
import { approveAppraisal } from "../crewing/appraisals/actions";
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE" | "APPRAISAL";
export type CrewApprovalItem = {
id: string; // applicationId, or leaveRequestId for LEAVE
kind: CrewApprovalKind;
candidateName: string;
rank: string;
requisitionCode: string;
detail: string;
link: string;
};
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 }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [returnOpen, setReturnOpen] = useState(false);
const [reason, setReason] = useState("");
async function approve() {
setPending(true); setError("");
const res = await approveFn[item.kind](item.id);
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function doReturn(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await returnFn[item.kind](item.id, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
}
return (
<tr className="hover:bg-neutral-50">
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3">
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
<form onSubmit={doReturn} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Return</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
return (
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)}
</tbody>
</table>
</div>
</div>
);
}