feat(crewing): ex-hand recognition (B3)
- 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>
This commit is contained in:
parent
0679883273
commit
df950c7253
4 changed files with 137 additions and 3 deletions
|
|
@ -21,7 +21,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const c = await db.crewMember.findUnique({
|
const c = await db.crewMember.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
|
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();
|
if (!c) notFound();
|
||||||
|
|
||||||
|
|
@ -53,8 +59,42 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
|
|
||||||
{c.source === "EX_HAND" && (
|
{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">
|
<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
|
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
||||||
assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase).
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,45 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const { type, status } = derive(d.source);
|
const { type, status } = derive(d.source);
|
||||||
|
|
||||||
|
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
||||||
|
// candidate (not already tagged EX_HAND) is matched to their existing EX_HAND
|
||||||
|
// pool record by a stable key — email when given, else an exact name match —
|
||||||
|
// and the SAME row is reused (so their tour history, documents and bank stay on
|
||||||
|
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
|
||||||
|
// name-only match can in theory collide; email is preferred when available.)
|
||||||
|
if (d.source !== "EX_HAND") {
|
||||||
|
const match = await db.crewMember.findFirst({
|
||||||
|
where: {
|
||||||
|
status: "EX_HAND",
|
||||||
|
...(d.email
|
||||||
|
? { email: { equals: d.email, mode: "insensitive" } }
|
||||||
|
: { name: { equals: d.name, mode: "insensitive" } }),
|
||||||
|
},
|
||||||
|
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
const updated = await db.crewMember.update({
|
||||||
|
where: { id: match.id },
|
||||||
|
data: {
|
||||||
|
// Keep EX_HAND type/status; refresh the application's details, never
|
||||||
|
// discarding prior history (take the larger recorded experience).
|
||||||
|
appliedRankId: d.appliedRankId || match.appliedRankId,
|
||||||
|
currentRankId: d.currentRankId || match.currentRankId,
|
||||||
|
email: d.email || match.email,
|
||||||
|
phone: d.phone || match.phone,
|
||||||
|
notes: d.notes || match.notes,
|
||||||
|
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cvKey = await storeCv(formData, updated.id);
|
||||||
|
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: updated.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const candidate = await db.crewMember.create({
|
const candidate = await db.crewMember.create({
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
|
|
|
||||||
|
|
@ -46,5 +46,9 @@ export default async function CandidatesPage() {
|
||||||
hasCv: Boolean(c.cvKey),
|
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} />;
|
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,21 @@
|
||||||
* its CrewAction rows) wholesale — no pre-existing rows to preserve.
|
* its CrewAction rows) wholesale — no pre-existing rows to preserve.
|
||||||
*/
|
*/
|
||||||
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
// The list page's JSX compiles to classic React.createElement in the node runner.
|
||||||
|
(globalThis as unknown as { React: typeof React }).React = React;
|
||||||
|
|
||||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
||||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||||
|
// We read the page element's props directly; the client component is irrelevant.
|
||||||
|
vi.mock("@/app/(portal)/crewing/candidates/candidates-manager", () => ({ CandidatesManager: () => null }));
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
|
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
|
||||||
|
import CandidatesPage from "@/app/(portal)/crewing/candidates/page";
|
||||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -88,6 +95,50 @@ describe("addCandidate", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
|
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
|
||||||
|
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
||||||
|
|
||||||
|
// Re-applies as a fresh careers candidate with the same email → recognized.
|
||||||
|
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
||||||
|
expect("ok" in res && res.id).toBe(exhand.id);
|
||||||
|
expect(await db.crewMember.count()).toBe(1); // no duplicate row
|
||||||
|
|
||||||
|
const after = await db.crewMember.findUniqueOrThrow({ where: { id: exhand.id }, include: { actions: true } });
|
||||||
|
expect(after.status).toBe("EX_HAND");
|
||||||
|
expect(after.appliedRankId).toBe(rankId);
|
||||||
|
expect(after.experienceMonths).toBe(120); // prior history preserved (max)
|
||||||
|
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
||||||
|
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
||||||
|
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
||||||
|
expect("ok" in res && res.id).toBe(exhand.id);
|
||||||
|
expect(await db.crewMember.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match a different person → creates a new candidate", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
|
||||||
|
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
||||||
|
expect(await db.crewMember.count()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
||||||
|
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
|
||||||
|
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
||||||
|
expect(el.props.candidates[0].status).toBe("EX_HAND");
|
||||||
|
expect(el.props.candidates[0].name).toBe("Ex Second");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("updateCandidate", () => {
|
describe("updateCandidate", () => {
|
||||||
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
|
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue