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>
120 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|