Rank held applies to every candidate, not just ex-hands; it auto-updates for returning crew on sign-off. Ex-hand designation is decoupled from the Source dropdown and owned by the office: - Candidate form: drop the EX_HAND source option, relabel "Rank held (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate no longer rewrites type/status, so an admin-set EX_HAND or onboarded EMPLOYEE is never clobbered by a candidate edit. - Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand (returning crew)" checkbox -- the only place ex-hand is tagged. - List/detail ex-hand indicators key on type === EX_HAND (not source). - Sign-off preserves the original recruitment source when flipping to EX_HAND. - Tests seed EX_HAND rows directly; assert candidate intake stays NEW. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
7.9 KiB
TypeScript
169 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import type { CandidateSource, CandidateType, 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;
|
|
type: CandidateType;
|
|
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.type === "EX_HAND" && (
|
|
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
|
|
)}
|
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</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>
|
|
);
|
|
}
|