Compare commits
3 commits
f3ce2d5da2
...
be6db075dc
| Author | SHA1 | Date | |
|---|---|---|---|
| be6db075dc | |||
| 0b2ed9ac07 | |||
| 4528c059aa |
27 changed files with 3061 additions and 79 deletions
|
|
@ -1,20 +1,22 @@
|
||||||
name: PR checks
|
name: PR checks
|
||||||
|
|
||||||
# Enforces the contribution policy on every PR into master — plus the crewing
|
# Enforces the contribution policy on every PR into master — plus the crewing
|
||||||
# integration branch (feat/crewing-foundations), which collects the stacked,
|
# stack branches (feat/crewing-*), which collect the stacked, feature-flagged
|
||||||
# feature-flagged crewing phases before they merge to master. Same hard gates:
|
# crewing phases (foundations → requisitions → candidates → …) before they merge
|
||||||
|
# to master. Same hard gates:
|
||||||
# - code changes must ship with tests (docs/config/automation are exempt)
|
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||||
# - type-check is clean across the whole project (tests included)
|
# - type-check is clean across the whole project (tests included)
|
||||||
# - unit tests pass
|
# - unit tests pass
|
||||||
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||||
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||||
#
|
#
|
||||||
# Note: for pull_request events the workflow is read from the BASE branch, so a
|
# Note: the workflow is evaluated from the branch under test, so the trigger list
|
||||||
# base must appear in this list for its incoming PRs to be checked.
|
# must match it. The feat/crewing-* glob covers every branch in the stack so each
|
||||||
|
# stacked phase PR is checked without further edits to this file.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, feat/crewing-foundations]
|
branches: [master, "feat/crewing-*"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,28 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
|
||||||
|
|
||||||
### Crewing (feature-flagged)
|
### Crewing (feature-flagged)
|
||||||
|
|
||||||
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12); only the **Foundations** layer ships so far:
|
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:
|
||||||
|
|
||||||
- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`.
|
- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`.
|
||||||
- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin.
|
- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin.
|
||||||
- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks.
|
- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks.
|
||||||
- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card.
|
- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card.
|
||||||
- The sidebar has a flag-gated **Crewing** section scaffold (`CREWING_ITEMS`, empty until later phases) and the Ranks link under Administration.
|
|
||||||
|
**Phase 2 — Requisitions + relief (spec §5.2/§8.2–8.3):**
|
||||||
|
|
||||||
|
- **Models:** `Requisition` (lifecycle `OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED`, `→ CANCELLED`), `ReliefRequest` (site-flagged gap the office converts), and `CrewAction` (the crewing audit trail — the `POAction` mirror). `Requisition.autoRaised` marks system-raised vacancies.
|
||||||
|
- **State machine:** `lib/requisition-state-machine.ts` mirrors `po-state-machine.ts` (`TRANSITIONS`, `canPerformAction`, `getAvailableActions`; orthogonal `CANCEL_ROLES`/`canCancel`). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING by `cancel_requisition` holders (MPO + Manager, per §6). Codes (`REQ-9000…`) come from `lib/requisition-number.ts`.
|
||||||
|
- **Actions** (`app/(portal)/crewing/requisitions/actions.ts`): `raiseRequisition`, `cancelRequisition`, `transitionRequisition`, `requestReliefCover`, `convertReliefToRequisition` — each guards flag + permission + state, writes a `CrewAction`, and notifies via `notifyCrew`. The shared `autoRaiseRequisition()` in `lib/requisition-service.ts` is the backfill entry point sign-off / leave-clash (later phases) will call.
|
||||||
|
- **Screens:** `/crewing/requisitions` (list + Raise modal + "Relief requests from sites" convert) and `/crewing/requisitions/[id]` (detail; the recruitment pipeline is a later phase). **Requisitions** is in the flag-gated sidebar **Crewing** section (`CREWING_ITEMS`, Manager + MPO). The Ranks link stays under Administration.
|
||||||
|
- **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`.
|
||||||
|
- **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it.
|
||||||
|
|
||||||
|
**Phase 3a — Candidates (Epic B; spec §8.6):** Phase 3 (candidate intake + 7-stage pipeline + onboarding) ships as **stacked sub-PRs** — 3a candidates, 3b pipeline, 3c onboarding.
|
||||||
|
|
||||||
|
- **Model:** `CrewMember` is the talent-pool spine — one row per person, created on first contact and kept through `CANDIDATE → EMPLOYEE → EX_HAND` (`CrewStatus`). `employeeId` is assigned only at onboarding (3c). `CandidateType` (NEW/EX_HAND) and `CandidateSource` derive from the chosen source; `currentRankId` (rank held) + `appliedRankId` (rank applied for). `CrewAction` gained a nullable `crewMemberId` (it now references at most one entity).
|
||||||
|
- **Actions** (`app/(portal)/crewing/candidates/actions.ts`): `addCandidate` / `updateCandidate` — guard flag + `manage_candidates`, write a `CrewAction`, optional CV upload via `buildStorageKey("cv", …)` + `uploadBuffer`. An EX_HAND source maps to `type/status = EX_HAND`; an edit never downgrades an `EMPLOYEE`.
|
||||||
|
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
|
||||||
|
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
||||||
|
|
|
||||||
97
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
97
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Candidate" };
|
||||||
|
|
||||||
|
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const c = await db.crewMember.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!c) notFound();
|
||||||
|
|
||||||
|
const profile: [string, string][] = [
|
||||||
|
["Rank applied", c.appliedRank?.name ?? "—"],
|
||||||
|
["Last rank held", c.currentRank?.name ?? "—"],
|
||||||
|
["Experience", experienceLabel(c.experienceMonths)],
|
||||||
|
["Vessel type", c.vesselTypeExperience ?? "—"],
|
||||||
|
["Source", SOURCE_LABEL[c.source]],
|
||||||
|
["Email", c.email ?? "—"],
|
||||||
|
["Phone", c.phone ?? "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href="/crewing/candidates" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Candidates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
||||||
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||||
|
{c.source === "EX_HAND" && (
|
||||||
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.source === "EX_HAND" && (
|
||||||
|
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
||||||
|
<strong>Returning crew.</strong> Prior documents, bank details and tour history are on file from earlier
|
||||||
|
assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Profile */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-neutral-100">
|
||||||
|
{profile.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
||||||
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
||||||
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{c.notes && (
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100">
|
||||||
|
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
|
||||||
|
<p className="text-sm text-neutral-700">{c.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recruitment pipeline — Phase 3b */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Recruitment</h2>
|
||||||
|
</div>
|
||||||
|
<p className="px-4 py-12 text-center text-sm text-neutral-400">
|
||||||
|
The 7-stage recruitment pipeline (shortlist → competency & references → docs →
|
||||||
|
salary → proposed → interview → selected) arrives in the next phase. Applications
|
||||||
|
against requisitions will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
App/app/(portal)/crewing/candidates/actions.ts
Normal file
143
App/app/(portal)/crewing/candidates/actions.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
|
import { CandidateSource } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const LIST_PATH = "/crewing/candidates";
|
||||||
|
|
||||||
|
async function guard(
|
||||||
|
permission: Permission
|
||||||
|
): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
source: z.nativeEnum(CandidateSource).default("CAREERS"),
|
||||||
|
appliedRankId: z.string().optional(),
|
||||||
|
currentRankId: z.string().optional(),
|
||||||
|
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
|
||||||
|
vesselTypeExperience: z.string().optional(),
|
||||||
|
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parse(formData: FormData) {
|
||||||
|
return candidateSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
source: (formData.get("source") as string) || undefined,
|
||||||
|
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
|
||||||
|
currentRankId: (formData.get("currentRankId") as string) || undefined,
|
||||||
|
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
|
||||||
|
vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined,
|
||||||
|
email: (formData.get("email") as string) || undefined,
|
||||||
|
phone: (formData.get("phone") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EX_HAND source means a returning crew member; everyone else is NEW. The
|
||||||
|
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
|
||||||
|
function derive(source: CandidateSource) {
|
||||||
|
const isExHand = source === "EX_HAND";
|
||||||
|
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store an optional CV upload and return its storage key (null if none).
|
||||||
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
||||||
|
const file = formData.get("cv");
|
||||||
|
if (!(file instanceof File) || file.size === 0) return null;
|
||||||
|
const key = buildStorageKey("cv", crewMemberId, file.name);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await uploadBuffer(key, buffer, file.type || "application/octet-stream");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
const { type, status } = derive(d.source);
|
||||||
|
|
||||||
|
const candidate = await db.crewMember.create({
|
||||||
|
data: {
|
||||||
|
name: d.name,
|
||||||
|
source: d.source,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
appliedRankId: d.appliedRankId || null,
|
||||||
|
currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || null,
|
||||||
|
email: d.email || null,
|
||||||
|
phone: d.phone || null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cvKey = await storeCv(formData, candidate.id);
|
||||||
|
if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } });
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: candidate.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
if (!id) return { error: "Candidate ID is required" };
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
const { type, status } = derive(d.source);
|
||||||
|
|
||||||
|
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!existing) return { error: "Candidate not found" };
|
||||||
|
|
||||||
|
const cvKey = await storeCv(formData, id);
|
||||||
|
|
||||||
|
await db.crewMember.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: d.name,
|
||||||
|
source: d.source,
|
||||||
|
// Don't downgrade an onboarded employee back to a candidate via an edit.
|
||||||
|
type,
|
||||||
|
status: existing.status === "EMPLOYEE" ? existing.status : status,
|
||||||
|
appliedRankId: d.appliedRankId || null,
|
||||||
|
currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || null,
|
||||||
|
email: d.email || null,
|
||||||
|
phone: d.phone || null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
...(cvKey ? { cvKey } : {}),
|
||||||
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true, id };
|
||||||
|
}
|
||||||
256
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal file
256
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { CandidateSource } from "@prisma/client";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { addCandidate, updateCandidate } from "./actions";
|
||||||
|
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full 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";
|
||||||
|
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
export type EditableCandidate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
appliedRankId: string | null;
|
||||||
|
currentRankId: string | null;
|
||||||
|
experienceMonths: number;
|
||||||
|
vesselTypeExperience: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CandidateFields({
|
||||||
|
ranks,
|
||||||
|
state,
|
||||||
|
set,
|
||||||
|
fileRef,
|
||||||
|
}: {
|
||||||
|
ranks: RankOpt[];
|
||||||
|
state: FieldState;
|
||||||
|
set: <K extends keyof FieldState>(k: K, v: FieldState[K]) => void;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||||
|
<input className={INPUT} value={state.name} onChange={(e) => set("name", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
||||||
|
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
||||||
|
{SOURCE_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank applied for</label>
|
||||||
|
<select className={INPUT} value={state.appliedRankId} onChange={(e) => set("appliedRankId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
|
||||||
|
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Experience (months)</label>
|
||||||
|
<input type="number" min={0} className={INPUT} value={state.experienceMonths} onChange={(e) => set("experienceMonths", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel type</label>
|
||||||
|
<input className={INPUT} value={state.vesselTypeExperience} onChange={(e) => set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
|
||||||
|
<input type="email" className={INPUT} value={state.email} onChange={(e) => set("email", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Phone</label>
|
||||||
|
<input className={INPUT} value={state.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">CV (PDF/DOC, optional)</label>
|
||||||
|
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={state.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldState = {
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
appliedRankId: string;
|
||||||
|
currentRankId: string;
|
||||||
|
experienceMonths: string;
|
||||||
|
vesselTypeExperience: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyState(): FieldState {
|
||||||
|
return {
|
||||||
|
name: "", source: "CAREERS", appliedRankId: "", currentRankId: "",
|
||||||
|
experienceMonths: "0", vesselTypeExperience: "", email: "", phone: "", notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFrom(c: EditableCandidate): FieldState {
|
||||||
|
return {
|
||||||
|
name: c.name,
|
||||||
|
source: c.source,
|
||||||
|
appliedRankId: c.appliedRankId ?? "",
|
||||||
|
currentRankId: c.currentRankId ?? "",
|
||||||
|
experienceMonths: String(c.experienceMonths),
|
||||||
|
vesselTypeExperience: c.vesselTypeExperience ?? "",
|
||||||
|
email: c.email ?? "",
|
||||||
|
phone: c.phone ?? "",
|
||||||
|
notes: c.notes ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(state: FieldState, file: File | undefined, id?: string): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (id) fd.set("id", id);
|
||||||
|
fd.set("name", state.name);
|
||||||
|
fd.set("source", state.source);
|
||||||
|
if (state.appliedRankId) fd.set("appliedRankId", state.appliedRankId);
|
||||||
|
if (state.currentRankId) fd.set("currentRankId", state.currentRankId);
|
||||||
|
fd.set("experienceMonths", state.experienceMonths || "0");
|
||||||
|
if (state.vesselTypeExperience) fd.set("vesselTypeExperience", state.vesselTypeExperience);
|
||||||
|
if (state.email) fd.set("email", state.email);
|
||||||
|
if (state.phone) fd.set("phone", state.phone);
|
||||||
|
if (state.notes) fd.set("notes", state.notes);
|
||||||
|
if (file && file.size > 0) fd.set("cv", file);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCandidateButton({ ranks }: { ranks: RankOpt[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [state, setState] = useState<FieldState>(emptyState);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await addCandidate(buildFormData(state, fileRef.current?.files?.[0]));
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
setState(emptyState());
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add candidate
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Add candidate" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Adding…" : "Add candidate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditCandidateButton({
|
||||||
|
candidate,
|
||||||
|
ranks,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
candidate: EditableCandidate;
|
||||||
|
ranks: RankOpt[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [state, setState] = useState<FieldState>(() => stateFrom(candidate));
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await updateCandidate(buildFormData(state, fileRef.current?.files?.[0], candidate.id));
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog title="Edit candidate" open={open} onClose={() => onOpenChange(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => onOpenChange(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal file
38
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
||||||
|
import type { BadgeProps } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||||
|
|
||||||
|
export const SOURCE_LABEL: Record<CandidateSource, string> = {
|
||||||
|
CAREERS: "Careers",
|
||||||
|
EX_HAND: "Ex-hand",
|
||||||
|
WALK_IN: "Walk-in",
|
||||||
|
REFERRAL: "Referral",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
||||||
|
PROSPECT: "Prospect",
|
||||||
|
CANDIDATE: "Candidate",
|
||||||
|
EMPLOYEE: "Employee",
|
||||||
|
EX_HAND: "Ex-hand",
|
||||||
|
BLACKLISTED: "Blacklisted",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_VARIANT: Record<CrewStatus, Variant> = {
|
||||||
|
PROSPECT: "outline",
|
||||||
|
CANDIDATE: "default",
|
||||||
|
EMPLOYEE: "success",
|
||||||
|
EX_HAND: "secondary",
|
||||||
|
BLACKLISTED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact experience label, e.g. "3y 6m", "8m", "—".
|
||||||
|
export function experienceLabel(months: number): string {
|
||||||
|
if (!months) return "—";
|
||||||
|
const y = Math.floor(months / 12);
|
||||||
|
const m = months % 12;
|
||||||
|
return [y ? `${y}y` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m";
|
||||||
|
}
|
||||||
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal file
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
||||||
|
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
||||||
|
import { SOURCE_LABEL, SOURCE_OPTIONS, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "./candidate-ui";
|
||||||
|
|
||||||
|
type CandidateRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
status: CrewStatus;
|
||||||
|
appliedRankId: string | null;
|
||||||
|
appliedRank: string | null;
|
||||||
|
currentRankId: string | null;
|
||||||
|
currentRank: string | null;
|
||||||
|
experienceMonths: number;
|
||||||
|
vesselTypeExperience: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
hasCv: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 text-primary-700 px-2.5 py-1 text-xs font-medium">
|
||||||
|
{label}
|
||||||
|
<button onClick={onClear} className="text-primary-400 hover:text-primary-700" aria-label="Remove filter">✕</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditable(c: CandidateRow): EditableCandidate {
|
||||||
|
return {
|
||||||
|
id: c.id, name: c.name, source: c.source,
|
||||||
|
appliedRankId: c.appliedRankId, currentRankId: c.currentRankId,
|
||||||
|
experienceMonths: c.experienceMonths, vesselTypeExperience: c.vesselTypeExperience,
|
||||||
|
email: c.email, phone: c.phone, notes: c.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||||
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
|
||||||
|
{SOURCE_LABEL[c.source]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
||||||
|
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
</div>
|
||||||
|
<EditCandidateButton candidate={toEditable(c)} ranks={ranks} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidatesManager({ candidates, ranks }: { candidates: CandidateRow[]; ranks: RankOpt[] }) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [source, setSource] = useState<"ALL" | CandidateSource>("ALL");
|
||||||
|
const [appliedRankId, setAppliedRankId] = useState("ALL");
|
||||||
|
const [minExp, setMinExp] = useState("");
|
||||||
|
|
||||||
|
const minExpMonths = minExp ? Math.max(0, parseInt(minExp, 10) || 0) : 0;
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return candidates.filter((c) => {
|
||||||
|
if (source !== "ALL" && c.source !== source) return false;
|
||||||
|
if (appliedRankId !== "ALL" && c.appliedRankId !== appliedRankId) return false;
|
||||||
|
if (minExpMonths && c.experienceMonths < minExpMonths) return false;
|
||||||
|
if (q && !`${c.name} ${c.appliedRank ?? ""} ${c.currentRank ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [candidates, search, source, appliedRankId, minExpMonths]);
|
||||||
|
|
||||||
|
const rankName = (id: string) => ranks.find((r) => r.id === id)?.name ?? id;
|
||||||
|
const hasFilters = Boolean(search) || source !== "ALL" || appliedRankId !== "ALL" || Boolean(minExp);
|
||||||
|
const clearAll = () => { setSearch(""); setSource("ALL"); setAppliedRankId("ALL"); setMinExp(""); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Candidates</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">
|
||||||
|
{candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AddCandidateButton ranks={ranks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
<select className={INPUT} value={source} onChange={(e) => setSource(e.target.value as typeof source)}>
|
||||||
|
<option value="ALL">All sources</option>
|
||||||
|
{SOURCE_OPTIONS.map((s) => <option key={s} value={s}>{SOURCE_LABEL[s]}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className={INPUT} value={appliedRankId} onChange={(e) => setAppliedRankId(e.target.value)}>
|
||||||
|
<option value="ALL">Any rank applied</option>
|
||||||
|
{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="number" min={0} className={`${INPUT} w-40`} placeholder="Min exp (months)" value={minExp} onChange={(e) => setMinExp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filter chips + match count */}
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{search && <Chip label={`“${search}”`} onClear={() => setSearch("")} />}
|
||||||
|
{source !== "ALL" && <Chip label={`Source: ${SOURCE_LABEL[source]}`} onClear={() => setSource("ALL")} />}
|
||||||
|
{appliedRankId !== "ALL" && <Chip label={`Rank: ${rankName(appliedRankId)}`} onClear={() => setAppliedRankId("ALL")} />}
|
||||||
|
{minExp && <Chip label={`≥ ${minExp} mo`} onClear={() => setMinExp("")} />}
|
||||||
|
<span className="text-xs text-neutral-500">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
|
||||||
|
<button onClick={clearAll} className="text-xs font-medium text-primary-600 hover:underline">Clear all</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">Name</th>
|
||||||
|
<th className="px-4 py-3">Source</th>
|
||||||
|
<th className="px-4 py-3">Rank held</th>
|
||||||
|
<th className="px-4 py-3">Rank applied</th>
|
||||||
|
<th className="px-4 py-3">Experience</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3 w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
||||||
|
{candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => <CandidateRowView key={c.id} c={c} ranks={ranks} />)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
App/app/(portal)/crewing/candidates/page.tsx
Normal file
50
App/app/(portal)/crewing/candidates/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CandidatesManager } from "./candidates-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Candidates" };
|
||||||
|
|
||||||
|
export default async function CandidatesPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [candidates, ranks] = await Promise.all([
|
||||||
|
db.crewMember.findMany({
|
||||||
|
// Active employees live in the Crew directory (Phase 4); the pool is
|
||||||
|
// everyone still a candidate / ex-hand (spec §8.6 R9).
|
||||||
|
where: { status: { not: "EMPLOYEE" } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
appliedRank: { select: { name: true } },
|
||||||
|
currentRank: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = candidates.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
source: c.source,
|
||||||
|
status: c.status,
|
||||||
|
appliedRankId: c.appliedRankId,
|
||||||
|
appliedRank: c.appliedRank?.name ?? null,
|
||||||
|
currentRankId: c.currentRankId,
|
||||||
|
currentRank: c.currentRank?.name ?? null,
|
||||||
|
experienceMonths: c.experienceMonths,
|
||||||
|
vesselTypeExperience: c.vesselTypeExperience,
|
||||||
|
email: c.email,
|
||||||
|
phone: c.phone,
|
||||||
|
notes: c.notes,
|
||||||
|
hasCv: Boolean(c.cvKey),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
||||||
|
}
|
||||||
124
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
124
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { canCancel } from "@/lib/requisition-state-machine";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { WithdrawRequisitionButton } from "./withdraw-button";
|
||||||
|
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "../requisition-ui";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Requisition" };
|
||||||
|
|
||||||
|
export default async function RequisitionDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const req = await db.requisition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true, code: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
raisedBy: { select: { name: true } },
|
||||||
|
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!req) notFound();
|
||||||
|
|
||||||
|
const location = req.vessel?.name ?? req.site?.name ?? "—";
|
||||||
|
const canWithdraw = hasPermission(session.user.role, "cancel_requisition") && canCancel(req.status, session.user.role);
|
||||||
|
|
||||||
|
const details: [string, string][] = [
|
||||||
|
["Requisition", req.code],
|
||||||
|
["Rank", `${req.rank.name} (${req.rank.code})`],
|
||||||
|
["Vessel / site", location],
|
||||||
|
["Reason", REASON_LABEL[req.reason]],
|
||||||
|
["Raised by", req.autoRaised ? "System (auto-raised)" : req.raisedBy?.name ?? "—"],
|
||||||
|
["Raised", `${ageLabel(req.createdAt.toISOString())} ago`],
|
||||||
|
["Needed by", req.neededBy ? req.neededBy.toLocaleDateString() : "—"],
|
||||||
|
];
|
||||||
|
if (req.status === "CANCELLED" && req.cancellationReason) {
|
||||||
|
details.push(["Withdrawn", req.cancellationReason]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href="/crewing/requisitions" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Requisitions
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{req.rank.name} — {location}</h1>
|
||||||
|
<Badge variant={STATUS_VARIANT[req.status]}>{STATUS_LABEL[req.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 mt-1">
|
||||||
|
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{req.autoRaised && (
|
||||||
|
<div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800">
|
||||||
|
This requisition was <strong>auto-raised by the system</strong> ({REASON_LABEL[req.reason]}). No manual action
|
||||||
|
was needed to open it.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{req.sourceReliefRequest && (
|
||||||
|
<div className="mb-6 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-800">
|
||||||
|
Converted from a relief request raised by{" "}
|
||||||
|
<strong>{req.sourceReliefRequest.requestedBy.name}</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Vacancy details */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Vacancy details</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-neutral-100">
|
||||||
|
{details.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
||||||
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
||||||
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{req.notes && (
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100">
|
||||||
|
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
|
||||||
|
<p className="text-sm text-neutral-700">{req.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Candidates — populated by the recruitment pipeline (Phase 3) */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
|
||||||
|
</div>
|
||||||
|
<p className="px-4 py-12 text-center text-sm text-neutral-400">
|
||||||
|
The recruitment pipeline arrives in a later phase. Candidates attached to this
|
||||||
|
requisition will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { cancelRequisition } from "../actions";
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full 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";
|
||||||
|
|
||||||
|
export function WithdrawRequisitionButton({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await cancelRequisition(id, reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50"
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Withdraw requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Withdrawing closes this requisition. A reason is required and is recorded on the audit trail.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason *</label>
|
||||||
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">
|
||||||
|
{pending ? "Withdrawing…" : "Withdraw requisition"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal file
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import {
|
||||||
|
canCancel,
|
||||||
|
canPerformAction,
|
||||||
|
getTransition,
|
||||||
|
type RequisitionAction,
|
||||||
|
} from "@/lib/requisition-state-machine";
|
||||||
|
import {
|
||||||
|
createRequisitionTx,
|
||||||
|
getMpoRecipients,
|
||||||
|
getOfficeRecipients,
|
||||||
|
requisitionLocationLabel,
|
||||||
|
} from "@/lib/requisition-service";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import { RequisitionReason } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const LIST_PATH = "/crewing/requisitions";
|
||||||
|
|
||||||
|
// Crewing flag + permission guard. Returns the actor on success.
|
||||||
|
async function guard(
|
||||||
|
permission: Permission
|
||||||
|
): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raise a requisition (MPO / Manager) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const raiseSchema = z
|
||||||
|
.object({
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
vesselId: z.string().optional(),
|
||||||
|
siteId: z.string().optional(),
|
||||||
|
reason: z.nativeEnum(RequisitionReason).default("NEW_VACANCY"),
|
||||||
|
neededBy: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
|
||||||
|
message: "A vessel or site is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function raiseRequisition(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("raise_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = raiseSchema.safeParse({
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
|
reason: (formData.get("reason") as string) || undefined,
|
||||||
|
neededBy: (formData.get("neededBy") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const requisition = await db.$transaction((tx) =>
|
||||||
|
createRequisitionTx(tx, {
|
||||||
|
rankId: d.rankId,
|
||||||
|
vesselId: d.vesselId || null,
|
||||||
|
siteId: d.siteId || null,
|
||||||
|
reason: d.reason,
|
||||||
|
neededBy: d.neededBy ? new Date(d.neededBy) : null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
raisedById: g.userId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify the MPO pool so it can start sourcing (spec §11). Don't self-notify.
|
||||||
|
const recipients = (await getMpoRecipients()).filter((u) => u.id !== g.userId);
|
||||||
|
if (recipients.length) {
|
||||||
|
const loc = requisitionLocationLabel(requisition);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "REQUISITION_RAISED",
|
||||||
|
recipients,
|
||||||
|
subject: `Requisition ${requisition.code} raised`,
|
||||||
|
body: `A ${requisition.rank.name} vacancy on ${loc} has been raised (${requisition.code}).`,
|
||||||
|
link: `${LIST_PATH}/${requisition.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: requisition.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Withdraw / cancel a requisition (Manager, from OPEN/SHORTLISTING) ──────────
|
||||||
|
|
||||||
|
export async function cancelRequisition(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("cancel_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const trimmed = reason?.trim();
|
||||||
|
if (!trimmed) return { error: "A reason is required to withdraw a requisition" };
|
||||||
|
|
||||||
|
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!req) return { error: "Requisition not found" };
|
||||||
|
if (!canCancel(req.status, g.role)) {
|
||||||
|
return { error: `A requisition cannot be withdrawn once it is ${req.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.requisition.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
cancellationReason: trimmed,
|
||||||
|
actions: {
|
||||||
|
create: { actionType: "REQUISITION_CANCELLED", actorId: g.userId, note: trimmed },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advance a requisition through the pipeline stages ──────────────────────────
|
||||||
|
// Phase 2 exposes the transitions; the recruitment pipeline (Phase 3) drives
|
||||||
|
// them as candidates progress. Role gating comes from the state machine.
|
||||||
|
|
||||||
|
export async function transitionRequisition(
|
||||||
|
id: string,
|
||||||
|
action: RequisitionAction
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!req) return { error: "Requisition not found" };
|
||||||
|
|
||||||
|
const transition = getTransition(req.status, action);
|
||||||
|
if (!transition) return { error: `Cannot ${action} from ${req.status}` };
|
||||||
|
if (!canPerformAction(req.status, action, session.user.role)) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
await db.requisition.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: transition.to,
|
||||||
|
filledAt: transition.to === "FILLED" ? new Date() : undefined,
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: transition.to === "FILLED" ? "REQUISITION_FILLED" : "REQUISITION_ADVANCED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
metadata: { from: req.status, to: transition.to },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relief cover request (site staff) ──────────────────────────────────────────
|
||||||
|
// Site staff flag a foreseen gap; the office converts it into a requisition. The
|
||||||
|
// site-staff origination UI lands with the Leave/clash screen (Phase 4); the
|
||||||
|
// action exists now so the office-side convert flow and auto-raise share a path.
|
||||||
|
|
||||||
|
const reliefSchema = z
|
||||||
|
.object({
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
vesselId: z.string().optional(),
|
||||||
|
siteId: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
|
||||||
|
message: "A vessel or site is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function requestReliefCover(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("request_relief_cover");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = reliefSchema.safeParse({
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
|
note: (formData.get("note") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const relief = await db.$transaction(async (tx) => {
|
||||||
|
const created = await tx.reliefRequest.create({
|
||||||
|
data: {
|
||||||
|
rankId: d.rankId,
|
||||||
|
vesselId: d.vesselId || null,
|
||||||
|
siteId: d.siteId || null,
|
||||||
|
note: d.note || null,
|
||||||
|
requestedById: g.userId,
|
||||||
|
},
|
||||||
|
include: { rank: true, vessel: true, site: true },
|
||||||
|
});
|
||||||
|
// CrewAction has no relief relation; record the id in metadata.
|
||||||
|
await tx.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: "RELIEF_REQUESTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
metadata: { reliefRequestId: created.id, rankId: d.rankId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = await getOfficeRecipients();
|
||||||
|
if (recipients.length) {
|
||||||
|
const loc = requisitionLocationLabel(relief);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "RELIEF_REQUESTED",
|
||||||
|
recipients,
|
||||||
|
subject: `Relief cover requested — ${relief.rank.name} on ${loc}`,
|
||||||
|
body: `A site has requested relief cover for a ${relief.rank.name} on ${loc}. Convert it to a requisition to start sourcing.`,
|
||||||
|
link: LIST_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: relief.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convert a relief request into a requisition (MPO / Manager) ────────────────
|
||||||
|
|
||||||
|
const convertSchema = z.object({
|
||||||
|
reliefRequestId: z.string().min(1, "Relief request is required"),
|
||||||
|
reason: z.nativeEnum(RequisitionReason).default("REPLACEMENT"),
|
||||||
|
neededBy: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function convertReliefToRequisition(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("convert_relief_to_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = convertSchema.safeParse({
|
||||||
|
reliefRequestId: formData.get("reliefRequestId"),
|
||||||
|
reason: (formData.get("reason") as string) || undefined,
|
||||||
|
neededBy: (formData.get("neededBy") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const relief = await db.reliefRequest.findUnique({ where: { id: d.reliefRequestId } });
|
||||||
|
if (!relief) return { error: "Relief request not found" };
|
||||||
|
if (relief.status !== "OPEN") return { error: "This relief request has already been handled" };
|
||||||
|
|
||||||
|
const requisition = await db.$transaction(async (tx) => {
|
||||||
|
const req = await createRequisitionTx(tx, {
|
||||||
|
rankId: relief.rankId,
|
||||||
|
vesselId: relief.vesselId,
|
||||||
|
siteId: relief.siteId,
|
||||||
|
reason: d.reason,
|
||||||
|
neededBy: d.neededBy ? new Date(d.neededBy) : null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
raisedById: g.userId,
|
||||||
|
});
|
||||||
|
await tx.reliefRequest.update({
|
||||||
|
where: { id: relief.id },
|
||||||
|
data: { status: "CONVERTED", convertedRequisitionId: req.id },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: "RELIEF_CONVERTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
requisitionId: req.id,
|
||||||
|
metadata: { reliefRequestId: relief.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the requester know their relief request became a requisition.
|
||||||
|
const requester = await db.user.findUnique({ where: { id: relief.requestedById } });
|
||||||
|
if (requester && requester.isActive && requester.id !== g.userId) {
|
||||||
|
const loc = requisitionLocationLabel(requisition);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "RELIEF_CONVERTED",
|
||||||
|
recipients: [requester],
|
||||||
|
subject: `Relief cover converted — ${requisition.code}`,
|
||||||
|
body: `Your relief request for a ${requisition.rank.name} on ${loc} is now requisition ${requisition.code}.`,
|
||||||
|
link: `${LIST_PATH}/${requisition.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: requisition.id };
|
||||||
|
}
|
||||||
78
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
78
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { RequisitionsManager } from "./requisitions-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Requisitions" };
|
||||||
|
|
||||||
|
export default async function RequisitionsPage() {
|
||||||
|
// Dark unless the crewing module is switched on.
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
|
||||||
|
const role = session.user.role;
|
||||||
|
|
||||||
|
const [requisitions, reliefRequests, ranks, vessels, sites] = await Promise.all([
|
||||||
|
db.requisition.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
raisedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.reliefRequest.findMany({
|
||||||
|
where: { status: "OPEN" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
requestedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Flatten to plain props — no Date/Decimal crosses the server→client boundary.
|
||||||
|
const rows = requisitions.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
code: r.code,
|
||||||
|
status: r.status,
|
||||||
|
reason: r.reason,
|
||||||
|
autoRaised: r.autoRaised,
|
||||||
|
rankName: r.rank.name,
|
||||||
|
location: r.vessel?.name ?? r.site?.name ?? "—",
|
||||||
|
raisedBy: r.raisedBy?.name ?? "System",
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const relief = reliefRequests.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
rankName: r.rank.name,
|
||||||
|
location: r.vessel?.name ?? r.site?.name ?? "—",
|
||||||
|
note: r.note,
|
||||||
|
requestedBy: r.requestedBy.name,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RequisitionsManager
|
||||||
|
requisitions={rows}
|
||||||
|
reliefRequests={relief}
|
||||||
|
ranks={ranks}
|
||||||
|
vessels={vessels}
|
||||||
|
sites={sites}
|
||||||
|
canRaise={hasPermission(role, "raise_requisition")}
|
||||||
|
canConvert={hasPermission(role, "convert_relief_to_requisition")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal file
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { raiseRequisition, convertReliefToRequisition } from "./actions";
|
||||||
|
import { REASON_OPTIONS, REASON_LABEL } from "./requisition-ui";
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full 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";
|
||||||
|
|
||||||
|
type Opt = { id: string; name: string };
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
// A single "Vessel / site" picker — values are encoded "v:<id>" / "s:<id>" so
|
||||||
|
// one control covers both cost axes (spec §9 modal). Returns "" when unset.
|
||||||
|
function LocationSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
vessels,
|
||||||
|
sites,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
vessels: Opt[];
|
||||||
|
sites: Opt[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select className={INPUT} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="">— Select vessel or site —</option>
|
||||||
|
{vessels.length > 0 && (
|
||||||
|
<optgroup label="Vessels">
|
||||||
|
{vessels.map((v) => (
|
||||||
|
<option key={v.id} value={`v:${v.id}`}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={`s:${s.id}`}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocation(fd: FormData, location: string) {
|
||||||
|
if (location.startsWith("v:")) fd.set("vesselId", location.slice(2));
|
||||||
|
else if (location.startsWith("s:")) fd.set("siteId", location.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raise requisition (MPO / Manager) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RaiseRequisitionButton({
|
||||||
|
ranks,
|
||||||
|
vessels,
|
||||||
|
sites,
|
||||||
|
}: {
|
||||||
|
ranks: RankOpt[];
|
||||||
|
vessels: Opt[];
|
||||||
|
sites: Opt[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [rankId, setRankId] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [reason, setReason] = useState(REASON_OPTIONS[0]);
|
||||||
|
const [neededBy, setNeededBy] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setRankId(""); setLocation(""); setReason(REASON_OPTIONS[0]); setNeededBy(""); setNotes(""); setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("rankId", rankId);
|
||||||
|
applyLocation(fd, location);
|
||||||
|
fd.set("reason", reason);
|
||||||
|
if (neededBy) fd.set("neededBy", neededBy);
|
||||||
|
if (notes) fd.set("notes", notes);
|
||||||
|
|
||||||
|
const result = await raiseRequisition(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Raise requisition
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Raise requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
|
||||||
|
<select className={INPUT} value={rankId} onChange={(e) => setRankId(e.target.value)} required>
|
||||||
|
<option value="">— Select rank —</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
|
||||||
|
<LocationSelect value={location} onChange={setLocation} vessels={vessels} sites={sites} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||||
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||||
|
{REASON_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
||||||
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Raising…" : "Raise requisition"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convert a relief request into a requisition (MPO / Manager) ─────────────────
|
||||||
|
|
||||||
|
export function ConvertReliefButton({
|
||||||
|
reliefRequestId,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
reliefRequestId: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [reason, setReason] = useState<typeof REASON_OPTIONS[number]>("REPLACEMENT");
|
||||||
|
const [neededBy, setNeededBy] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("reliefRequestId", reliefRequestId);
|
||||||
|
fd.set("reason", reason);
|
||||||
|
if (neededBy) fd.set("neededBy", neededBy);
|
||||||
|
if (notes) fd.set("notes", notes);
|
||||||
|
|
||||||
|
const result = await convertReliefToRequisition(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Convert to requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Convert the relief request <span className="font-medium text-neutral-900">{label}</span> into an open
|
||||||
|
requisition so sourcing can begin.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||||
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||||
|
{REASON_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
||||||
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Converting…" : "Convert"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal file
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
||||||
|
import type { BadgeProps } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||||
|
|
||||||
|
// Status → badge variant (Crewing-Implementation-Spec §8.2).
|
||||||
|
export const STATUS_VARIANT: Record<RequisitionStatus, Variant> = {
|
||||||
|
OPEN: "outline",
|
||||||
|
SHORTLISTING: "default",
|
||||||
|
PROPOSING: "default",
|
||||||
|
INTERVIEWING: "warning",
|
||||||
|
SELECTED: "default",
|
||||||
|
FILLED: "success",
|
||||||
|
CANCELLED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<RequisitionStatus, string> = {
|
||||||
|
OPEN: "Open",
|
||||||
|
SHORTLISTING: "Shortlisting",
|
||||||
|
PROPOSING: "Proposing",
|
||||||
|
INTERVIEWING: "Interviewing",
|
||||||
|
SELECTED: "Selected",
|
||||||
|
FILLED: "Filled",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASON_LABEL: Record<RequisitionReason, string> = {
|
||||||
|
NEW_VACANCY: "New vacancy",
|
||||||
|
REPLACEMENT: "Replacement",
|
||||||
|
LEAVE: "Leave cover",
|
||||||
|
SIGN_OFF: "Sign-off",
|
||||||
|
END_OF_CONTRACT: "End of contract",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASON_OPTIONS: RequisitionReason[] = [
|
||||||
|
"NEW_VACANCY",
|
||||||
|
"REPLACEMENT",
|
||||||
|
"LEAVE",
|
||||||
|
"SIGN_OFF",
|
||||||
|
"END_OF_CONTRACT",
|
||||||
|
"OTHER",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compact "age" label (e.g. "3d", "5h", "12m") relative to now.
|
||||||
|
export function ageLabel(iso: string): string {
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
|
if (mins < 60) return `${Math.max(mins, 0)}m`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h`;
|
||||||
|
return `${Math.floor(hrs / 24)}d`;
|
||||||
|
}
|
||||||
204
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
204
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"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;
|
||||||
|
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 locations = useMemo(
|
||||||
|
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
||||||
|
[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 (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [requisitions, search, status, location]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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">Raised by</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} 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-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,8 @@ import {
|
||||||
UserCircle,
|
UserCircle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Network,
|
Network,
|
||||||
|
ClipboardList,
|
||||||
|
UserSearch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -69,11 +71,16 @@ const PURCHASING_MGMT: NavItem[] = [
|
||||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||||
|
|
||||||
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||||
// Scaffold for the Crewing module. Phase 1 (Foundations) adds no top-level items
|
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
||||||
// here — its only screen, "Ranks & documents", lives under Administration. Later
|
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
|
||||||
// phases append Requisitions / Candidates / Crew / Leave / Attendance /
|
// / Attendance / Verification with their per-role visibility. "Ranks & documents"
|
||||||
// Verification with their per-role visibility (see Crewing-Implementation-Spec §7).
|
// lives under Administration.
|
||||||
const CREWING_ITEMS: NavItem[] = [];
|
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
||||||
|
? [
|
||||||
|
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
|
||||||
|
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
// ── Administration section ────────────────────────────────────────────────────
|
// ── Administration section ────────────────────────────────────────────────────
|
||||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export type NotificationEvent =
|
||||||
| "RECEIPT_CONFIRMED"
|
| "RECEIPT_CONFIRMED"
|
||||||
| "PARTIAL_RECEIPT_CONFIRMED";
|
| "PARTIAL_RECEIPT_CONFIRMED";
|
||||||
|
|
||||||
|
// Crewing notification events (Crewing-Implementation-Spec §4.5/§11). These are
|
||||||
|
// not tied to a PurchaseOrder, so they go through notifyCrew() and store a
|
||||||
|
// Notification row with a null poId. Extended per phase; Phase 2 covers
|
||||||
|
// requisitions + relief.
|
||||||
|
export type CrewNotificationEvent =
|
||||||
|
| "REQUISITION_RAISED"
|
||||||
|
| "RELIEF_REQUESTED"
|
||||||
|
| "RELIEF_CONVERTED";
|
||||||
|
|
||||||
interface NotifyParams {
|
interface NotifyParams {
|
||||||
event: NotificationEvent;
|
event: NotificationEvent;
|
||||||
po: PurchaseOrder & { submitter: User };
|
po: PurchaseOrder & { submitter: User };
|
||||||
|
|
@ -398,3 +407,99 @@ function buildHtml(
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Crewing notifications ──────────────────────────────────────────────────────
|
||||||
|
// A PO-independent path: callers compose the subject/body/link (which embed the
|
||||||
|
// crewing entity details) and pick recipients. Mirrors notify()'s dev-console /
|
||||||
|
// Resend / Notification-row behaviour, but writes rows with a null poId.
|
||||||
|
|
||||||
|
interface CrewNotifyParams {
|
||||||
|
event: CrewNotificationEvent;
|
||||||
|
recipients: User[];
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
|
||||||
|
REQUISITION_RAISED: "View Requisition",
|
||||||
|
RELIEF_REQUESTED: "View Requisitions",
|
||||||
|
RELIEF_CONVERTED: "View Requisition",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
|
||||||
|
await Promise.allSettled(
|
||||||
|
recipients.map(async (recipient) => {
|
||||||
|
let status = "sent";
|
||||||
|
if (isDev) {
|
||||||
|
console.log(
|
||||||
|
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${body}\n Link: ${APP_URL}${link ?? ""}\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { error } = await resend!.emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: recipient.email,
|
||||||
|
subject,
|
||||||
|
html: buildCrewHtml(event, recipient, subject, body, link),
|
||||||
|
});
|
||||||
|
if (error) status = "failed";
|
||||||
|
} catch {
|
||||||
|
status = "failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.notification.create({
|
||||||
|
data: { subject, body, link: link ?? null, status, userId: recipient.id },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCrewHtml(
|
||||||
|
event: CrewNotificationEvent,
|
||||||
|
recipient: User,
|
||||||
|
subject: string,
|
||||||
|
body: string,
|
||||||
|
link?: string
|
||||||
|
): string {
|
||||||
|
const actionUrl = link ? `${APP_URL}${link}` : APP_URL;
|
||||||
|
const actionLabel = CREW_ACTION_LABEL[event] ?? "Open PPMS";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,-apple-system,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 16px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr><td style="background:#1d4ed8;padding:20px 32px;">
|
||||||
|
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">PPMS</span>
|
||||||
|
<span style="font-size:13px;color:#93c5fd;margin-left:10px;">Crewing</span>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr><td style="padding:32px;">
|
||||||
|
<p style="margin:0 0 20px;font-size:15px;color:#111827;">Hi ${recipient.name},</p>
|
||||||
|
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">${body}</p>
|
||||||
|
|
||||||
|
<table cellpadding="0" cellspacing="0">
|
||||||
|
<tr><td style="background:#2563eb;border-radius:8px;">
|
||||||
|
<a href="${actionUrl}" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${actionLabel} →</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr><td style="background:#f8fafc;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#9ca3af;">${subject}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
|
||||||
34
App/lib/requisition-number.ts
Normal file
34
App/lib/requisition-number.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Requisition code generator. Format: REQ-<id>, e.g. REQ-9000.
|
||||||
|
*
|
||||||
|
* The id is a globally sequential integer floored at 9000 (mirroring the PO
|
||||||
|
* numbering convention in lib/po-number.ts) so generated codes never collide
|
||||||
|
* with any future imported/historical numbering. Call inside the same
|
||||||
|
* transaction that creates the requisition to minimise race windows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
const PREFIX = "REQ-";
|
||||||
|
const FLOOR = 8999; // first generated id is 9000
|
||||||
|
|
||||||
|
/** Next sequential requisition id by scanning existing REQ- codes. */
|
||||||
|
async function nextRequisitionId(client: Prisma.TransactionClient | typeof db): Promise<number> {
|
||||||
|
const rows = await client.requisition.findMany({ select: { code: true } });
|
||||||
|
let maxId = FLOOR;
|
||||||
|
for (const { code } of rows) {
|
||||||
|
if (!code.startsWith(PREFIX)) continue;
|
||||||
|
const n = parseInt(code.slice(PREFIX.length), 10);
|
||||||
|
if (!isNaN(n) && n > maxId) maxId = n;
|
||||||
|
}
|
||||||
|
return maxId + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate the next requisition code (e.g. "REQ-9000"). */
|
||||||
|
export async function generateRequisitionCode(
|
||||||
|
client: Prisma.TransactionClient | typeof db = db
|
||||||
|
): Promise<string> {
|
||||||
|
const id = await nextRequisitionId(client);
|
||||||
|
return `${PREFIX}${id}`;
|
||||||
|
}
|
||||||
108
App/lib/requisition-service.ts
Normal file
108
App/lib/requisition-service.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Requisition service helpers shared by the crewing server actions and by the
|
||||||
|
* system auto-raise paths (sign-off / end-of-contract / leave-clash backfill,
|
||||||
|
* Phase 3/4). Kept out of the "use server" action module so non-action code can
|
||||||
|
* import the auto-raise helper. See Crewing-Implementation-Spec §5.2/§5.3 (R6).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { generateRequisitionCode } from "@/lib/requisition-number";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import type { Prisma, RequisitionReason, User } from "@prisma/client";
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
|
export interface NewRequisitionInput {
|
||||||
|
rankId: string;
|
||||||
|
vesselId?: string | null;
|
||||||
|
siteId?: string | null;
|
||||||
|
reason: RequisitionReason;
|
||||||
|
neededBy?: Date | null;
|
||||||
|
notes?: string | null;
|
||||||
|
raisedById?: string | null; // null = system-raised
|
||||||
|
autoRaised?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequisitionWithRefs = Prisma.RequisitionGetPayload<{
|
||||||
|
include: { rank: true; vessel: true; site: true };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core requisition creator — run inside a transaction. Generates the code and
|
||||||
|
* writes the REQUISITION_RAISED CrewAction. Callers own notification + any
|
||||||
|
* relief-request linking afterwards.
|
||||||
|
*/
|
||||||
|
export async function createRequisitionTx(
|
||||||
|
tx: Tx,
|
||||||
|
input: NewRequisitionInput
|
||||||
|
): Promise<RequisitionWithRefs> {
|
||||||
|
const code = await generateRequisitionCode(tx);
|
||||||
|
return tx.requisition.create({
|
||||||
|
data: {
|
||||||
|
code,
|
||||||
|
reason: input.reason,
|
||||||
|
autoRaised: input.autoRaised ?? false,
|
||||||
|
neededBy: input.neededBy ?? null,
|
||||||
|
notes: input.notes ?? null,
|
||||||
|
rankId: input.rankId,
|
||||||
|
vesselId: input.vesselId ?? null,
|
||||||
|
siteId: input.siteId ?? null,
|
||||||
|
raisedById: input.raisedById ?? null,
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: "REQUISITION_RAISED",
|
||||||
|
actorId: input.raisedById ?? null,
|
||||||
|
metadata: input.autoRaised ? { auto: true, reason: input.reason } : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { rank: true, vessel: true, site: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a requisition's cost axis (vessel preferred, else site). */
|
||||||
|
export function requisitionLocationLabel(r: {
|
||||||
|
vessel: { name: string } | null;
|
||||||
|
site: { name: string } | null;
|
||||||
|
}): string {
|
||||||
|
return r.vessel?.name ?? r.site?.name ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Office recipients (MPO sources recruitment; Manager oversees). */
|
||||||
|
export function getOfficeRecipients(): Promise<User[]> {
|
||||||
|
return db.user.findMany({
|
||||||
|
where: { isActive: true, role: { in: ["MANNING", "MANAGER", "SUPERUSER"] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MPO recipients — for "requisition raised → MPO" (spec §11). */
|
||||||
|
export function getMpoRecipients(): Promise<User[]> {
|
||||||
|
return db.user.findMany({
|
||||||
|
where: { isActive: true, role: { in: ["MANNING", "SUPERUSER"] } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
|
||||||
|
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
|
||||||
|
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
|
||||||
|
*/
|
||||||
|
export async function autoRaiseRequisition(
|
||||||
|
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
|
||||||
|
): Promise<RequisitionWithRefs> {
|
||||||
|
const requisition = await db.$transaction((tx) =>
|
||||||
|
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
const recipients = await getOfficeRecipients();
|
||||||
|
const loc = requisitionLocationLabel(requisition);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "REQUISITION_RAISED",
|
||||||
|
recipients,
|
||||||
|
subject: `Requisition ${requisition.code} auto-raised`,
|
||||||
|
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
|
||||||
|
link: `/crewing/requisitions/${requisition.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return requisition;
|
||||||
|
}
|
||||||
88
App/lib/requisition-state-machine.ts
Normal file
88
App/lib/requisition-state-machine.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import type { RequisitionStatus, Role } from "@prisma/client";
|
||||||
|
|
||||||
|
// Requisition lifecycle state machine — mirrors the PO state machine
|
||||||
|
// (lib/po-state-machine.ts) and the reconciled spec (Crewing-Implementation-Spec
|
||||||
|
// §5.2): OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
|
||||||
|
// with CANCELLED reachable from OPEN/SHORTLISTING (Manager).
|
||||||
|
//
|
||||||
|
// The intermediate stage advances are driven by the recruitment pipeline that
|
||||||
|
// lands in Phase 3; they are modelled here now so the transitions, allowed
|
||||||
|
// roles and audit are settled and testable. Phase 2 wires raise (create OPEN)
|
||||||
|
// and cancel via server actions; selection is Manager-only (spec §6).
|
||||||
|
|
||||||
|
export type RequisitionAction =
|
||||||
|
| "start_shortlisting"
|
||||||
|
| "mark_proposing"
|
||||||
|
| "start_interviewing"
|
||||||
|
| "mark_selected"
|
||||||
|
| "mark_filled";
|
||||||
|
|
||||||
|
interface Transition {
|
||||||
|
to: RequisitionStatus;
|
||||||
|
allowedRoles: Role[];
|
||||||
|
requiresNote: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransitionMap = Partial<Record<RequisitionAction, Transition>>;
|
||||||
|
|
||||||
|
// MPO (MANNING) and Manager source recruitment; final selection is Manager-only.
|
||||||
|
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
||||||
|
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
||||||
|
|
||||||
|
const TRANSITIONS: Partial<Record<RequisitionStatus, TransitionMap>> = {
|
||||||
|
OPEN: {
|
||||||
|
start_shortlisting: { to: "SHORTLISTING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
||||||
|
},
|
||||||
|
SHORTLISTING: {
|
||||||
|
mark_proposing: { to: "PROPOSING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
||||||
|
},
|
||||||
|
PROPOSING: {
|
||||||
|
start_interviewing: { to: "INTERVIEWING", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
||||||
|
},
|
||||||
|
INTERVIEWING: {
|
||||||
|
// Final selection of a candidate is a Manager approval (spec §6).
|
||||||
|
mark_selected: { to: "SELECTED", allowedRoles: MANAGER_ROLES, requiresNote: false },
|
||||||
|
},
|
||||||
|
SELECTED: {
|
||||||
|
// The onboarding side-effect (Phase 3) fills the vacancy.
|
||||||
|
mark_filled: { to: "FILLED", allowedRoles: SOURCING_ROLES, requiresNote: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTransition(from: RequisitionStatus, action: RequisitionAction): Transition | null {
|
||||||
|
return TRANSITIONS[from]?.[action] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPerformAction(
|
||||||
|
from: RequisitionStatus,
|
||||||
|
action: RequisitionAction,
|
||||||
|
role: Role
|
||||||
|
): boolean {
|
||||||
|
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableActions(status: RequisitionStatus, role: Role): RequisitionAction[] {
|
||||||
|
const map = TRANSITIONS[status];
|
||||||
|
if (!map) return [];
|
||||||
|
return (Object.keys(map) as RequisitionAction[]).filter((action) =>
|
||||||
|
canPerformAction(status, action, role)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiresNote(from: RequisitionStatus, action: RequisitionAction): boolean {
|
||||||
|
return getTransition(from, action)?.requiresNote ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cancellation (orthogonal) ────────────────────────────────────────────────
|
||||||
|
// A requisition may be withdrawn while it is still early in the pipeline — OPEN
|
||||||
|
// or SHORTLISTING (spec §5.2) — and a reason is required. WHO may cancel is the
|
||||||
|
// `cancel_requisition` grant (spec §6: MPO + Manager + SuperUser); the actions
|
||||||
|
// enforce that permission, and CANCEL_ROLES mirrors it so the state machine and
|
||||||
|
// the matrix agree. Modelled separately from TRANSITIONS, like PO CANCEL_ROLES.
|
||||||
|
|
||||||
|
export const CANCEL_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
||||||
|
export const CANCELLABLE_FROM: RequisitionStatus[] = ["OPEN", "SHORTLISTING"];
|
||||||
|
|
||||||
|
export function canCancel(from: RequisitionStatus, role: Role): boolean {
|
||||||
|
return CANCELLABLE_FROM.includes(from) && CANCEL_ROLES.includes(role);
|
||||||
|
}
|
||||||
|
|
@ -44,13 +44,15 @@ export async function generateDownloadUrl(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStorageKey(
|
export function buildStorageKey(
|
||||||
type: "po-document" | "receipt",
|
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
||||||
poId: string,
|
// phases — see Crewing-Implementation-Spec §4.5.
|
||||||
|
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
|
||||||
|
ownerId: string,
|
||||||
fileName: string
|
fileName: string
|
||||||
): string {
|
): string {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
return `${type}/${poId}/${timestamp}-${safe}`;
|
return `${type}/${ownerId}/${timestamp}-${safe}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSignatureKey(userId: string, ext: string): string {
|
export function buildSignatureKey(userId: string, ext: string): string {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RequisitionStatus" AS ENUM ('OPEN', 'SHORTLISTING', 'PROPOSING', 'INTERVIEWING', 'SELECTED', 'FILLED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RequisitionReason" AS ENUM ('NEW_VACANCY', 'REPLACEMENT', 'LEAVE', 'SIGN_OFF', 'END_OF_CONTRACT', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ReliefRequestStatus" AS ENUM ('OPEN', 'CONVERTED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CrewActionType" AS ENUM ('REQUISITION_RAISED', 'REQUISITION_ADVANCED', 'REQUISITION_FILLED', 'REQUISITION_CANCELLED', 'RELIEF_REQUESTED', 'RELIEF_CONVERTED', 'RELIEF_CANCELLED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Requisition" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"status" "RequisitionStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"reason" "RequisitionReason" NOT NULL DEFAULT 'NEW_VACANCY',
|
||||||
|
"autoRaised" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"neededBy" TIMESTAMP(3),
|
||||||
|
"notes" TEXT,
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"cancellationReason" TEXT,
|
||||||
|
"filledAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"rankId" TEXT NOT NULL,
|
||||||
|
"vesselId" TEXT,
|
||||||
|
"siteId" TEXT,
|
||||||
|
"raisedById" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Requisition_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReliefRequest" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"status" "ReliefRequestStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"rankId" TEXT NOT NULL,
|
||||||
|
"vesselId" TEXT,
|
||||||
|
"siteId" TEXT,
|
||||||
|
"requestedById" TEXT NOT NULL,
|
||||||
|
"convertedRequisitionId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "ReliefRequest_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CrewAction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"actionType" "CrewActionType" NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"requisitionId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CrewAction_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Requisition_code_key" ON "Requisition"("code");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ReliefRequest_convertedRequisitionId_key" ON "ReliefRequest"("convertedRequisitionId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_raisedById_fkey" FOREIGN KEY ("raisedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_convertedRequisitionId_fkey" FOREIGN KEY ("convertedRequisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CrewStatus" AS ENUM ('PROSPECT', 'CANDIDATE', 'EMPLOYEE', 'EX_HAND', 'BLACKLISTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CandidateType" AS ENUM ('NEW', 'EX_HAND');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CandidateSource" AS ENUM ('CAREERS', 'EX_HAND', 'WALK_IN', 'REFERRAL', 'OTHER');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_ADDED';
|
||||||
|
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_UPDATED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CrewAction" ADD COLUMN "crewMemberId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CrewMember" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"employeeId" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"status" "CrewStatus" NOT NULL DEFAULT 'CANDIDATE',
|
||||||
|
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
|
||||||
|
"source" "CandidateSource" NOT NULL DEFAULT 'CAREERS',
|
||||||
|
"email" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"dob" TIMESTAMP(3),
|
||||||
|
"experienceMonths" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"vesselTypeExperience" TEXT,
|
||||||
|
"cvKey" TEXT,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"currentRankId" TEXT,
|
||||||
|
"appliedRankId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "CrewMember_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "CrewMember_employeeId_key" ON "CrewMember"("employeeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_currentRankId_fkey" FOREIGN KEY ("currentRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_appliedRankId_fkey" FOREIGN KEY ("appliedRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
@ -87,6 +87,81 @@ enum SeafarerDocType {
|
||||||
CONTRACT_LETTER
|
CONTRACT_LETTER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Crewing lifecycle (Phase 2: Requisitions + relief) ─────────────────────
|
||||||
|
// Requisition lifecycle — Crewing-Implementation-Spec §5.2. The intermediate
|
||||||
|
// stages (SHORTLISTING…SELECTED) are advanced by the recruitment pipeline that
|
||||||
|
// lands in Phase 3; Phase 2 wires OPEN, CANCELLED and the FILLED terminal.
|
||||||
|
enum RequisitionStatus {
|
||||||
|
OPEN
|
||||||
|
SHORTLISTING
|
||||||
|
PROPOSING
|
||||||
|
INTERVIEWING
|
||||||
|
SELECTED
|
||||||
|
FILLED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Why a vacancy exists. LEAVE / SIGN_OFF / END_OF_CONTRACT are the system
|
||||||
|
// auto-raise reasons (§5.2/§5.3); the rest are raised manually by MPO/Manager.
|
||||||
|
enum RequisitionReason {
|
||||||
|
NEW_VACANCY
|
||||||
|
REPLACEMENT
|
||||||
|
LEAVE
|
||||||
|
SIGN_OFF
|
||||||
|
END_OF_CONTRACT
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreseen-gap flag raised by site staff (§8.2 "Relief requests from sites").
|
||||||
|
// The office converts an OPEN relief request into a real requisition.
|
||||||
|
enum ReliefRequestStatus {
|
||||||
|
OPEN
|
||||||
|
CONVERTED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crewing audit-trail action types — the CrewAction mirror of ActionType for
|
||||||
|
// POAction (§4.5/§11). Extended per phase; Phase 2 covers requisition + relief,
|
||||||
|
// Phase 3a adds candidate intake.
|
||||||
|
enum CrewActionType {
|
||||||
|
REQUISITION_RAISED
|
||||||
|
REQUISITION_ADVANCED
|
||||||
|
REQUISITION_FILLED
|
||||||
|
REQUISITION_CANCELLED
|
||||||
|
RELIEF_REQUESTED
|
||||||
|
RELIEF_CONVERTED
|
||||||
|
RELIEF_CANCELLED
|
||||||
|
CANDIDATE_ADDED
|
||||||
|
CANDIDATE_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Crewing candidates (Phase 3a: Epic B) ──────────────────────────────────
|
||||||
|
// A CrewMember is the talent-pool spine: a row exists from first contact and
|
||||||
|
// persists through CANDIDATE → EMPLOYEE → EX_HAND. `employeeId` is assigned only
|
||||||
|
// at onboarding (Phase 3c). See Crewing-Data-Model §4 + Implementation-Spec §8.6.
|
||||||
|
enum CrewStatus {
|
||||||
|
PROSPECT
|
||||||
|
CANDIDATE
|
||||||
|
EMPLOYEE
|
||||||
|
EX_HAND
|
||||||
|
BLACKLISTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW applicants vs returning EX_HAND crew (drives the ex-hand affordances).
|
||||||
|
enum CandidateType {
|
||||||
|
NEW
|
||||||
|
EX_HAND
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the candidate came from (the §8.6 "Source" column; ex-hand renders purple).
|
||||||
|
enum CandidateSource {
|
||||||
|
CAREERS
|
||||||
|
EX_HAND
|
||||||
|
WALK_IN
|
||||||
|
REFERRAL
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
employeeId String @unique
|
employeeId String @unique
|
||||||
|
|
@ -99,12 +174,15 @@ model User {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||||
actions POAction[]
|
actions POAction[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
consumption ItemConsumption[]
|
consumption ItemConsumption[]
|
||||||
superUserRequests SuperUserRequest[] @relation("Requester")
|
superUserRequests SuperUserRequest[] @relation("Requester")
|
||||||
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
|
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
|
||||||
|
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
|
||||||
|
reliefRequested ReliefRequest[] @relation("ReliefRequester")
|
||||||
|
crewActions CrewAction[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model SuperUserRequest {
|
model SuperUserRequest {
|
||||||
|
|
@ -133,15 +211,19 @@ model Site {
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
inventory ItemInventory[]
|
inventory ItemInventory[]
|
||||||
consumption ItemConsumption[]
|
consumption ItemConsumption[]
|
||||||
|
requisitions Requisition[]
|
||||||
|
reliefRequests ReliefRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vessel {
|
model Vessel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
code String @unique
|
code String @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
|
requisitions Requisition[]
|
||||||
|
reliefRequests ReliefRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
|
|
@ -155,8 +237,8 @@ model Company {
|
||||||
email String?
|
email String?
|
||||||
invoiceEmail String?
|
invoiceEmail String?
|
||||||
invoiceAddress String?
|
invoiceAddress String?
|
||||||
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||||||
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
@ -180,12 +262,12 @@ model Account {
|
||||||
}
|
}
|
||||||
|
|
||||||
model VendorContact {
|
model VendorContact {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
role String?
|
role String?
|
||||||
mobile String?
|
mobile String?
|
||||||
email String?
|
email String?
|
||||||
isPrimary Boolean @default(false)
|
isPrimary Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
vendorId String
|
vendorId String
|
||||||
|
|
@ -193,17 +275,17 @@ model VendorContact {
|
||||||
}
|
}
|
||||||
|
|
||||||
model Vendor {
|
model Vendor {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
vendorId String? @unique
|
vendorId String? @unique
|
||||||
address String?
|
address String?
|
||||||
pincode String?
|
pincode String?
|
||||||
gstin String?
|
gstin String?
|
||||||
latitude Float?
|
latitude Float?
|
||||||
longitude Float?
|
longitude Float?
|
||||||
isVerified Boolean @default(false)
|
isVerified Boolean @default(false)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
contacts VendorContact[]
|
contacts VendorContact[]
|
||||||
purchaseOrders PurchaseOrder[]
|
purchaseOrders PurchaseOrder[]
|
||||||
|
|
@ -272,51 +354,51 @@ model ItemConsumption {
|
||||||
}
|
}
|
||||||
|
|
||||||
model PurchaseOrder {
|
model PurchaseOrder {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
poNumber String @unique
|
poNumber String @unique
|
||||||
title String
|
title String
|
||||||
status POStatus @default(DRAFT)
|
status POStatus @default(DRAFT)
|
||||||
totalAmount Decimal @db.Decimal(12, 2)
|
totalAmount Decimal @db.Decimal(12, 2)
|
||||||
currency String @default("INR")
|
currency String @default("INR")
|
||||||
dateRequired DateTime?
|
dateRequired DateTime?
|
||||||
projectCode String?
|
projectCode String?
|
||||||
managerNote String?
|
managerNote String?
|
||||||
paymentRef String?
|
paymentRef String?
|
||||||
paymentDate DateTime?
|
paymentDate DateTime?
|
||||||
paidAmount Decimal? @db.Decimal(12, 2)
|
paidAmount Decimal? @db.Decimal(12, 2)
|
||||||
piQuotationNo String?
|
piQuotationNo String?
|
||||||
piQuotationDate DateTime?
|
piQuotationDate DateTime?
|
||||||
requisitionNo String?
|
requisitionNo String?
|
||||||
requisitionDate DateTime?
|
requisitionDate DateTime?
|
||||||
placeOfDelivery String?
|
placeOfDelivery String?
|
||||||
tcDelivery String?
|
tcDelivery String?
|
||||||
tcDispatch String?
|
tcDispatch String?
|
||||||
tcInspection String?
|
tcInspection String?
|
||||||
tcTransitInsurance String?
|
tcTransitInsurance String?
|
||||||
tcPaymentTerms String?
|
tcPaymentTerms String?
|
||||||
tcOthers String?
|
tcOthers String?
|
||||||
poDate DateTime?
|
poDate DateTime?
|
||||||
submittedAt DateTime?
|
submittedAt DateTime?
|
||||||
approvedAt DateTime?
|
approvedAt DateTime?
|
||||||
paidAt DateTime?
|
paidAt DateTime?
|
||||||
closedAt DateTime?
|
closedAt DateTime?
|
||||||
cancelledAt DateTime?
|
cancelledAt DateTime?
|
||||||
cancellationReason String?
|
cancellationReason String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
submitterId String
|
submitterId String
|
||||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||||
vesselId String
|
vesselId String
|
||||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
companyId String?
|
companyId String?
|
||||||
company Company? @relation(fields: [companyId], references: [id])
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
vendorId String?
|
vendorId String?
|
||||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||||
siteId String?
|
siteId String?
|
||||||
site Site? @relation(fields: [siteId], references: [id])
|
site Site? @relation(fields: [siteId], references: [id])
|
||||||
|
|
||||||
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
|
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
|
||||||
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
||||||
|
|
@ -423,10 +505,14 @@ model Rank {
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
parentId String?
|
parentId String?
|
||||||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||||||
children Rank[] @relation("RankHierarchy")
|
children Rank[] @relation("RankHierarchy")
|
||||||
|
|
||||||
docRequirements RankDocRequirement[]
|
docRequirements RankDocRequirement[]
|
||||||
|
requisitions Requisition[]
|
||||||
|
reliefRequests ReliefRequest[]
|
||||||
|
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
||||||
|
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which documents a rank is required (or conditionally required) to hold.
|
// Which documents a rank is required (or conditionally required) to hold.
|
||||||
|
|
@ -442,3 +528,116 @@ model RankDocRequirement {
|
||||||
|
|
||||||
@@unique([rankId, docType])
|
@@unique([rankId, docType])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Crewing lifecycle models (Phase 2) ──────────────────────────────────────
|
||||||
|
|
||||||
|
// A vacancy to be filled for a rank on a vessel/site. Raised manually by
|
||||||
|
// MPO/Manager, or auto-raised by the system on a leave clash / sign-off / EOC
|
||||||
|
// (autoRaised = true). The recruitment pipeline (Phase 3) attaches candidates
|
||||||
|
// and drives the intermediate stages. See Crewing-Implementation-Spec §5.2/§8.
|
||||||
|
model Requisition {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique // mono id, e.g. REQ-9000
|
||||||
|
status RequisitionStatus @default(OPEN)
|
||||||
|
reason RequisitionReason @default(NEW_VACANCY)
|
||||||
|
autoRaised Boolean @default(false)
|
||||||
|
neededBy DateTime?
|
||||||
|
notes String?
|
||||||
|
cancelledAt DateTime?
|
||||||
|
cancellationReason String?
|
||||||
|
filledAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rankId String
|
||||||
|
rank Rank @relation(fields: [rankId], references: [id])
|
||||||
|
vesselId String?
|
||||||
|
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||||||
|
siteId String?
|
||||||
|
site Site? @relation(fields: [siteId], references: [id])
|
||||||
|
|
||||||
|
// Null when the system auto-raised it.
|
||||||
|
raisedById String?
|
||||||
|
raisedBy User? @relation("RequisitionRaiser", fields: [raisedById], references: [id])
|
||||||
|
|
||||||
|
// The site relief request this requisition was converted from, if any.
|
||||||
|
sourceReliefRequest ReliefRequest? @relation("ReliefConversion")
|
||||||
|
|
||||||
|
actions CrewAction[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreseen-gap flag from a site (site staff), pending office conversion into a
|
||||||
|
// Requisition. Complementary, proactive channel to the auto-raised LEAVE
|
||||||
|
// requisition. See Crewing-Implementation-Spec §8.2 (R3/R6).
|
||||||
|
model ReliefRequest {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status ReliefRequestStatus @default(OPEN)
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rankId String
|
||||||
|
rank Rank @relation(fields: [rankId], references: [id])
|
||||||
|
vesselId String?
|
||||||
|
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||||||
|
siteId String?
|
||||||
|
site Site? @relation(fields: [siteId], references: [id])
|
||||||
|
|
||||||
|
requestedById String
|
||||||
|
requestedBy User @relation("ReliefRequester", fields: [requestedById], references: [id])
|
||||||
|
|
||||||
|
// Set when an MPO/Manager converts it; one relief request → one requisition.
|
||||||
|
convertedRequisitionId String? @unique
|
||||||
|
convertedRequisition Requisition? @relation("ReliefConversion", fields: [convertedRequisitionId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crewing audit trail — one row per transition / verification (mirror of
|
||||||
|
// POAction). Entity relations are added per phase; Phase 2 links requisitions,
|
||||||
|
// Phase 3a adds candidates. A row references at most one entity (the rest null).
|
||||||
|
model CrewAction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actionType CrewActionType
|
||||||
|
note String?
|
||||||
|
metadata Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Null for system-performed actions (auto-raise).
|
||||||
|
actorId String?
|
||||||
|
actor User? @relation(fields: [actorId], references: [id])
|
||||||
|
|
||||||
|
requisitionId String?
|
||||||
|
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
||||||
|
crewMemberId String?
|
||||||
|
crewMember CrewMember? @relation(fields: [crewMemberId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The talent-pool spine (Phase 3a, Epic B). One row per person, created the
|
||||||
|
// moment they enter the pool and kept through CANDIDATE → EMPLOYEE → EX_HAND, so
|
||||||
|
// an ex-hand's history/documents are already on file. `employeeId` is assigned
|
||||||
|
// at onboarding (Phase 3c). The recruitment pipeline (Applications, Phase 3b)
|
||||||
|
// and crew records (Phase 4) hang off this model. See Crewing-Data-Model §4.
|
||||||
|
model CrewMember {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
employeeId String? @unique // assigned at onboarding (Phase 3c)
|
||||||
|
name String
|
||||||
|
status CrewStatus @default(CANDIDATE)
|
||||||
|
type CandidateType @default(NEW)
|
||||||
|
source CandidateSource @default(CAREERS)
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
dob DateTime?
|
||||||
|
experienceMonths Int @default(0)
|
||||||
|
vesselTypeExperience String? // free-text "vessel type" from the Add-candidate modal
|
||||||
|
cvKey String? // storage key for an uploaded CV (no parsing yet — A2 deferred)
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Rank held / last held (ex-hands) and the rank being applied for.
|
||||||
|
currentRankId String?
|
||||||
|
currentRank Rank? @relation("CrewCurrentRank", fields: [currentRankId], references: [id])
|
||||||
|
appliedRankId String?
|
||||||
|
appliedRank Rank? @relation("CrewAppliedRank", fields: [appliedRankId], references: [id])
|
||||||
|
|
||||||
|
actions CrewAction[]
|
||||||
|
}
|
||||||
|
|
|
||||||
122
App/tests/integration/candidates.test.ts
Normal file
122
App/tests/integration/candidates.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the Crewing Phase 3a candidate server actions
|
||||||
|
* (addCandidate / updateCandidate). Mirrors the requisitions test setup.
|
||||||
|
*
|
||||||
|
* The CrewMember table is introduced in this phase, so afterEach wipes it (and
|
||||||
|
* its CrewAction rows) wholesale — no pre-existing rows to preserve.
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
|
||||||
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
let managerId: string;
|
||||||
|
let siteStaffId: string;
|
||||||
|
let rankId: string;
|
||||||
|
|
||||||
|
const SS_EMAIL = "sitestaff@itcand.local";
|
||||||
|
|
||||||
|
const as = (userId: string, role: Role) =>
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||||
|
const ss = await db.user.upsert({
|
||||||
|
where: { email: SS_EMAIL },
|
||||||
|
update: { role: "SITE_STAFF", isActive: true },
|
||||||
|
create: { employeeId: "ITCAND-SS", email: SS_EMAIL, name: "Site Staff Cand", role: "SITE_STAFF" },
|
||||||
|
});
|
||||||
|
siteStaffId = ss.id;
|
||||||
|
rankId = (await db.rank.findFirstOrThrow()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.crewAction.deleteMany({ where: { crewMemberId: { not: null } } });
|
||||||
|
await db.crewMember.deleteMany({});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addCandidate", () => {
|
||||||
|
it("adds a NEW candidate with an audit action and sensible defaults", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const res = await addCandidate(fd({ name: "Asha Rao", source: "CAREERS", appliedRankId: rankId, experienceMonths: "60" }));
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const c = await db.crewMember.findFirstOrThrow({ include: { actions: true } });
|
||||||
|
expect(c.name).toBe("Asha Rao");
|
||||||
|
expect(c.type).toBe("NEW");
|
||||||
|
expect(c.status).toBe("CANDIDATE");
|
||||||
|
expect(c.appliedRankId).toBe(rankId);
|
||||||
|
expect(c.experienceMonths).toBe(60);
|
||||||
|
expect(c.employeeId).toBeNull();
|
||||||
|
expect(c.actions[0].actionType).toBe("CANDIDATE_ADDED");
|
||||||
|
expect(c.actions[0].actorId).toBe(managerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
||||||
|
const c = await db.crewMember.findFirstOrThrow();
|
||||||
|
expect(c.type).toBe("EX_HAND");
|
||||||
|
expect(c.status).toBe("EX_HAND");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a name", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const res = await addCandidate(fd({ name: " ", source: "CAREERS" }));
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
expect(await db.crewMember.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected for roles without manage_candidates (site staff, accounts)", async () => {
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
|
||||||
|
as(managerId, "ACCOUNTS");
|
||||||
|
expect(await addCandidate(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
|
||||||
|
expect(await db.crewMember.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCandidate", () => {
|
||||||
|
it("edits fields and writes a CANDIDATE_UPDATED action", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Edit Me", source: "CAREERS", experienceMonths: "12" }));
|
||||||
|
const c = await db.crewMember.findFirstOrThrow();
|
||||||
|
|
||||||
|
const res = await updateCandidate(fd({ id: c.id, name: "Edited Name", source: "REFERRAL", experienceMonths: "24" }));
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id }, include: { actions: true } });
|
||||||
|
expect(after.name).toBe("Edited Name");
|
||||||
|
expect(after.source).toBe("REFERRAL");
|
||||||
|
expect(after.experienceMonths).toBe(24);
|
||||||
|
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not downgrade an onboarded EMPLOYEE back to a candidate", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await addCandidate(fd({ name: "Hired Hannah", source: "CAREERS" }));
|
||||||
|
const c = await db.crewMember.findFirstOrThrow();
|
||||||
|
await db.crewMember.update({ where: { id: c.id }, data: { status: "EMPLOYEE" } });
|
||||||
|
|
||||||
|
await updateCandidate(fd({ id: c.id, name: "Hired Hannah", source: "CAREERS" }));
|
||||||
|
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown id", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const res = await updateCandidate(fd({ id: "nonexistent", name: "X", source: "CAREERS" }));
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
246
App/tests/integration/requisitions.test.ts
Normal file
246
App/tests/integration/requisitions.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the Crewing Phase 2 requisition + relief server actions:
|
||||||
|
* raise / cancel / transition, relief request + convert, and the shared
|
||||||
|
* autoRaiseRequisition helper. Mirrors the admin-ranks test setup.
|
||||||
|
*
|
||||||
|
* The Requisition/ReliefRequest/CrewAction tables are introduced in this phase,
|
||||||
|
* so afterEach wipes them wholesale (no pre-existing rows to preserve).
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||||
|
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
raiseRequisition,
|
||||||
|
cancelRequisition,
|
||||||
|
transitionRequisition,
|
||||||
|
requestReliefCover,
|
||||||
|
convertReliefToRequisition,
|
||||||
|
} from "@/app/(portal)/crewing/requisitions/actions";
|
||||||
|
import { autoRaiseRequisition } from "@/lib/requisition-service";
|
||||||
|
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
let managerId: string;
|
||||||
|
let manningId: string;
|
||||||
|
let siteStaffId: string;
|
||||||
|
let rankId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
|
||||||
|
const SS_EMAIL = "sitestaff@itreq.local";
|
||||||
|
|
||||||
|
const as = (userId: string, role: Role) =>
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||||
|
manningId = (await getSeedUser("manning@pelagia.local")).id;
|
||||||
|
const ss = await db.user.upsert({
|
||||||
|
where: { email: SS_EMAIL },
|
||||||
|
update: { role: "SITE_STAFF", isActive: true },
|
||||||
|
create: { employeeId: "ITREQ-SS", email: SS_EMAIL, name: "Site Staff Test", role: "SITE_STAFF" },
|
||||||
|
});
|
||||||
|
siteStaffId = ss.id;
|
||||||
|
rankId = (await db.rank.findFirstOrThrow()).id;
|
||||||
|
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.crewAction.deleteMany({});
|
||||||
|
await db.reliefRequest.deleteMany({});
|
||||||
|
await db.requisition.deleteMany({});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("raiseRequisition", () => {
|
||||||
|
it("creates an OPEN requisition with a REQ- code and an audit action", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const res = await raiseRequisition(fd({ rankId, vesselId, reason: "NEW_VACANCY", notes: "Urgent" }));
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const req = await db.requisition.findFirstOrThrow({ include: { actions: true } });
|
||||||
|
expect(req.status).toBe("OPEN");
|
||||||
|
expect(req.code).toMatch(/^REQ-\d+$/);
|
||||||
|
expect(req.autoRaised).toBe(false);
|
||||||
|
expect(req.raisedById).toBe(managerId);
|
||||||
|
expect(req.actions).toHaveLength(1);
|
||||||
|
expect(req.actions[0].actionType).toBe("REQUISITION_RAISED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a vessel or site", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const res = await raiseRequisition(fd({ rankId, reason: "NEW_VACANCY" }));
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
expect(await db.requisition.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected for a role without raise_requisition (site staff)", async () => {
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
const res = await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
expect(res).toEqual({ error: "Unauthorized" });
|
||||||
|
expect(await db.requisition.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancelRequisition", () => {
|
||||||
|
it("a Manager withdraws an OPEN requisition with a reason", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
|
||||||
|
const res = await cancelRequisition(req.id, "Vacancy no longer needed");
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id } });
|
||||||
|
expect(after.status).toBe("CANCELLED");
|
||||||
|
expect(after.cancellationReason).toBe("Vacancy no longer needed");
|
||||||
|
expect(after.cancelledAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a reason", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
const res = await cancelRequisition(req.id, " ");
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot withdraw once past shortlisting", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } });
|
||||||
|
|
||||||
|
const res = await cancelRequisition(req.id, "too late");
|
||||||
|
expect("error" in res).toBe(true);
|
||||||
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("INTERVIEWING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the MPO may also withdraw (holds cancel_requisition per §6)", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
const res = await cancelRequisition(req.id, "sourced elsewhere");
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("CANCELLED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected for a role without cancel_requisition (site staff)", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
const res = await cancelRequisition(req.id, "nope");
|
||||||
|
expect(res).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("transitionRequisition", () => {
|
||||||
|
it("Manager selects from INTERVIEWING; MPO cannot", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
await db.requisition.update({ where: { id: req.id }, data: { status: "INTERVIEWING" } });
|
||||||
|
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
expect(await transitionRequisition(req.id, "mark_selected")).toEqual({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
const ok = await transitionRequisition(req.id, "mark_selected");
|
||||||
|
expect("ok" in ok && ok.ok).toBe(true);
|
||||||
|
expect((await db.requisition.findUniqueOrThrow({ where: { id: req.id } })).status).toBe("SELECTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks FILLED and stamps filledAt", async () => {
|
||||||
|
as(managerId, "MANAGER");
|
||||||
|
await raiseRequisition(fd({ rankId, vesselId }));
|
||||||
|
const req = await db.requisition.findFirstOrThrow();
|
||||||
|
await db.requisition.update({ where: { id: req.id }, data: { status: "SELECTED" } });
|
||||||
|
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
const res = await transitionRequisition(req.id, "mark_filled");
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
const after = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } });
|
||||||
|
expect(after.status).toBe("FILLED");
|
||||||
|
expect(after.filledAt).not.toBeNull();
|
||||||
|
expect(after.actions.some((a) => a.actionType === "REQUISITION_FILLED")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("relief requests", () => {
|
||||||
|
it("site staff raise an OPEN relief request with an audit action", async () => {
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
const res = await requestReliefCover(fd({ rankId, vesselId, note: "Chief going on leave" }));
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const relief = await db.reliefRequest.findFirstOrThrow();
|
||||||
|
expect(relief.status).toBe("OPEN");
|
||||||
|
expect(relief.requestedById).toBe(siteStaffId);
|
||||||
|
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "RELIEF_REQUESTED" } });
|
||||||
|
expect((action.metadata as { reliefRequestId: string }).reliefRequestId).toBe(relief.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is rejected for the MPO (no request_relief_cover)", async () => {
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
const res = await requestReliefCover(fd({ rankId, vesselId }));
|
||||||
|
expect(res).toEqual({ error: "Unauthorized" });
|
||||||
|
expect(await db.reliefRequest.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MPO converts a relief request into a requisition and links them", async () => {
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
await requestReliefCover(fd({ rankId, vesselId, note: "cover" }));
|
||||||
|
const relief = await db.reliefRequest.findFirstOrThrow();
|
||||||
|
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
const res = await convertReliefToRequisition(fd({ reliefRequestId: relief.id, reason: "REPLACEMENT" }));
|
||||||
|
expect("ok" in res && res.ok).toBe(true);
|
||||||
|
|
||||||
|
const after = await db.reliefRequest.findUniqueOrThrow({ where: { id: relief.id } });
|
||||||
|
expect(after.status).toBe("CONVERTED");
|
||||||
|
expect(after.convertedRequisitionId).not.toBeNull();
|
||||||
|
|
||||||
|
const req = await db.requisition.findUniqueOrThrow({
|
||||||
|
where: { id: after.convertedRequisitionId! },
|
||||||
|
include: { actions: true, sourceReliefRequest: true },
|
||||||
|
});
|
||||||
|
expect(req.status).toBe("OPEN");
|
||||||
|
expect(req.reason).toBe("REPLACEMENT");
|
||||||
|
expect(req.sourceReliefRequest?.id).toBe(relief.id);
|
||||||
|
expect(req.actions.some((a) => a.actionType === "RELIEF_CONVERTED")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to convert an already-handled relief request", async () => {
|
||||||
|
as(siteStaffId, "SITE_STAFF");
|
||||||
|
await requestReliefCover(fd({ rankId, vesselId }));
|
||||||
|
const relief = await db.reliefRequest.findFirstOrThrow();
|
||||||
|
|
||||||
|
as(manningId, "MANNING");
|
||||||
|
await convertReliefToRequisition(fd({ reliefRequestId: relief.id }));
|
||||||
|
const second = await convertReliefToRequisition(fd({ reliefRequestId: relief.id }));
|
||||||
|
expect("error" in second).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("autoRaiseRequisition (shared helper)", () => {
|
||||||
|
it("creates an autoRaised OPEN requisition with no human actor", async () => {
|
||||||
|
const req = await autoRaiseRequisition({ rankId, vesselId, reason: "LEAVE" });
|
||||||
|
const stored = await db.requisition.findUniqueOrThrow({ where: { id: req.id }, include: { actions: true } });
|
||||||
|
expect(stored.autoRaised).toBe(true);
|
||||||
|
expect(stored.raisedById).toBeNull();
|
||||||
|
expect(stored.reason).toBe("LEAVE");
|
||||||
|
expect(stored.status).toBe("OPEN");
|
||||||
|
expect(stored.actions[0].actionType).toBe("REQUISITION_RAISED");
|
||||||
|
expect(stored.actions[0].actorId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
App/tests/unit/requisition-state-machine.test.ts
Normal file
78
App/tests/unit/requisition-state-machine.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
canCancel,
|
||||||
|
canPerformAction,
|
||||||
|
getAvailableActions,
|
||||||
|
getTransition,
|
||||||
|
} from "@/lib/requisition-state-machine";
|
||||||
|
|
||||||
|
// The requisition lifecycle (Crewing-Implementation-Spec §5.2):
|
||||||
|
// OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
|
||||||
|
// CANCELLED reachable from OPEN/SHORTLISTING (Manager). Selection is Manager-only.
|
||||||
|
describe("Requisition state machine", () => {
|
||||||
|
describe("forward transitions", () => {
|
||||||
|
it("MPO can start shortlisting an OPEN requisition", () => {
|
||||||
|
expect(canPerformAction("OPEN", "start_shortlisting", "MANNING")).toBe(true);
|
||||||
|
expect(getTransition("OPEN", "start_shortlisting")?.to).toBe("SHORTLISTING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("MPO advances through proposing and interviewing", () => {
|
||||||
|
expect(canPerformAction("SHORTLISTING", "mark_proposing", "MANNING")).toBe(true);
|
||||||
|
expect(canPerformAction("PROPOSING", "start_interviewing", "MANNING")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("final selection is Manager-only (spec §6)", () => {
|
||||||
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANAGER")).toBe(true);
|
||||||
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "SUPERUSER")).toBe(true);
|
||||||
|
expect(canPerformAction("INTERVIEWING", "mark_selected", "MANNING")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onboarding fills the vacancy from SELECTED", () => {
|
||||||
|
expect(getTransition("SELECTED", "mark_filled")?.to).toBe("FILLED");
|
||||||
|
expect(canPerformAction("SELECTED", "mark_filled", "MANNING")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects actions on the wrong source state", () => {
|
||||||
|
expect(canPerformAction("OPEN", "mark_selected", "MANAGER")).toBe(false);
|
||||||
|
expect(getTransition("FILLED", "mark_filled")).toBeNull();
|
||||||
|
expect(getTransition("CANCELLED", "start_shortlisting")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("site staff and accounts can perform no transitions", () => {
|
||||||
|
for (const status of ["OPEN", "SHORTLISTING", "INTERVIEWING", "SELECTED"] as const) {
|
||||||
|
expect(getAvailableActions(status, "SITE_STAFF")).toHaveLength(0);
|
||||||
|
expect(getAvailableActions(status, "ACCOUNTS")).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAvailableActions", () => {
|
||||||
|
it("offers shortlisting on OPEN to the MPO", () => {
|
||||||
|
expect(getAvailableActions("OPEN", "MANNING")).toEqual(["start_shortlisting"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers nothing once FILLED", () => {
|
||||||
|
expect(getAvailableActions("FILLED", "MANAGER")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cancellation (orthogonal)", () => {
|
||||||
|
it("MPO and Manager can withdraw from OPEN or SHORTLISTING (matrix §6)", () => {
|
||||||
|
expect(canCancel("OPEN", "MANAGER")).toBe(true);
|
||||||
|
expect(canCancel("SHORTLISTING", "SUPERUSER")).toBe(true);
|
||||||
|
expect(canCancel("OPEN", "MANNING")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot be withdrawn once past shortlisting", () => {
|
||||||
|
expect(canCancel("PROPOSING", "MANAGER")).toBe(false);
|
||||||
|
expect(canCancel("INTERVIEWING", "MANAGER")).toBe(false);
|
||||||
|
expect(canCancel("FILLED", "MANAGER")).toBe(false);
|
||||||
|
expect(canCancel("CANCELLED", "MANAGER")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("site staff and accounts may never withdraw", () => {
|
||||||
|
expect(canCancel("OPEN", "SITE_STAFF")).toBe(false);
|
||||||
|
expect(canCancel("OPEN", "ACCOUNTS")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue