diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx
index 2ea86f8..16884e5 100644
--- a/App/app/(portal)/crewing/candidates/[id]/page.tsx
+++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx
@@ -21,7 +21,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
const { id } = await params;
const c = await db.crewMember.findUnique({
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();
@@ -53,8 +59,42 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
{c.source === "EX_HAND" && (
-
Returning crew. 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).
+
Returning crew. The interview may be waived with Manager approval.{" "}
+ {c.experienceRecords.length === 0 && c.documents.length === 0 ? (
+
No prior records are on file yet.
+ ) : (
+
Prior records on file from earlier assignments:
+ )}
+
+ {c.experienceRecords.length > 0 && (
+
+
Tour history
+
+ {c.experienceRecords.map((e) => (
+ -
+ {e.rank?.name ?? "—"}
+ {e.vesselType ? ` · ${e.vesselType}` : ""}
+ {e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
+ {e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""}
+
+ ))}
+
+
+ )}
+
+ {c.documents.length > 0 && (
+
+
Documents on file
+
+ {c.documents.map((doc) => (
+
+ {doc.docType}
+ {doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
+
+ ))}
+
+
+ )}
)}
diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts
index 5d7c928..63972ca 100644
--- a/App/app/(portal)/crewing/candidates/actions.ts
+++ b/App/app/(portal)/crewing/candidates/actions.ts
@@ -76,6 +76,45 @@ export async function addCandidate(formData: FormData): Promise {
const d = parsed.data;
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({
data: {
name: d.name,
diff --git a/App/app/(portal)/crewing/candidates/page.tsx b/App/app/(portal)/crewing/candidates/page.tsx
index 80adfae..065ef37 100644
--- a/App/app/(portal)/crewing/candidates/page.tsx
+++ b/App/app/(portal)/crewing/candidates/page.tsx
@@ -46,5 +46,9 @@ export default async function CandidatesPage() {
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 ;
}
diff --git a/App/tests/integration/candidates.test.ts b/App/tests/integration/candidates.test.ts
index 0baafe5..915ad8b 100644
--- a/App/tests/integration/candidates.test.ts
+++ b/App/tests/integration/candidates.test.ts
@@ -6,14 +6,21 @@
* its CrewAction rows) wholesale — no pre-existing rows to preserve.
*/
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("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 }));
+// 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 { db } from "@/lib/db";
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
+import CandidatesPage from "@/app/(portal)/crewing/candidates/page";
import { makeSession, getSeedUser, fd } from "./helpers";
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", () => {
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
as(managerId, "MANAGER");