First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).
What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
(3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
guard flag + manage_candidates, write a CrewAction, optional CV upload via
buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
min-experience filters as removable chips + match count + Clear all; Add-candidate
modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
added to the flag-gated Crewing nav (Manager + MPO).
Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
no-downgrade, permission gating. type-check clean; full unit (225) + integration
(153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.
Deferred: public careers intake API (A2, §13 open question); CV parsing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { hasPermission } from "@/lib/permissions";
|
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
|
import { redirect, notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
|
|
import type { Metadata } from "next";
|
|
|
|
export const metadata: Metadata = { title: "Candidate" };
|
|
|
|
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
if (!CREWING_ENABLED) notFound();
|
|
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
|
|
|
const { id } = await params;
|
|
const c = await db.crewMember.findUnique({
|
|
where: { id },
|
|
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
|
|
});
|
|
if (!c) notFound();
|
|
|
|
const profile: [string, string][] = [
|
|
["Rank applied", c.appliedRank?.name ?? "—"],
|
|
["Last rank held", c.currentRank?.name ?? "—"],
|
|
["Experience", experienceLabel(c.experienceMonths)],
|
|
["Vessel type", c.vesselTypeExperience ?? "—"],
|
|
["Source", SOURCE_LABEL[c.source]],
|
|
["Email", c.email ?? "—"],
|
|
["Phone", c.phone ?? "—"],
|
|
];
|
|
|
|
return (
|
|
<div className="max-w-4xl">
|
|
<Link href="/crewing/candidates" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
|
<ArrowLeft className="h-4 w-4" /> Candidates
|
|
</Link>
|
|
|
|
<div className="mb-6 flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
|
{c.source === "EX_HAND" && (
|
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{c.source === "EX_HAND" && (
|
|
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
|
<strong>Returning crew.</strong> Prior documents, bank details and tour history are on file from earlier
|
|
assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase).
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Profile */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
|
|
</div>
|
|
<dl className="divide-y divide-neutral-100">
|
|
{profile.map(([k, v]) => (
|
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
{c.notes && (
|
|
<div className="px-4 py-3 border-t border-neutral-100">
|
|
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
|
|
<p className="text-sm text-neutral-700">{c.notes}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recruitment pipeline — Phase 3b */}
|
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Recruitment</h2>
|
|
</div>
|
|
<p className="px-4 py-12 text-center text-sm text-neutral-400">
|
|
The 7-stage recruitment pipeline (shortlist → competency & references → docs →
|
|
salary → proposed → interview → selected) arrives in the next phase. Applications
|
|
against requisitions will appear here.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|