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>
256 lines
9.8 KiB
TypeScript
256 lines
9.8 KiB
TypeScript
"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>
|
|
);
|
|
}
|