feat(crewing): land full Crewing module on master (Phases 1–5 + hardening) #93
3 changed files with 58 additions and 2 deletions
|
|
@ -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(),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue