- 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>
54 lines
1.9 KiB
TypeScript
54 lines
1.9 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 { CandidatesManager } from "./candidates-manager";
|
|
import type { Metadata } from "next";
|
|
|
|
export const metadata: Metadata = { title: "Candidates" };
|
|
|
|
export default async function CandidatesPage() {
|
|
if (!CREWING_ENABLED) notFound();
|
|
|
|
const session = await auth();
|
|
if (!session?.user) redirect("/login");
|
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
|
|
|
const [candidates, ranks] = await Promise.all([
|
|
db.crewMember.findMany({
|
|
// Active employees live in the Crew directory (Phase 4); the pool is
|
|
// everyone still a candidate / ex-hand (spec §8.6 R9).
|
|
where: { status: { not: "EMPLOYEE" } },
|
|
orderBy: { createdAt: "desc" },
|
|
include: {
|
|
appliedRank: { select: { name: true } },
|
|
currentRank: { select: { name: true } },
|
|
},
|
|
}),
|
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
|
]);
|
|
|
|
const rows = candidates.map((c) => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
source: c.source,
|
|
status: c.status,
|
|
appliedRankId: c.appliedRankId,
|
|
appliedRank: c.appliedRank?.name ?? null,
|
|
currentRankId: c.currentRankId,
|
|
currentRank: c.currentRank?.name ?? null,
|
|
experienceMonths: c.experienceMonths,
|
|
vesselTypeExperience: c.vesselTypeExperience,
|
|
email: c.email,
|
|
phone: c.phone,
|
|
notes: c.notes,
|
|
hasCv: Boolean(c.cvKey),
|
|
}));
|
|
|
|
// B3 AC2 — ex-hands (proven crew) surface above new candidates by default.
|
|
// Stable sort preserves the createdAt-desc order within each group.
|
|
rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND"));
|
|
|
|
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
|
}
|