Merge pull request 'feat(crewing): Phase 3a — candidates / talent pool (flagged)' (#66) from feat/crewing-candidates into feat/crewing-requisitions
Reviewed-on: #66
This commit is contained in:
commit
a89440d6dd
12 changed files with 1014 additions and 5 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
97
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
97
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal 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 & references → docs →
|
||||
salary → proposed → interview → selected) arrives in the next phase. Applications
|
||||
against requisitions will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
App/app/(portal)/crewing/candidates/actions.ts
Normal file
143
App/app/(portal)/crewing/candidates/actions.ts
Normal 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 };
|
||||
}
|
||||
256
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal file
256
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal file
38
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal 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";
|
||||
}
|
||||
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal file
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
App/app/(portal)/crewing/candidates/page.tsx
Normal file
50
App/app/(portal)/crewing/candidates/page.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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"] },
|
||||
]
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
122
App/tests/integration/candidates.test.ts
Normal file
122
App/tests/integration/candidates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue