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>
169 lines
7.8 KiB
TypeScript
169 lines
7.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|