From be6db075dc3375d8b75525eae8a9a6db4bf8b107 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 17:48:53 +0530 Subject: [PATCH] =?UTF-8?q?feat(crewing):=20Phase=203a=20=E2=80=94=20candi?= =?UTF-8?q?dates=20/=20talent=20pool=20(flagged)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- App/CLAUDE.md | 7 + .../(portal)/crewing/candidates/[id]/page.tsx | 97 +++++++ .../(portal)/crewing/candidates/actions.ts | 143 ++++++++++ .../crewing/candidates/candidate-form.tsx | 256 ++++++++++++++++++ .../crewing/candidates/candidate-ui.ts | 38 +++ .../crewing/candidates/candidates-manager.tsx | 169 ++++++++++++ App/app/(portal)/crewing/candidates/page.tsx | 50 ++++ App/components/layout/sidebar.tsx | 2 + App/lib/storage.ts | 8 +- .../migration.sql | 57 ++++ App/prisma/schema.prisma | 70 ++++- App/tests/integration/candidates.test.ts | 122 +++++++++ 12 files changed, 1014 insertions(+), 5 deletions(-) create mode 100644 App/app/(portal)/crewing/candidates/[id]/page.tsx create mode 100644 App/app/(portal)/crewing/candidates/actions.ts create mode 100644 App/app/(portal)/crewing/candidates/candidate-form.tsx create mode 100644 App/app/(portal)/crewing/candidates/candidate-ui.ts create mode 100644 App/app/(portal)/crewing/candidates/candidates-manager.tsx create mode 100644 App/app/(portal)/crewing/candidates/page.tsx create mode 100644 App/prisma/migrations/20260622121127_crewing_candidates/migration.sql create mode 100644 App/tests/integration/candidates.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index df91c5d..0820d46 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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`. diff --git a/App/app/(portal)/crewing/candidates/[id]/page.tsx b/App/app/(portal)/crewing/candidates/[id]/page.tsx new file mode 100644 index 0000000..2ea86f8 --- /dev/null +++ b/App/app/(portal)/crewing/candidates/[id]/page.tsx @@ -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 ( +
+ + Candidates + + +
+
+

{c.name}

+ {STATUS_LABEL[c.status]} + {c.source === "EX_HAND" && ( + Returning crew + )} +
+
+ + {c.source === "EX_HAND" && ( +
+ Returning crew. 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). +
+ )} + +
+ {/* Profile */} +
+
+

Profile

+
+
+ {profile.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+ {c.notes && ( +
+

Notes

+

{c.notes}

+
+ )} +
+ + {/* Recruitment pipeline — Phase 3b */} +
+
+

Recruitment

+
+

+ The 7-stage recruitment pipeline (shortlist → competency & references → docs → + salary → proposed → interview → selected) arrives in the next phase. Applications + against requisitions will appear here. +

+
+
+
+ ); +} diff --git a/App/app/(portal)/crewing/candidates/actions.ts b/App/app/(portal)/crewing/candidates/actions.ts new file mode 100644 index 0000000..5d7c928 --- /dev/null +++ b/App/app/(portal)/crewing/candidates/actions.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/crewing/candidates/candidate-form.tsx b/App/app/(portal)/crewing/candidates/candidate-form.tsx new file mode 100644 index 0000000..ed71b1e --- /dev/null +++ b/App/app/(portal)/crewing/candidates/candidate-form.tsx @@ -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: K, v: FieldState[K]) => void; + fileRef: React.RefObject; +}) { + return ( +
+
+
+ + set("name", e.target.value)} required /> +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + set("experienceMonths", e.target.value)} /> +
+
+ + set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" /> +
+
+ +
+
+ + set("email", e.target.value)} /> +
+
+ + set("phone", e.target.value)} /> +
+
+ +
+ + +
+ +
+ + set("notes", e.target.value)} placeholder="Optional" /> +
+
+ ); +} + +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(emptyState); + const fileRef = useRef(null); + const set = (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 ( + <> + + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +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(() => stateFrom(candidate)); + const fileRef = useRef(null); + const set = (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 ( + onOpenChange(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ ); +} diff --git a/App/app/(portal)/crewing/candidates/candidate-ui.ts b/App/app/(portal)/crewing/candidates/candidate-ui.ts new file mode 100644 index 0000000..fe98253 --- /dev/null +++ b/App/app/(portal)/crewing/candidates/candidate-ui.ts @@ -0,0 +1,38 @@ +import type { CandidateSource, CrewStatus } from "@prisma/client"; +import type { BadgeProps } from "@/components/ui/badge"; + +type Variant = NonNullable; + +export const SOURCE_LABEL: Record = { + 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 = { + PROSPECT: "Prospect", + CANDIDATE: "Candidate", + EMPLOYEE: "Employee", + EX_HAND: "Ex-hand", + BLACKLISTED: "Blacklisted", +}; + +export const STATUS_VARIANT: Record = { + 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"; +} diff --git a/App/app/(portal)/crewing/candidates/candidates-manager.tsx b/App/app/(portal)/crewing/candidates/candidates-manager.tsx new file mode 100644 index 0000000..066e5e7 --- /dev/null +++ b/App/app/(portal)/crewing/candidates/candidates-manager.tsx @@ -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 ( + + {label} + + + ); +} + +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 ( + + + {c.name} + {c.hasCv && CV} + + + + {SOURCE_LABEL[c.source]} + + + {c.currentRank ?? "—"} + {c.appliedRank ?? "—"} + {experienceLabel(c.experienceMonths)} + {STATUS_LABEL[c.status]} + +
e.stopPropagation()}> + + setEditOpen(true)}>Edit + +
+ + + + ); +} + +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 ( +
+
+
+

Candidates

+

+ {candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals +

+
+ +
+ + {/* Filters */} +
+ setSearch(e.target.value)} /> + + + setMinExp(e.target.value)} /> +
+ + {/* Active filter chips + match count */} + {hasFilters && ( +
+ {search && setSearch("")} />} + {source !== "ALL" && setSource("ALL")} />} + {appliedRankId !== "ALL" && setAppliedRankId("ALL")} />} + {minExp && setMinExp("")} />} + {filtered.length} match{filtered.length === 1 ? "" : "es"} + +
+ )} + +
+ + + + + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((c) => ) + )} + +
NameSourceRank heldRank appliedExperienceStatus
+ {candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."} +
+
+
+ ); +} diff --git a/App/app/(portal)/crewing/candidates/page.tsx b/App/app/(portal)/crewing/candidates/page.tsx new file mode 100644 index 0000000..80adfae --- /dev/null +++ b/App/app/(portal)/crewing/candidates/page.tsx @@ -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 ; +} diff --git a/App/components/layout/sidebar.tsx b/App/components/layout/sidebar.tsx index 7b33f7e..8a630aa 100644 --- a/App/components/layout/sidebar.tsx +++ b/App/components/layout/sidebar.tsx @@ -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"] }, ] : []; diff --git a/App/lib/storage.ts b/App/lib/storage.ts index 5a56dea..ecc645b 100644 --- a/App/lib/storage.ts +++ b/App/lib/storage.ts @@ -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 { diff --git a/App/prisma/migrations/20260622121127_crewing_candidates/migration.sql b/App/prisma/migrations/20260622121127_crewing_candidates/migration.sql new file mode 100644 index 0000000..010d1e9 --- /dev/null +++ b/App/prisma/migrations/20260622121127_crewing_candidates/migration.sql @@ -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; diff --git a/App/prisma/schema.prisma b/App/prisma/schema.prisma index 63737e6..b268cc5 100644 --- a/App/prisma/schema.prisma +++ b/App/prisma/schema.prisma @@ -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[] } diff --git a/App/tests/integration/candidates.test.ts b/App/tests/integration/candidates.test.ts new file mode 100644 index 0000000..0baafe5 --- /dev/null +++ b/App/tests/integration/candidates.test.ts @@ -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).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); + }); +});