feat(crewing): Phase 3a — candidates / talent pool (flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 27s

First slice of Phase 3 (Epics B/C/D shipped as stacked sub-PRs). Adds the
CrewMember talent-pool spine and the Candidates screens. Behind
NEXT_PUBLIC_CREWING_ENABLED; production unchanged. Stacks on the requisitions
branch (Phase 2).

What's in
- Schema (crewing_candidates migration): CrewMember (spine) + CrewStatus,
  CandidateType, CandidateSource enums; CrewAction gains a nullable crewMemberId;
  CrewActionType += CANDIDATE_ADDED/UPDATED. employeeId is assigned at onboarding
  (3c), so it's nullable here.
- Actions (crewing/candidates/actions.ts): addCandidate / updateCandidate —
  guard flag + manage_candidates, write a CrewAction, optional CV upload via
  buildStorageKey("cv", …) + uploadBuffer (no parsing — A2 deferred). EX_HAND
  source ⇒ type/status EX_HAND; edits never downgrade an EMPLOYEE.
- Screens: /crewing/candidates (master list with search/source/rank-applied/
  min-experience filters as removable chips + match count + Clear all; Add-candidate
  modal) and /crewing/candidates/[id] (profile; pipeline stepper is 3b). Candidates
  added to the flag-gated Crewing nav (Manager + MPO).

Tests & docs
- Integration: candidates.test.ts (7) — add/update, ex-hand derivation, employee
  no-downgrade, permission gating. type-check clean; full unit (225) + integration
  (153) suites green.
- CLAUDE.md "Crewing" section updated with the Phase 3a surface.

Deferred: public careers intake API (A2, §13 open question); CV parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-06-22 17:48:53 +05:30
parent 0b2ed9ac07
commit be6db075dc
12 changed files with 1014 additions and 5 deletions

View file

@ -136,6 +136,13 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`.
- **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it.
**Phase 3a — Candidates (Epic B; spec §8.6):** Phase 3 (candidate intake + 7-stage pipeline + onboarding) ships as **stacked sub-PRs** — 3a candidates, 3b pipeline, 3c onboarding.
- **Model:** `CrewMember` is the talent-pool spine — one row per person, created on first contact and kept through `CANDIDATE → EMPLOYEE → EX_HAND` (`CrewStatus`). `employeeId` is assigned only at onboarding (3c). `CandidateType` (NEW/EX_HAND) and `CandidateSource` derive from the chosen source; `currentRankId` (rank held) + `appliedRankId` (rank applied for). `CrewAction` gained a nullable `crewMemberId` (it now references at most one entity).
- **Actions** (`app/(portal)/crewing/candidates/actions.ts`): `addCandidate` / `updateCandidate` — guard flag + `manage_candidates`, write a `CrewAction`, optional CV upload via `buildStorageKey("cv", …)` + `uploadBuffer`. An EX_HAND source maps to `type/status = EX_HAND`; an edit never downgrades an `EMPLOYEE`.
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
### GST Calculation
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.

View file

@ -0,0 +1,97 @@
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 Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Candidate" };
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
});
if (!c) notFound();
const profile: [string, string][] = [
["Rank applied", c.appliedRank?.name ?? "—"],
["Last rank held", c.currentRank?.name ?? "—"],
["Experience", experienceLabel(c.experienceMonths)],
["Vessel type", c.vesselTypeExperience ?? "—"],
["Source", SOURCE_LABEL[c.source]],
["Email", c.email ?? "—"],
["Phone", c.phone ?? "—"],
];
return (
<div className="max-w-4xl">
<Link href="/crewing/candidates" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Candidates
</Link>
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.source === "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>
)}
</div>
</div>
{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">
<strong>Returning crew.</strong> 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).
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Profile */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{profile.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{c.notes && (
<div className="px-4 py-3 border-t border-neutral-100">
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
<p className="text-sm text-neutral-700">{c.notes}</p>
</div>
)}
</div>
{/* Recruitment pipeline — Phase 3b */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Recruitment</h2>
</div>
<p className="px-4 py-12 text-center text-sm text-neutral-400">
The 7-stage recruitment pipeline (shortlist competency &amp; references docs
salary proposed interview selected) arrives in the next phase. Applications
against requisitions will appear here.
</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,143 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { CandidateSource } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const LIST_PATH = "/crewing/candidates";
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
const candidateSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
source: z.nativeEnum(CandidateSource).default("CAREERS"),
appliedRankId: z.string().optional(),
currentRankId: z.string().optional(),
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
vesselTypeExperience: z.string().optional(),
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
phone: z.string().optional(),
notes: z.string().optional(),
});
function parse(formData: FormData) {
return candidateSchema.safeParse({
name: formData.get("name"),
source: (formData.get("source") as string) || undefined,
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
currentRankId: (formData.get("currentRankId") as string) || undefined,
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined,
email: (formData.get("email") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
}
// 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<string | null> {
const file = formData.get("cv");
if (!(file instanceof File) || file.size === 0) return null;
const key = buildStorageKey("cv", crewMemberId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await uploadBuffer(key, buffer, file.type || "application/octet-stream");
return key;
}
export async function addCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
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 candidate = await db.crewMember.create({
data: {
name: d.name,
source: d.source,
type,
status,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
},
});
const cvKey = await storeCv(formData, candidate.id);
if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: candidate.id };
}
export async function updateCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("id") as string;
if (!id) return { error: "Candidate ID is required" };
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" };
const cvKey = await storeCv(formData, id);
await db.crewMember.update({
where: { id },
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,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
...(cvKey ? { cvKey } : {}),
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true, id };
}

View file

@ -0,0 +1,256 @@
"use client";
import { useRef, useState } from "react";
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";
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";
type RankOpt = { id: string; code: string; name: string };
export type EditableCandidate = {
id: string;
name: string;
source: CandidateSource;
appliedRankId: string | null;
currentRankId: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
};
function CandidateFields({
ranks,
state,
set,
fileRef,
}: {
ranks: RankOpt[];
state: FieldState;
set: <K extends keyof FieldState>(k: K, v: FieldState[K]) => void;
fileRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input className={INPUT} value={state.name} onChange={(e) => set("name", e.target.value)} required />
</div>
<div>
<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)}>
{SOURCE_OPTIONS.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank applied for</label>
<select className={INPUT} value={state.appliedRankId} onChange={(e) => set("appliedRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Experience (months)</label>
<input type="number" min={0} className={INPUT} value={state.experienceMonths} onChange={(e) => set("experienceMonths", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel type</label>
<input className={INPUT} value={state.vesselTypeExperience} onChange={(e) => set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
<input type="email" className={INPUT} value={state.email} onChange={(e) => set("email", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Phone</label>
<input className={INPUT} value={state.phone} onChange={(e) => set("phone", e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">CV (PDF/DOC, optional)</label>
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={state.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Optional" />
</div>
</div>
);
}
type FieldState = {
name: string;
source: CandidateSource;
appliedRankId: string;
currentRankId: string;
experienceMonths: string;
vesselTypeExperience: string;
email: string;
phone: string;
notes: string;
};
function emptyState(): FieldState {
return {
name: "", source: "CAREERS", appliedRankId: "", currentRankId: "",
experienceMonths: "0", vesselTypeExperience: "", email: "", phone: "", notes: "",
};
}
function stateFrom(c: EditableCandidate): FieldState {
return {
name: c.name,
source: c.source,
appliedRankId: c.appliedRankId ?? "",
currentRankId: c.currentRankId ?? "",
experienceMonths: String(c.experienceMonths),
vesselTypeExperience: c.vesselTypeExperience ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
notes: c.notes ?? "",
};
}
function buildFormData(state: FieldState, file: File | undefined, id?: string): FormData {
const fd = new FormData();
if (id) fd.set("id", id);
fd.set("name", state.name);
fd.set("source", state.source);
if (state.appliedRankId) fd.set("appliedRankId", state.appliedRankId);
if (state.currentRankId) fd.set("currentRankId", state.currentRankId);
fd.set("experienceMonths", state.experienceMonths || "0");
if (state.vesselTypeExperience) fd.set("vesselTypeExperience", state.vesselTypeExperience);
if (state.email) fd.set("email", state.email);
if (state.phone) fd.set("phone", state.phone);
if (state.notes) fd.set("notes", state.notes);
if (file && file.size > 0) fd.set("cv", file);
return fd;
}
export function AddCandidateButton({ ranks }: { ranks: RankOpt[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(emptyState);
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await addCandidate(buildFormData(state, fileRef.current?.files?.[0]));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
setState(emptyState());
if (fileRef.current) fileRef.current.value = "";
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Add candidate
</button>
<AdminDialog title="Add candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Adding…" : "Add candidate"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditCandidateButton({
candidate,
ranks,
open,
onOpenChange,
}: {
candidate: EditableCandidate;
ranks: RankOpt[];
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(() => stateFrom(candidate));
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await updateCandidate(buildFormData(state, fileRef.current?.files?.[0], candidate.id));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
onOpenChange(false);
router.refresh();
}
}
return (
<AdminDialog title="Edit candidate" open={open} onClose={() => onOpenChange(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => onOpenChange(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save changes"}
</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -0,0 +1,38 @@
import type { CandidateSource, CrewStatus } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
export const SOURCE_LABEL: Record<CandidateSource, string> = {
CAREERS: "Careers",
EX_HAND: "Ex-hand",
WALK_IN: "Walk-in",
REFERRAL: "Referral",
OTHER: "Other",
};
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
export const STATUS_LABEL: Record<CrewStatus, string> = {
PROSPECT: "Prospect",
CANDIDATE: "Candidate",
EMPLOYEE: "Employee",
EX_HAND: "Ex-hand",
BLACKLISTED: "Blacklisted",
};
export const STATUS_VARIANT: Record<CrewStatus, Variant> = {
PROSPECT: "outline",
CANDIDATE: "default",
EMPLOYEE: "success",
EX_HAND: "secondary",
BLACKLISTED: "danger",
};
// Compact experience label, e.g. "3y 6m", "8m", "—".
export function experienceLabel(months: number): string {
if (!months) return "—";
const y = Math.floor(months / 12);
const m = months % 12;
return [y ? `${y}y` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m";
}

View file

@ -0,0 +1,169 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { CandidateSource, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
import { SOURCE_LABEL, SOURCE_OPTIONS, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "./candidate-ui";
type CandidateRow = {
id: string;
name: string;
source: CandidateSource;
status: CrewStatus;
appliedRankId: string | null;
appliedRank: string | null;
currentRankId: string | null;
currentRank: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
hasCv: boolean;
};
type RankOpt = { id: string; code: string; name: string };
const INPUT =
"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";
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 text-primary-700 px-2.5 py-1 text-xs font-medium">
{label}
<button onClick={onClear} className="text-primary-400 hover:text-primary-700" aria-label="Remove filter"></button>
</span>
);
}
function toEditable(c: CandidateRow): EditableCandidate {
return {
id: c.id, name: c.name, source: c.source,
appliedRankId: c.appliedRankId, currentRankId: c.currentRankId,
experienceMonths: c.experienceMonths, vesselTypeExperience: c.vesselTypeExperience,
email: c.email, phone: c.phone, notes: c.notes,
};
}
function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
const [editOpen, setEditOpen] = useState(false);
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<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>
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
</td>
<td className="px-4 py-3">
<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.appliedRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge></td>
<td className="px-4 py-3 text-right">
<div onClick={(e) => e.stopPropagation()}>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
</RowActionsMenu>
</div>
<EditCandidateButton candidate={toEditable(c)} ranks={ranks} open={editOpen} onOpenChange={setEditOpen} />
</td>
</tr>
);
}
export function CandidatesManager({ candidates, ranks }: { candidates: CandidateRow[]; ranks: RankOpt[] }) {
const [search, setSearch] = useState("");
const [source, setSource] = useState<"ALL" | CandidateSource>("ALL");
const [appliedRankId, setAppliedRankId] = useState("ALL");
const [minExp, setMinExp] = useState("");
const minExpMonths = minExp ? Math.max(0, parseInt(minExp, 10) || 0) : 0;
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return candidates.filter((c) => {
if (source !== "ALL" && c.source !== source) return false;
if (appliedRankId !== "ALL" && c.appliedRankId !== appliedRankId) return false;
if (minExpMonths && c.experienceMonths < minExpMonths) return false;
if (q && !`${c.name} ${c.appliedRank ?? ""} ${c.currentRank ?? ""}`.toLowerCase().includes(q)) return false;
return true;
});
}, [candidates, search, source, appliedRankId, minExpMonths]);
const rankName = (id: string) => ranks.find((r) => r.id === id)?.name ?? id;
const hasFilters = Boolean(search) || source !== "ALL" || appliedRankId !== "ALL" || Boolean(minExp);
const clearAll = () => { setSearch(""); setSource("ALL"); setAppliedRankId("ALL"); setMinExp(""); };
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Candidates</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals
</p>
</div>
<AddCandidateButton ranks={ranks} />
</div>
{/* Filters */}
<div className="mb-3 flex flex-wrap items-center gap-3">
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className={INPUT} value={source} onChange={(e) => setSource(e.target.value as typeof source)}>
<option value="ALL">All sources</option>
{SOURCE_OPTIONS.map((s) => <option key={s} value={s}>{SOURCE_LABEL[s]}</option>)}
</select>
<select className={INPUT} value={appliedRankId} onChange={(e) => setAppliedRankId(e.target.value)}>
<option value="ALL">Any rank applied</option>
{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}
</select>
<input type="number" min={0} className={`${INPUT} w-40`} placeholder="Min exp (months)" value={minExp} onChange={(e) => setMinExp(e.target.value)} />
</div>
{/* Active filter chips + match count */}
{hasFilters && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{search && <Chip label={`${search}`} onClear={() => setSearch("")} />}
{source !== "ALL" && <Chip label={`Source: ${SOURCE_LABEL[source]}`} onClear={() => setSource("ALL")} />}
{appliedRankId !== "ALL" && <Chip label={`Rank: ${rankName(appliedRankId)}`} onClear={() => setAppliedRankId("ALL")} />}
{minExp && <Chip label={`${minExp} mo`} onClear={() => setMinExp("")} />}
<span className="text-xs text-neutral-500">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
<button onClick={clearAll} className="text-xs font-medium text-primary-600 hover:underline">Clear all</button>
</div>
)}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Rank held</th>
<th className="px-4 py-3">Rank applied</th>
<th className="px-4 py-3">Experience</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 w-12"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
{candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."}
</td>
</tr>
) : (
filtered.map((c) => <CandidateRowView key={c.id} c={c} ranks={ranks} />)
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
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),
}));
return <CandidatesManager candidates={rows} ranks={ranks} />;
}

View file

@ -26,6 +26,7 @@ import {
ShieldCheck,
Network,
ClipboardList,
UserSearch,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -77,6 +78,7 @@ const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
? [
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
]
: [];

View file

@ -44,13 +44,15 @@ export async function generateDownloadUrl(
}
export function buildStorageKey(
type: "po-document" | "receipt",
poId: string,
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
// phases — see Crewing-Implementation-Spec §4.5.
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
ownerId: string,
fileName: string
): string {
const timestamp = Date.now();
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${type}/${poId}/${timestamp}-${safe}`;
return `${type}/${ownerId}/${timestamp}-${safe}`;
}
export function buildSignatureKey(userId: string, ext: string): string {

View file

@ -0,0 +1,57 @@
-- CreateEnum
CREATE TYPE "CrewStatus" AS ENUM ('PROSPECT', 'CANDIDATE', 'EMPLOYEE', 'EX_HAND', 'BLACKLISTED');
-- CreateEnum
CREATE TYPE "CandidateType" AS ENUM ('NEW', 'EX_HAND');
-- CreateEnum
CREATE TYPE "CandidateSource" AS ENUM ('CAREERS', 'EX_HAND', 'WALK_IN', 'REFERRAL', 'OTHER');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_ADDED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_UPDATED';
-- AlterTable
ALTER TABLE "CrewAction" ADD COLUMN "crewMemberId" TEXT;
-- CreateTable
CREATE TABLE "CrewMember" (
"id" TEXT NOT NULL,
"employeeId" TEXT,
"name" TEXT NOT NULL,
"status" "CrewStatus" NOT NULL DEFAULT 'CANDIDATE',
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
"source" "CandidateSource" NOT NULL DEFAULT 'CAREERS',
"email" TEXT,
"phone" TEXT,
"dob" TIMESTAMP(3),
"experienceMonths" INTEGER NOT NULL DEFAULT 0,
"vesselTypeExperience" TEXT,
"cvKey" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"currentRankId" TEXT,
"appliedRankId" TEXT,
CONSTRAINT "CrewMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewMember_employeeId_key" ON "CrewMember"("employeeId");
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_currentRankId_fkey" FOREIGN KEY ("currentRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_appliedRankId_fkey" FOREIGN KEY ("appliedRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -121,7 +121,8 @@ enum ReliefRequestStatus {
}
// Crewing audit-trail action types — the CrewAction mirror of ActionType for
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief.
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief,
// Phase 3a adds candidate intake.
enum CrewActionType {
REQUISITION_RAISED
REQUISITION_ADVANCED
@ -130,6 +131,35 @@ enum CrewActionType {
RELIEF_REQUESTED
RELIEF_CONVERTED
RELIEF_CANCELLED
CANDIDATE_ADDED
CANDIDATE_UPDATED
}
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
// A CrewMember is the talent-pool spine: a row exists from first contact and
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
// at onboarding (Phase 3c). See Crewing-Data-Model §4 + Implementation-Spec §8.6.
enum CrewStatus {
PROSPECT
CANDIDATE
EMPLOYEE
EX_HAND
BLACKLISTED
}
// NEW applicants vs returning EX_HAND crew (drives the ex-hand affordances).
enum CandidateType {
NEW
EX_HAND
}
// Where the candidate came from (the §8.6 "Source" column; ex-hand renders purple).
enum CandidateSource {
CAREERS
EX_HAND
WALK_IN
REFERRAL
OTHER
}
model User {
@ -481,6 +511,8 @@ model Rank {
docRequirements RankDocRequirement[]
requisitions Requisition[]
reliefRequests ReliefRequest[]
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
}
// Which documents a rank is required (or conditionally required) to hold.
@ -560,7 +592,8 @@ model ReliefRequest {
}
// Crewing audit trail — one row per transition / verification (mirror of
// POAction). Entity relations are added per phase; Phase 2 links requisitions.
// POAction). Entity relations are added per phase; Phase 2 links requisitions,
// Phase 3a adds candidates. A row references at most one entity (the rest null).
model CrewAction {
id String @id @default(cuid())
actionType CrewActionType
@ -574,4 +607,37 @@ model CrewAction {
requisitionId String?
requisition Requisition? @relation(fields: [requisitionId], references: [id])
crewMemberId String?
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
}
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
// moment they enter the pool and kept through CANDIDATE → EMPLOYEE → EX_HAND, so
// an ex-hand's history/documents are already on file. `employeeId` is assigned
// at onboarding (Phase 3c). The recruitment pipeline (Applications, Phase 3b)
// and crew records (Phase 4) hang off this model. See Crewing-Data-Model §4.
model CrewMember {
id String @id @default(cuid())
employeeId String? @unique // assigned at onboarding (Phase 3c)
name String
status CrewStatus @default(CANDIDATE)
type CandidateType @default(NEW)
source CandidateSource @default(CAREERS)
email String?
phone String?
dob DateTime?
experienceMonths Int @default(0)
vesselTypeExperience String? // free-text "vessel type" from the Add-candidate modal
cvKey String? // storage key for an uploaded CV (no parsing yet — A2 deferred)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Rank held / last held (ex-hands) and the rank being applied for.
currentRankId String?
currentRank Rank? @relation("CrewCurrentRank", fields: [currentRankId], references: [id])
appliedRankId String?
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
actions CrewAction[]
}

View file

@ -0,0 +1,122 @@
/**
* Integration tests for the Crewing Phase 3a candidate server actions
* (addCandidate / updateCandidate). Mirrors the requisitions test setup.
*
* The CrewMember table is introduced in this phase, so afterEach wipes it (and
* its CrewAction rows) wholesale no pre-existing rows to preserve.
*/
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
let managerId: string;
let siteStaffId: string;
let rankId: string;
const SS_EMAIL = "sitestaff@itcand.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
const ss = await db.user.upsert({
where: { email: SS_EMAIL },
update: { role: "SITE_STAFF", isActive: true },
create: { employeeId: "ITCAND-SS", email: SS_EMAIL, name: "Site Staff Cand", role: "SITE_STAFF" },
});
siteStaffId = ss.id;
rankId = (await db.rank.findFirstOrThrow()).id;
});
afterEach(async () => {
await db.crewAction.deleteMany({ where: { crewMemberId: { not: null } } });
await db.crewMember.deleteMany({});
vi.clearAllMocks();
});
afterAll(async () => {
await db.user.deleteMany({ where: { email: SS_EMAIL } });
});
describe("addCandidate", () => {
it("adds a NEW candidate with an audit action and sensible defaults", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: "Asha Rao", source: "CAREERS", appliedRankId: rankId, experienceMonths: "60" }));
expect("ok" in res && res.ok).toBe(true);
const c = await db.crewMember.findFirstOrThrow({ include: { actions: true } });
expect(c.name).toBe("Asha Rao");
expect(c.type).toBe("NEW");
expect(c.status).toBe("CANDIDATE");
expect(c.appliedRankId).toBe(rankId);
expect(c.experienceMonths).toBe(60);
expect(c.employeeId).toBeNull();
expect(c.actions[0].actionType).toBe("CANDIDATE_ADDED");
expect(c.actions[0].actorId).toBe(managerId);
});
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
const c = await db.crewMember.findFirstOrThrow();
expect(c.type).toBe("EX_HAND");
expect(c.status).toBe("EX_HAND");
});
it("requires a name", async () => {
as(managerId, "MANAGER");
const res = await addCandidate(fd({ name: " ", source: "CAREERS" }));
expect("error" in res).toBe(true);
expect(await db.crewMember.count()).toBe(0);
});
it("is rejected for roles without manage_candidates (site staff, accounts)", async () => {
as(siteStaffId, "SITE_STAFF");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
as(managerId, "ACCOUNTS");
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
expect(await db.crewMember.count()).toBe(0);
});
});
describe("updateCandidate", () => {
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Edit Me", source: "CAREERS", experienceMonths: "12" }));
const c = await db.crewMember.findFirstOrThrow();
const res = await updateCandidate(fd({ id: c.id, name: "Edited Name", source: "REFERRAL", experienceMonths: "24" }));
expect("ok" in res && res.ok).toBe(true);
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id }, include: { actions: true } });
expect(after.name).toBe("Edited Name");
expect(after.source).toBe("REFERRAL");
expect(after.experienceMonths).toBe(24);
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
});
it("does not downgrade an onboarded EMPLOYEE back to a candidate", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Hired Hannah", source: "CAREERS" }));
const c = await db.crewMember.findFirstOrThrow();
await db.crewMember.update({ where: { id: c.id }, data: { status: "EMPLOYEE" } });
await updateCandidate(fd({ id: c.id, name: "Hired Hannah", source: "CAREERS" }));
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
});
it("rejects an unknown id", async () => {
as(managerId, "MANAGER");
const res = await updateCandidate(fd({ id: "nonexistent", name: "X", source: "CAREERS" }));
expect("error" in res).toBe(true);
});
});