pelagia-portal/App/app/(portal)/crewing/candidates/candidates-manager.tsx
Hardik be6db075dc 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>
2026-06-22 18:23:01 +05:30

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>
);
}