feat(crewing): Phase 3a — candidates / talent pool (flagged)
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:
parent
0b2ed9ac07
commit
be6db075dc
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`.
|
- **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.
|
- **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
|
### 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`.
|
`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,
|
ShieldCheck,
|
||||||
Network,
|
Network,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
UserSearch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -77,6 +78,7 @@ const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||||
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
||||||
? [
|
? [
|
||||||
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
|
{ 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(
|
export function buildStorageKey(
|
||||||
type: "po-document" | "receipt",
|
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
||||||
poId: string,
|
// phases — see Crewing-Implementation-Spec §4.5.
|
||||||
|
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
|
||||||
|
ownerId: string,
|
||||||
fileName: string
|
fileName: string
|
||||||
): string {
|
): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
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 {
|
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
|
// 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 {
|
enum CrewActionType {
|
||||||
REQUISITION_RAISED
|
REQUISITION_RAISED
|
||||||
REQUISITION_ADVANCED
|
REQUISITION_ADVANCED
|
||||||
|
|
@ -130,6 +131,35 @@ enum CrewActionType {
|
||||||
RELIEF_REQUESTED
|
RELIEF_REQUESTED
|
||||||
RELIEF_CONVERTED
|
RELIEF_CONVERTED
|
||||||
RELIEF_CANCELLED
|
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 {
|
model User {
|
||||||
|
|
@ -481,6 +511,8 @@ model Rank {
|
||||||
docRequirements RankDocRequirement[]
|
docRequirements RankDocRequirement[]
|
||||||
requisitions Requisition[]
|
requisitions Requisition[]
|
||||||
reliefRequests ReliefRequest[]
|
reliefRequests ReliefRequest[]
|
||||||
|
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
||||||
|
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which documents a rank is required (or conditionally required) to hold.
|
// 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
|
// 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 {
|
model CrewAction {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
actionType CrewActionType
|
actionType CrewActionType
|
||||||
|
|
@ -574,4 +607,37 @@ model CrewAction {
|
||||||
|
|
||||||
requisitionId String?
|
requisitionId String?
|
||||||
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
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