From 93d13a415c487c2de83e0cd782b3d71a7906bf2b Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 23:52:05 +0530 Subject: [PATCH] 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 --- .../(portal)/crewing/requisitions/page.tsx | 2 ++ .../requisitions/requisitions-manager.tsx | 31 +++++++++++++++++-- App/tests/integration/requisitions.test.ts | 27 ++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/App/app/(portal)/crewing/requisitions/page.tsx b/App/app/(portal)/crewing/requisitions/page.tsx index f43c1cf..556ad90 100644 --- a/App/app/(portal)/crewing/requisitions/page.tsx +++ b/App/app/(portal)/crewing/requisitions/page.tsx @@ -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(), })); diff --git a/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx b/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx index 2f1ef6c..986b10c 100644 --- a/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx +++ b/App/app/(portal)/crewing/requisitions/requisitions-manager.tsx @@ -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 (
@@ -106,6 +119,18 @@ export function RequisitionsManager({ ))} + +
{/* Requisitions table */} @@ -117,6 +142,7 @@ export function RequisitionsManager({ Vessel / site Rank Reason + Candidates Raised by Status @@ -124,7 +150,7 @@ export function RequisitionsManager({ {filtered.length === 0 ? ( - + No requisitions match these filters. @@ -145,6 +171,7 @@ export function RequisitionsManager({ {r.location} {r.rankName} {REASON_LABEL[r.reason]} + {r.candidateCount} {r.raisedBy} {STATUS_LABEL[r.status]} diff --git a/App/tests/integration/requisitions.test.ts b/App/tests/integration/requisitions.test.ts index c17fd79..d64656c 100644 --- a/App/tests/integration/requisitions.test.ts +++ b/App/tests/integration/requisitions.test.ts @@ -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); + }); +});