diff --git a/App/app/(portal)/admin/crew/admin-crew-manager.tsx b/App/app/(portal)/admin/crew/admin-crew-manager.tsx index 2fe7d7d..5dc725c 100644 --- a/App/app/(portal)/admin/crew/admin-crew-manager.tsx +++ b/App/app/(portal)/admin/crew/admin-crew-manager.tsx @@ -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 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()); type Opt = { id: string; name: string }; @@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt setF({ ...f, name: e.target.value })} required /> - + setF({ ...f, email: e.target.value })} /> diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx index 16884e5..f8c8e00 100644 --- a/App/app/(portal)/crewing/candidates/[id]/page.tsx +++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx @@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<

{c.name}

{STATUS_LABEL[c.status]} - {c.source === "EX_HAND" && ( + {c.type === "EX_HAND" && ( Returning crew )}
- {c.source === "EX_HAND" && ( + {c.type === "EX_HAND" && (
Returning crew. The interview may be waived with Manager approval.{" "} {c.experienceRecords.length === 0 && c.documents.length === 0 ? ( diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts index 63972ca..68263b1 100644 --- a/App/app/(portal)/crewing/candidates/actions.ts +++ b/App/app/(portal)/crewing/candidates/actions.ts @@ -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). async function storeCv(formData: FormData, crewMemberId: string): Promise { const file = formData.get("cv"); @@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise { const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; 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 + // candidate 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. (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.) - 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" } }), + 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 } } }, }, - 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 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, source: d.source, - type, - status, + // The candidate form always intakes a fresh NEW candidate. Ex-hand status + // is an office/admin designation set on the crew record, not here. + type: "NEW", + status: "CANDIDATE", appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, @@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise const parsed = parse(formData); if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; const d = parsed.data; - const { type, status } = derive(d.source); const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } }); if (!existing) return { error: "Candidate not found" }; @@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise data: { name: d.name, source: d.source, - // Don't downgrade an onboarded employee back to a candidate via an edit. - type, - status: existing.status === "EMPLOYEE" ? existing.status : status, + // type/status are left untouched — ex-hand / employee designation is owned + // by the office (admin crew record + sign-off), never by a candidate edit. appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null, experienceMonths: d.experienceMonths, diff --git a/App/app/(portal)/crewing/candidates/candidate-form.tsx b/App/app/(portal)/crewing/candidates/candidate-form.tsx index ed71b1e..f5bac50 100644 --- a/App/app/(portal)/crewing/candidates/candidate-form.tsx +++ b/App/app/(portal)/crewing/candidates/candidate-form.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import type { CandidateSource } from "@prisma/client"; import { AdminDialog } from "@/components/ui/admin-dialog"; import { addCandidate, updateCandidate } from "./actions"; -import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui"; +import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui"; 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"; @@ -46,7 +46,7 @@ function CandidateFields({
@@ -64,7 +64,7 @@ function CandidateFields({
- +