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);
+ });
+});
|