fix(crewing): make rank-held universal, ex-hand an admin-only flag
Rank held applies to every candidate, not just ex-hands; it auto-updates for returning crew on sign-off. Ex-hand designation is decoupled from the Source dropdown and owned by the office: - Candidate form: drop the EX_HAND source option, relabel "Rank held (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate no longer rewrites type/status, so an admin-set EX_HAND or onboarded EMPLOYEE is never clobbered by a candidate edit. - Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand (returning crew)" checkbox -- the only place ex-hand is tagged. - List/detail ex-hand indicators key on type === EX_HAND (not source). - Sign-off preserves the original recruitment source when flipping to EX_HAND. - Tests seed EX_HAND rows directly; assert candidate intake stays NEW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
81744d1fa8
commit
e951a44a67
9 changed files with 92 additions and 73 deletions
|
|
@ -15,7 +15,6 @@ const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-m
|
||||||
|
|
||||||
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
||||||
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
const TYPES: CandidateType[] = ["NEW", "EX_HAND"];
|
|
||||||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
type Opt = { id: string; name: string };
|
type Opt = { id: string; name: string };
|
||||||
|
|
@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt
|
||||||
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||||
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
<label className="flex items-center gap-2 px-1 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" checked={f.type === "EX_HAND"} onChange={(e) => setF({ ...f, type: e.target.checked ? "EX_HAND" : "NEW" })} />
|
||||||
|
Ex-hand (returning crew)
|
||||||
|
</label>
|
||||||
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
||||||
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||||
{c.source === "EX_HAND" && (
|
{c.type === "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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{c.source === "EX_HAND" && (
|
{c.type === "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> The interview may be waived with Manager approval.{" "}
|
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
||||||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,6 @@ function parse(formData: FormData) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// An EX_HAND source means a returning crew member; everyone else is NEW. The
|
|
||||||
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
|
|
||||||
function derive(source: CandidateSource) {
|
|
||||||
const isExHand = source === "EX_HAND";
|
|
||||||
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store an optional CV upload and return its storage key (null if none).
|
// Store an optional CV upload and return its storage key (null if none).
|
||||||
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
||||||
const file = formData.get("cv");
|
const file = formData.get("cv");
|
||||||
|
|
@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const { type, status } = derive(d.source);
|
|
||||||
|
|
||||||
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
// 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
|
// candidate is matched to their existing EX_HAND pool record by a stable key —
|
||||||
// pool record by a stable key — email when given, else an exact name match —
|
// email when given, else an exact name match — and the SAME row is reused (so
|
||||||
// and the SAME row is reused (so their tour history, documents and bank stay on
|
// their tour history, documents and bank stay on file) rather than creating a
|
||||||
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
|
// duplicate. (Ex-hand is set by the office on the admin crew record; the
|
||||||
|
// candidate form never tags it directly. Heuristic: with no DOB on file a
|
||||||
// name-only match can in theory collide; email is preferred when available.)
|
// name-only match can in theory collide; email is preferred when available.)
|
||||||
if (d.source !== "EX_HAND") {
|
const match = await db.crewMember.findFirst({
|
||||||
const match = await db.crewMember.findFirst({
|
where: {
|
||||||
where: {
|
status: "EX_HAND",
|
||||||
status: "EX_HAND",
|
...(d.email
|
||||||
...(d.email
|
? { email: { equals: d.email, mode: "insensitive" } }
|
||||||
? { email: { equals: d.email, mode: "insensitive" } }
|
: { name: { equals: d.name, 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 } } },
|
||||||
},
|
},
|
||||||
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
|
|
||||||
});
|
});
|
||||||
if (match) {
|
const cvKey = await storeCv(formData, updated.id);
|
||||||
const updated = await db.crewMember.update({
|
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
|
||||||
where: { id: match.id },
|
revalidatePath(LIST_PATH);
|
||||||
data: {
|
return { ok: true, id: updated.id };
|
||||||
// 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,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
type,
|
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
|
||||||
status,
|
// is an office/admin designation set on the crew record, not here.
|
||||||
|
type: "NEW",
|
||||||
|
status: "CANDIDATE",
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const { type, status } = derive(d.source);
|
|
||||||
|
|
||||||
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
||||||
if (!existing) return { error: "Candidate not found" };
|
if (!existing) return { error: "Candidate not found" };
|
||||||
|
|
@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
// Don't downgrade an onboarded employee back to a candidate via an edit.
|
// type/status are left untouched — ex-hand / employee designation is owned
|
||||||
type,
|
// by the office (admin crew record + sign-off), never by a candidate edit.
|
||||||
status: existing.status === "EMPLOYEE" ? existing.status : status,
|
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import type { CandidateSource } from "@prisma/client";
|
import type { CandidateSource } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { addCandidate, updateCandidate } from "./actions";
|
import { addCandidate, updateCandidate } from "./actions";
|
||||||
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
@ -46,7 +46,7 @@ function CandidateFields({
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
||||||
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
||||||
{SOURCE_OPTIONS.map((s) => (
|
{FORM_SOURCE_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -64,7 +64,7 @@ function CandidateFields({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
|
||||||
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{ranks.map((r) => (
|
{ranks.map((r) => (
|
||||||
|
|
@ -131,7 +131,9 @@ function emptyState(): FieldState {
|
||||||
function stateFrom(c: EditableCandidate): FieldState {
|
function stateFrom(c: EditableCandidate): FieldState {
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
source: c.source,
|
// Ex-hand is an admin-only designation; the candidate form only edits origin.
|
||||||
|
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
|
||||||
|
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
|
||||||
appliedRankId: c.appliedRankId ?? "",
|
appliedRankId: c.appliedRankId ?? "",
|
||||||
currentRankId: c.currentRankId ?? "",
|
currentRankId: c.currentRankId ?? "",
|
||||||
experienceMonths: String(c.experienceMonths),
|
experienceMonths: String(c.experienceMonths),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@ export const SOURCE_LABEL: Record<CandidateSource, string> = {
|
||||||
|
|
||||||
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
|
||||||
|
// Ex-hand is now its own checkbox (not a source) — the Add/Edit form offers only
|
||||||
|
// the real origins. EX_HAND stays in the enum/label for legacy rows created
|
||||||
|
// before the split.
|
||||||
|
export const FORM_SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
|
||||||
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
||||||
PROSPECT: "Prospect",
|
PROSPECT: "Prospect",
|
||||||
CANDIDATE: "Candidate",
|
CANDIDATE: "Candidate",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
||||||
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
||||||
|
|
@ -12,6 +12,7 @@ type CandidateRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source: CandidateSource;
|
source: CandidateSource;
|
||||||
|
type: CandidateType;
|
||||||
status: CrewStatus;
|
status: CrewStatus;
|
||||||
appliedRankId: string | null;
|
appliedRankId: string | null;
|
||||||
appliedRank: string | null;
|
appliedRank: string | null;
|
||||||
|
|
@ -54,13 +55,12 @@ function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
|
||||||
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||||
|
{c.type === "EX_HAND" && (
|
||||||
|
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
|
||||||
|
)}
|
||||||
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</td>
|
||||||
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
|
|
||||||
{SOURCE_LABEL[c.source]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export default async function CandidatesPage() {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
source: c.source,
|
source: c.source,
|
||||||
|
type: c.type,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
appliedRankId: c.appliedRankId,
|
appliedRankId: c.appliedRankId,
|
||||||
appliedRank: c.appliedRank?.name ?? null,
|
appliedRank: c.appliedRank?.name ?? null,
|
||||||
|
|
|
||||||
|
|
@ -304,10 +304,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
source: "internal",
|
source: "internal",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
|
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
|
||||||
|
// returning hand. The ex-hand flag lives on type/status — their original
|
||||||
|
// source (how they were first recruited) is preserved. currentRank (rank
|
||||||
|
// held) is refreshed to the tour they just signed off from.
|
||||||
await tx.crewMember.update({
|
await tx.crewMember.update({
|
||||||
where: { id: assignment.crewMemberId },
|
where: { id: assignment.crewMemberId },
|
||||||
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
|
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
|
||||||
});
|
});
|
||||||
await tx.crewAction.create({
|
await tx.crewAction.create({
|
||||||
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,21 @@ const SS_EMAIL = "sitestaff@itcand.local";
|
||||||
const as = (userId: string, role: Role) =>
|
const as = (userId: string, role: Role) =>
|
||||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||||
|
|
||||||
|
// Ex-hand is an office/admin designation (set on the admin crew record, not the
|
||||||
|
// candidate form) — seed such rows directly for the recognition tests.
|
||||||
|
const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
|
||||||
|
db.crewMember.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
type: "EX_HAND",
|
||||||
|
status: "EX_HAND",
|
||||||
|
source: "CAREERS",
|
||||||
|
email: data.email ?? null,
|
||||||
|
experienceMonths: data.experienceMonths ?? 0,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||||
const ss = await db.user.upsert({
|
const ss = await db.user.upsert({
|
||||||
|
|
@ -71,12 +86,14 @@ describe("addCandidate", () => {
|
||||||
expect(c.actions[0].actorId).toBe(managerId);
|
expect(c.actions[0].actorId).toBe(managerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
|
it("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
// Even if an ex-hand hint is smuggled into the form data, intake stays
|
||||||
|
// NEW/CANDIDATE; ex-hand is set only on the admin crew record.
|
||||||
|
await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
|
||||||
const c = await db.crewMember.findFirstOrThrow();
|
const c = await db.crewMember.findFirstOrThrow();
|
||||||
expect(c.type).toBe("EX_HAND");
|
expect(c.type).toBe("NEW");
|
||||||
expect(c.status).toBe("EX_HAND");
|
expect(c.status).toBe("CANDIDATE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a name", async () => {
|
it("requires a name", async () => {
|
||||||
|
|
@ -98,8 +115,7 @@ describe("addCandidate", () => {
|
||||||
describe("ex-hand recognition + ordering (B3)", () => {
|
describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
|
const exhand = await seedExHand({ name: "Ravi Old", 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.
|
// 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 }));
|
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
||||||
|
|
@ -115,16 +131,15 @@ describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
|
|
||||||
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
const exhand = await seedExHand({ name: "Returning Ravi" });
|
||||||
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
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("ok" in res && res.id).toBe(exhand.id);
|
||||||
expect(await db.crewMember.count()).toBe(1);
|
expect(await db.crewMember.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not match a different person → creates a new candidate", async () => {
|
it("does not match a different person → creates a new candidate", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
|
await seedExHand({ name: "Ex One", email: "one@ex.com" });
|
||||||
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
||||||
expect(await db.crewMember.count()).toBe(2);
|
expect(await db.crewMember.count()).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +147,7 @@ describe("ex-hand recognition + ordering (B3)", () => {
|
||||||
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
||||||
as(managerId, "MANAGER");
|
as(managerId, "MANAGER");
|
||||||
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
||||||
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
|
await seedExHand({ name: "Ex Second" });
|
||||||
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
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].status).toBe("EX_HAND");
|
||||||
expect(el.props.candidates[0].name).toBe("Ex Second");
|
expect(el.props.candidates[0].name).toBe("Ex Second");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue