feat(crewing): complete requisition list A3 (candidate count + filters)

- 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>
This commit is contained in:
Hardik 2026-06-22 23:52:05 +05:30
parent df950c7253
commit 93d13a415c
3 changed files with 58 additions and 2 deletions

View file

@ -25,6 +25,7 @@ export default async function RequisitionsPage() {
vessel: { select: { name: true } },
site: { select: { name: true } },
raisedBy: { select: { name: true } },
_count: { select: { applications: true } },
},
}),
db.reliefRequest.findMany({
@ -52,6 +53,7 @@ export default async function RequisitionsPage() {
rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—",
raisedBy: r.raisedBy?.name ?? "System",
candidateCount: r._count.applications,
createdAt: r.createdAt.toISOString(),
}));

View file

@ -16,6 +16,7 @@ type RequisitionRow = {
rankName: string;
location: string;
raisedBy: string;
candidateCount: number;
createdAt: string;
};
@ -58,21 +59,33 @@ export function RequisitionsManager({
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]);
}, [requisitions, search, status, location, rank, reason]);
return (
<div>
@ -106,6 +119,18 @@ export function RequisitionsManager({
<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 */}
@ -117,6 +142,7 @@ export function RequisitionsManager({
<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>
@ -124,7 +150,7 @@ export function RequisitionsManager({
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
No requisitions match these filters.
</td>
</tr>
@ -145,6 +171,7 @@ export function RequisitionsManager({
<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>

View file

@ -7,11 +7,17 @@
* so afterEach wipes them wholesale (no pre-existing rows to preserve).
*/
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import React from "react";
// The list page's JSX compiles to classic React.createElement in the node runner.
(globalThis as unknown as { React: typeof React }).React = React;
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
// We read the page element's props directly; the client component is irrelevant.
vi.mock("@/app/(portal)/crewing/requisitions/requisitions-manager", () => ({ RequisitionsManager: () => null }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
@ -22,6 +28,7 @@ import {
requestReliefCover,
convertReliefToRequisition,
} from "@/app/(portal)/crewing/requisitions/actions";
import RequisitionsPage from "@/app/(portal)/crewing/requisitions/page";
import { autoRaiseRequisition } from "@/lib/requisition-service";
import { makeSession, getSeedUser, fd } from "./helpers";
import type { Role } from "@prisma/client";
@ -52,6 +59,8 @@ beforeAll(async () => {
afterEach(async () => {
await db.crewAction.deleteMany({});
await db.application.deleteMany({});
await db.crewMember.deleteMany({});
await db.reliefRequest.deleteMany({});
await db.requisition.deleteMany({});
vi.clearAllMocks();
@ -244,3 +253,21 @@ describe("autoRaiseRequisition (shared helper)", () => {
expect(stored.actions[0].actorId).toBeNull();
});
});
describe("requisitions list (A3)", () => {
it("exposes a candidate count per requisition row", async () => {
as(managerId, "MANAGER");
const req = await db.requisition.create({ data: { code: "REQ-A3", rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } });
const empty = await db.requisition.create({ data: { code: "REQ-A3B", rankId, vesselId, reason: "LEAVE", status: "OPEN" } });
for (const name of ["Cand A", "Cand B"]) {
const c = await db.crewMember.create({ data: { name, type: "NEW", status: "CANDIDATE", source: "CAREERS" } });
await db.application.create({ data: { requisitionId: req.id, crewMemberId: c.id, stage: "SHORTLISTED", type: "NEW" } });
}
const el = (await RequisitionsPage()) as unknown as {
props: { requisitions: Array<{ id: string; candidateCount: number }> };
};
expect(el.props.requisitions.find((r) => r.id === req.id)?.candidateCount).toBe(2);
expect(el.props.requisitions.find((r) => r.id === empty.id)?.candidateCount).toBe(0);
});
});