- AC1: addCandidate recognizes a returning hand re-entered as a fresh candidate
— matched to their existing EX_HAND pool record by email (preferred) or exact
name — and reuses that row instead of creating a duplicate, preserving tour
history/documents/bank. Audited CANDIDATE_UPDATED { exHandRecognized: true }.
- AC2: the Candidates list sorts ex-hands above new candidates by default
(stable, preserving createdAt order within each group).
- AC3: the candidate detail "Returning crew" callout now renders the matched
member's actual tour history (ExperienceRecord) and documents on file.
candidates.test.ts covers email/name recognition, the no-match path, and the
ex-hand-first page ordering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
6 KiB
TypeScript
137 lines
6 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 } },
|
||
// B3 AC3 — pull the returning hand's history so the callout shows real records.
|
||
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
||
documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: 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> The interview may be waived with Manager approval.{" "}
|
||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||
<span>No prior records are on file yet.</span>
|
||
) : (
|
||
<span>Prior records on file from earlier assignments:</span>
|
||
)}
|
||
|
||
{c.experienceRecords.length > 0 && (
|
||
<div className="mt-3">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
|
||
<ul className="space-y-1">
|
||
{c.experienceRecords.map((e) => (
|
||
<li key={e.id} className="text-sm text-purple-900">
|
||
{e.rank?.name ?? "—"}
|
||
{e.vesselType ? ` · ${e.vesselType}` : ""}
|
||
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
|
||
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{c.documents.length > 0 && (
|
||
<div className="mt-3">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{c.documents.map((doc) => (
|
||
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
|
||
{doc.docType}
|
||
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|