- A3 AC2: each requisition row shows its candidate count (sourced via _count.applications in the list query) alongside the existing days-open age. - A3 AC1: add rank and reason filters (derived from the visible data, like the existing vessel/site filter) on top of search + status + location. requisitions.test.ts asserts the per-row candidateCount (2 vs 0) the page exposes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
231 lines
9.1 KiB
TypeScript
231 lines
9.1 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { RaiseRequisitionButton, ConvertReliefButton } from "./requisition-form";
|
|
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "./requisition-ui";
|
|
|
|
type RequisitionRow = {
|
|
id: string;
|
|
code: string;
|
|
status: RequisitionStatus;
|
|
reason: RequisitionReason;
|
|
autoRaised: boolean;
|
|
rankName: string;
|
|
location: string;
|
|
raisedBy: string;
|
|
candidateCount: number;
|
|
createdAt: string;
|
|
};
|
|
|
|
type ReliefRow = {
|
|
id: string;
|
|
rankName: string;
|
|
location: string;
|
|
note: string | null;
|
|
requestedBy: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type Opt = { id: string; name: string };
|
|
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";
|
|
|
|
const STATUS_FILTERS: RequisitionStatus[] = [
|
|
"OPEN", "SHORTLISTING", "PROPOSING", "INTERVIEWING", "SELECTED", "FILLED", "CANCELLED",
|
|
];
|
|
|
|
export function RequisitionsManager({
|
|
requisitions,
|
|
reliefRequests,
|
|
ranks,
|
|
vessels,
|
|
sites,
|
|
canRaise,
|
|
canConvert,
|
|
}: {
|
|
requisitions: RequisitionRow[];
|
|
reliefRequests: ReliefRow[];
|
|
ranks: RankOpt[];
|
|
vessels: Opt[];
|
|
sites: Opt[];
|
|
canRaise: boolean;
|
|
canConvert: boolean;
|
|
}) {
|
|
const [search, setSearch] = useState("");
|
|
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
|
|
const [location, setLocation] = useState("ALL");
|
|
const [rank, setRank] = useState("ALL");
|
|
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
|
|
|
|
const locations = useMemo(
|
|
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
|
[requisitions]
|
|
);
|
|
const rankNames = useMemo(
|
|
() => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(),
|
|
[requisitions]
|
|
);
|
|
const reasons = useMemo(
|
|
() => Array.from(new Set(requisitions.map((r) => r.reason))),
|
|
[requisitions]
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
return requisitions.filter((r) => {
|
|
if (status !== "ALL" && r.status !== status) return false;
|
|
if (location !== "ALL" && r.location !== location) return false;
|
|
if (rank !== "ALL" && r.rankName !== rank) return false;
|
|
if (reason !== "ALL" && r.reason !== reason) return false;
|
|
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
|
return true;
|
|
});
|
|
}, [requisitions, search, status, location, rank, reason]);
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-neutral-900">Requisitions</h1>
|
|
<p className="text-sm text-neutral-500 mt-0.5">
|
|
{requisitions.length} requisition{requisitions.length === 1 ? "" : "s"} · vacancies being sourced and filled
|
|
</p>
|
|
</div>
|
|
{canRaise && <RaiseRequisitionButton ranks={ranks} vessels={vessels} sites={sites} />}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
|
<input
|
|
className={`${INPUT} flex-1 min-w-[200px]`}
|
|
placeholder="Search code, rank or location…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
<select className={INPUT} value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
|
|
<option value="ALL">All statuses</option>
|
|
{STATUS_FILTERS.map((s) => (
|
|
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
|
))}
|
|
</select>
|
|
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
|
|
<option value="ALL">All vessels / sites</option>
|
|
{locations.map((l) => (
|
|
<option key={l} value={l}>{l}</option>
|
|
))}
|
|
</select>
|
|
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
|
|
<option value="ALL">All ranks</option>
|
|
{rankNames.map((r) => (
|
|
<option key={r} value={r}>{r}</option>
|
|
))}
|
|
</select>
|
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
|
<option value="ALL">All reasons</option>
|
|
{reasons.map((r) => (
|
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Requisitions table */}
|
|
<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">Requisition</th>
|
|
<th className="px-4 py-3">Vessel / site</th>
|
|
<th className="px-4 py-3">Rank</th>
|
|
<th className="px-4 py-3">Reason</th>
|
|
<th className="px-4 py-3">Candidates</th>
|
|
<th className="px-4 py-3">Raised by</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
|
No requisitions match these filters.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map((r) => (
|
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
|
<td className="px-4 py-3">
|
|
<Link href={`/crewing/requisitions/${r.id}`} className="block">
|
|
<span className="font-mono text-xs text-neutral-900">{r.code}</span>
|
|
<span className="ml-2 text-xs text-neutral-400">{ageLabel(r.createdAt)} ago</span>
|
|
{r.autoRaised && (
|
|
<span className="ml-2 rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-[10px] font-medium">
|
|
Auto
|
|
</span>
|
|
)}
|
|
</Link>
|
|
</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
|
|
<td className="px-4 py-3 text-neutral-700 tabular-nums">{r.candidateCount}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Relief requests from sites (spec §8.2 / R3 / R6) */}
|
|
<div className="mt-8">
|
|
<h2 className="text-sm font-semibold text-neutral-900">Relief requests from sites</h2>
|
|
<p className="text-xs text-neutral-500 mt-0.5 mb-3">
|
|
Foreseen gaps flagged by site staff. Convert one into a requisition to start sourcing.
|
|
</p>
|
|
<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">Vessel / site</th>
|
|
<th className="px-4 py-3">Rank</th>
|
|
<th className="px-4 py-3">Note</th>
|
|
<th className="px-4 py-3">Requested by</th>
|
|
<th className="px-4 py-3 w-20"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{reliefRequests.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
|
No open relief requests.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
reliefRequests.map((r) => (
|
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
|
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
|
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.note ?? "—"}</td>
|
|
<td className="px-4 py-3 text-neutral-500">{r.requestedBy}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{canConvert && (
|
|
<ConvertReliefButton reliefRequestId={r.id} label={`${r.rankName} on ${r.location}`} />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|