Merge pull request 'feat(crewing): land full Crewing module on master (Phases 1–5 + hardening)' (#93) from feat/crewing-review-hardening into master
Some checks failed
Refresh staging / refresh (push) Failing after 7s
Some checks failed
Refresh staging / refresh (push) Failing after 7s
Reviewed-on: #93
This commit is contained in:
commit
f7e38fc60c
98 changed files with 11548 additions and 82 deletions
|
|
@ -49,6 +49,13 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
|||
# Start the service with: cd GstService && npm run dev
|
||||
GST_SERVICE_URL=http://localhost:3003
|
||||
|
||||
# ── EPFO / UAN lookup microservice (crewing) ──────────────────
|
||||
# Run the EpfoService/ microservice alongside the app (default localhost:3004).
|
||||
# Start with: cd EpfoService && npm run dev
|
||||
# Runs in STUB mode unless EPFO_LIVE=true (the live portal selectors/OTP must be
|
||||
# validated against a real session first). Aadhaar is NOT handled here (manual).
|
||||
EPFO_SERVICE_URL=http://localhost:3004
|
||||
|
||||
# ── Forgejo issue reporting (Report Issue button) ─────────────
|
||||
# Token needs write:issue scope on the repo below.
|
||||
FORGEJO_URL=https://git.pelagiamarine.com
|
||||
|
|
|
|||
|
|
@ -120,13 +120,92 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
|
|||
|
||||
### 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`.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):**
|
||||
|
||||
- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`.
|
||||
- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns.
|
||||
- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`).
|
||||
- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail.
|
||||
- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue.
|
||||
|
||||
**Phase 3c — Onboarding (Epic D; spec §8.5/§9/§11):**
|
||||
|
||||
- **Models:** `CrewAssignment` (a tour of duty, `AssignmentStatus` ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) and `ContractLetter` (`salaryRestricted`). `SalaryStructure` gained `assignmentId` (bound at onboarding). `CrewActionType += CREW_ONBOARDED`. Employee numbers `CRW-xxxx` via `lib/employee-number.ts`.
|
||||
- **Action** (`onboardCandidate`, `onboard_crew`): one transaction off a `SELECTED` application — assign `employeeId`, create `CrewAssignment(ACTIVE, signOnDate)`, bind the approved `SalaryStructure` (`assignmentId` + `effectiveFrom`), `Application → ONBOARDED`, `Requisition → FILLED`, `CrewMember → EMPLOYEE` (+ `currentRank`); contract letter stored after. Onboarded crew leave the Candidates pool (the Crew directory is Phase 4).
|
||||
- **Screen:** the SELECTED action card's **Onboard to crew** modal (joining date, contract upload, starts-automatically chips); the assigned `CRW-` number shows on the ONBOARDED card.
|
||||
- **Deferred:** SITE_STAFF **login creation** for management ranks (grantsLogin) is a follow-up; attendance/experience/PPE records (the "starts automatically" chips) begin in Phase 4.
|
||||
|
||||
**Phase 4a — Crew records & profile + PPE (Epics E + F; spec §8.7–8.8):** Phase 4 (crew records, PPE, leave/attendance + sign-off) ships as **stacked sub-PRs** — 4a records/profile/PPE, 4b leave/attendance, 4c sign-off/experience.
|
||||
|
||||
- **Models:** `SeafarerDocument`, `NextOfKin` (`isEmergency`), `ExperienceRecord`, `PpeIssue` (`PpeItem` enum) — all on `CrewMember`. `CrewActionType += DOCUMENT_UPLOADED / RECORD_UPDATED / PPE_ISSUED / PPE_RETURNED / EXPERIENCE_ADDED`. (`BankDetail`/`EpfDetail` already exist from 3b.)
|
||||
- **PII masking** (`lib/crew-pii.ts`, spec §6/§8.8): bank account number + Aadhaar are full only for **Accounts/SuperUser**, masked (`•••• 1234`) otherwise; salary hidden from **site staff**. Masking is applied **server-side** before data crosses to the client.
|
||||
- **Actions** (`app/(portal)/crewing/crew/actions.ts`): `uploadDocument`/`deleteDocument`, `saveBankEpf`, `addNextOfKin`/`deleteNextOfKin`, `issuePpe`/`returnPpe`, `addExperience` — guarded by `upload_crew_records` / `issue_ppe`, each writes a `CrewAction`. Document/contract files via `buildStorageKey("crew-document", …)`.
|
||||
- **Screens:** `/crewing/crew` (directory — active `EMPLOYEE` crew, search + vessel filter; ex-hands excluded) and `/crewing/crew/[id]` (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). **Crew** added to the flag-gated nav (MGR/MPO/Site/Accounts).
|
||||
- **Deferred:** site-staff **own-site scoping** (needs a User↔Site link, not modelled — all crew show for now); the records **verify queue** (§8.11, Phase 5); the Pay-status tab shows the salary structure only until wage reports (Phase 6).
|
||||
|
||||
**Phase 4b — Leave & attendance (Epic G; spec §5.3/§8.9–8.10):**
|
||||
|
||||
- **Models:** `LeaveRequest` (`LeaveType`, `LeaveStatus`) and `Attendance` (`AttendanceStatus`, `@@unique([assignmentId, date])`) hang off `CrewAssignment`. `CrewActionType += LEAVE_APPLIED / LEAVE_DECIDED / ATTENDANCE_RECORDED`.
|
||||
- **Leave (R1):** **Site staff apply on behalf** (`apply_leave`); the **Manager decides** (`decide_leave`) — the **MPO has no leave role**. On approval the assignment goes `ON_LEAVE`. Leave approvals also surface in the central `/approvals` queue (§8.13 "Leave" kind, inline Approve/Decline). Notification `LEAVE_FOR_APPROVAL`.
|
||||
- **Clash auto-backfill (R6, Option A):** `VesselRankRequirement{vesselId, rankId, minStrength}` configures required crew strength per rank per vessel. `lib/leave-clash.ts` flags a clash when approving a leave would drop the **active same-rank cover over the window below `minStrength`** (default **1** when unconfigured) → auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. The requirement is managed by the office (`manage_crew`).
|
||||
- **Attendance (R5):** daily month calendar, **site staff record** (`record_attendance`), **Manager views** (`view_attendance`) but cannot edit, **MPO has neither**. `saveAttendance(assignmentId, marks)` bulk-upserts the dirty cells.
|
||||
- **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only).
|
||||
- **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred.
|
||||
|
||||
**Crewing admin (office/admin management):** a new `manage_crew` permission (Manager + SuperUser + Admin) gates a small Administration surface:
|
||||
- **Crew management** (`/admin/crew`): full CRUD over `CrewMember` (any status), and **direct placement** — `placeCrew` assigns a crew member to a vessel/site **without a requisition** (creates an `ACTIVE` `CrewAssignment`; promotes a candidate to `EMPLOYEE` with a `CRW-` number; blocked if they already have an active assignment).
|
||||
- **Crew strength** (`/admin/crew-strength`): CRUD over `VesselRankRequirement` (the `minStrength` that drives R6 leave-clash detection).
|
||||
- Both links sit under **Administration** (flag-gated, Manager/Admin/SuperUser).
|
||||
|
||||
**Phase 4c — Sign-off & experience (Epic K; spec §5.3):** completes Phase 4 (and the Epic K piece deferred from Phase 2).
|
||||
|
||||
- **`signOffCrew(assignmentId, date, remarks)`** (`crewing/crew/actions.ts`, `sign_off_crew`): one transaction — assignment → `SIGNED_OFF` (+ `signOffDate`), append an internal `ExperienceRecord` (rank, on/off dates, computed `durationMonths`), flip the **same `CrewMember`** `EMPLOYEE → EX_HAND` (so they return to the Candidates pool as a returning hand), `CrewAction CREW_SIGNED_OFF`; then auto-raise a `SIGN_OFF` backfill requisition via `autoRaiseRequisition`. (`CrewActionType += CREW_SIGNED_OFF`.)
|
||||
- **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`).
|
||||
- This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications).
|
||||
|
||||
**Phase 5a — Verification (Epic I; spec §8.11/R11):** the office queue for site-entered records (Phase 5 ships as 5a verification → 5b appraisal).
|
||||
|
||||
- **Actions** (`crewing/verification/actions.ts`): `verifyDocument(id, approve, remarks)` (`verify_site_records` — MPO/Manager) sets a `SeafarerDocument`'s `verificationStatus` + `verifiedById`; `verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)` (`verify_bank_epf` — Accounts) does the same for `BankDetail`/`EpfDetail`. Rejection requires remarks; both write a `CrewAction` (`RECORD_VERIFIED`/`RECORD_REJECTED`). No new models — the verification fields already existed (3b/4a).
|
||||
- **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7).
|
||||
- **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models).
|
||||
|
||||
**Phase 5b — Appraisal (Epic H; spec §5.4/§8.14):** completes Phase 5.
|
||||
|
||||
- **Model:** `Appraisal` (on `CrewAssignment`) + `AppraisalStatus` (`DRAFT → SUBMITTED → MPO_VERIFIED → MANAGER_APPROVED`; `→ REJECTED`). `ratings` is a small JSON (competence/conduct/safety). `CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED`.
|
||||
- **State machine** `lib/appraisal-state-machine.ts`: `verify` (SUBMITTED→MPO_VERIFIED, MPO/Manager) and `approve` (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject.
|
||||
- **Actions** (`crewing/appraisals/actions.ts`): `raiseAppraisal` (`raise_appraisal` — PM/site staff; creates `SUBMITTED`), `verifyAppraisal` (`verify_appraisal` — MPO), `approveAppraisal` (`approve_appraisal` — Manager); reject paths require remarks; notifications `APPRAISAL_FOR_VERIFICATION` / `APPRAISAL_FOR_APPROVAL`.
|
||||
- **Three surfaces** (§8.14): the PM raises + sees status on the crew profile **Appraisals** tab; the MPO verifies in the **Verification** queue (Appraisals section); the Manager approves in the central **/approvals** queue (Appraisal kind).
|
||||
- This completes **Phase 5** (I + H). Remaining roadmap: **Phase 6** — payroll (Pay-status tab + Approvals "Wage"), dashboards, notifications (J, M).
|
||||
|
||||
**Crewing follow-ups (resolved deferrals):** the self-contained deferrals from earlier phases are now done:
|
||||
- **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site.
|
||||
- **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above.
|
||||
- **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`.
|
||||
- **EPFO / UAN assisted verification (A3):** `EpfoService/` is a standalone Express + Playwright proxy (the **GstService pattern**) that does an OTP-handshake UAN lookup against the EPFO member portal — `POST /otp` then `POST /verify`. The app proxies via `/api/epfo/otp` + `/api/epfo` (gated by `verify_bank_epf`), and the **EPFO check** affordance in the verification queue records the returned member name onto `EpfDetail.epfoMemberName` (`recordEpfoCheck`). The live portal navigation is **stubbed behind `EPFO_LIVE`** (deterministic in dev/CI: OTP `000000` → matched) until the real selectors/OTP are validated. **Aadhaar is intentionally not handled** (UIDAI-restricted — stays assisted-manual; only `aadhaarLast4` stored, masked).
|
||||
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
|
||||
|
||||
### GST Calculation
|
||||
|
||||
|
|
@ -151,6 +230,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
|
|||
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
||||
|
||||
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
||||
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
|
||||
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
|
||||
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
|
||||
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||
|
|
|
|||
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal file
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
const PATH = "/admin/crew-strength";
|
||||
|
||||
async function guard(): Promise<{ error: string } | { ok: true }> {
|
||||
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
vesselId: z.string().min(1, "Vessel is required"),
|
||||
rankId: z.string().min(1, "Rank is required"),
|
||||
minStrength: z.coerce.number().int().min(0, "Strength must be 0 or more").max(999),
|
||||
});
|
||||
|
||||
// Per-vessel, per-rank required strength (drives leave-clash detection, R6).
|
||||
export async function upsertRequirement(formData: FormData): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if ("error" in denied) return denied;
|
||||
|
||||
const parsed = schema.safeParse({
|
||||
vesselId: formData.get("vesselId"),
|
||||
rankId: formData.get("rankId"),
|
||||
minStrength: formData.get("minStrength"),
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
|
||||
await db.vesselRankRequirement.upsert({
|
||||
where: { vesselId_rankId: { vesselId: d.vesselId, rankId: d.rankId } },
|
||||
update: { minStrength: d.minStrength },
|
||||
create: { vesselId: d.vesselId, rankId: d.rankId, minStrength: d.minStrength },
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteRequirement(id: string): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if ("error" in denied) return denied;
|
||||
await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {});
|
||||
revalidatePath(PATH);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { upsertRequirement, deleteRequirement } from "./actions";
|
||||
|
||||
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 BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||
|
||||
type Opt = { id: string; name: string };
|
||||
type RankOpt = { id: string; code: string; name: string };
|
||||
type Req = { id: string; vessel: string; rank: string; minStrength: number };
|
||||
|
||||
export function CrewStrengthManager({ requirements, vessels, ranks }: { requirements: Req[]; vessels: Opt[]; ranks: RankOpt[] }) {
|
||||
const router = useRouter();
|
||||
const [f, setF] = useState({ vesselId: "", rankId: "", minStrength: "1" });
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
fd.set("vesselId", f.vesselId); fd.set("rankId", f.rankId); fd.set("minStrength", f.minStrength);
|
||||
const res = await upsertRequirement(fd);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setF({ vesselId: "", rankId: "", minStrength: "1" }); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Crew strength</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Required crew per rank, per vessel. Drives the leave-clash backfill — a leave that drops cover below the required strength auto-raises a requisition.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel</label>
|
||||
<select className={INPUT} value={f.vesselId} onChange={(e) => setF({ ...f, vesselId: e.target.value })} required><option value="">— Vessel —</option>{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank</label>
|
||||
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value="">— 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">Min strength</label>
|
||||
<input className={`${INPUT} w-28`} type="number" min={0} value={f.minStrength} onChange={(e) => setF({ ...f, minStrength: e.target.value })} required />
|
||||
</div>
|
||||
<button className={BTN} disabled={pending || !f.vesselId || !f.rankId}>{pending ? "Saving…" : "Set requirement"}</button>
|
||||
{error && <p className="w-full text-sm text-danger-700">{error}</p>}
|
||||
</form>
|
||||
|
||||
<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</th>
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Min strength</th>
|
||||
<th className="px-4 py-3 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requirements.length === 0 ? (
|
||||
<tr><td colSpan={4} className="px-4 py-12 text-center text-neutral-400">No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.</td></tr>
|
||||
) : requirements.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-800">{r.vessel}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{r.rank}</td>
|
||||
<td className="px-4 py-3 font-semibold text-neutral-900">{r.minStrength}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button className="text-xs font-medium text-danger-600 hover:underline" onClick={async () => { await deleteRequirement(r.id); router.refresh(); }}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal file
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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 { CrewStrengthManager } from "./crew-strength-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Crew strength" };
|
||||
|
||||
export default async function CrewStrengthPage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
|
||||
|
||||
const [requirements, vessels, ranks] = await Promise.all([
|
||||
db.vesselRankRequirement.findMany({
|
||||
orderBy: [{ vessel: { name: "asc" } }, { rank: { name: "asc" } }],
|
||||
include: { vessel: { select: { name: true } }, rank: { select: { name: true } } },
|
||||
}),
|
||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<CrewStrengthManager
|
||||
requirements={requirements.map((r) => ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))}
|
||||
vessels={vessels}
|
||||
ranks={ranks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
167
App/app/(portal)/admin/crew/actions.ts
Normal file
167
App/app/(portal)/admin/crew/actions.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { generateEmployeeId } from "@/lib/employee-number";
|
||||
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||
const PATH = "/admin/crew";
|
||||
|
||||
async function guard(): Promise<{ error: string } | { userId: string }> {
|
||||
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
|
||||
return { userId: session.user.id };
|
||||
}
|
||||
|
||||
const crewSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
status: z.nativeEnum(CrewStatus).default("CANDIDATE"),
|
||||
type: z.nativeEnum(CandidateType).default("NEW"),
|
||||
source: z.nativeEnum(CandidateSource).default("CAREERS"),
|
||||
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
|
||||
phone: z.string().optional(),
|
||||
appliedRankId: z.string().optional(),
|
||||
currentRankId: z.string().optional(),
|
||||
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
|
||||
});
|
||||
|
||||
function parse(formData: FormData) {
|
||||
return crewSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
status: (formData.get("status") as string) || undefined,
|
||||
type: (formData.get("type") as string) || undefined,
|
||||
source: (formData.get("source") as string) || undefined,
|
||||
email: (formData.get("email") as string) || undefined,
|
||||
phone: (formData.get("phone") as string) || undefined,
|
||||
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
|
||||
currentRankId: (formData.get("currentRankId") as string) || undefined,
|
||||
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createCrewMember(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard();
|
||||
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 crew = await db.crewMember.create({
|
||||
data: {
|
||||
name: d.name, status: d.status, type: d.type, source: d.source,
|
||||
email: d.email || null, phone: d.phone || null,
|
||||
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
||||
experienceMonths: d.experienceMonths,
|
||||
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
|
||||
},
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return { ok: true, id: crew.id };
|
||||
}
|
||||
|
||||
export async function updateCrewMember(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
const id = formData.get("id") as string;
|
||||
if (!id) return { error: "Crew ID is required" };
|
||||
const parsed = parse(formData);
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await db.crewMember.findUnique({ where: { id }, select: { id: true } }))) return { error: "Crew member not found" };
|
||||
|
||||
await db.crewMember.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: d.name, status: d.status, type: d.type, source: d.source,
|
||||
email: d.email || null, phone: d.phone || null,
|
||||
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
||||
experienceMonths: d.experienceMonths,
|
||||
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
|
||||
},
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteCrewMember(id: string): Promise<ActionResult> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
const crew = await db.crewMember.findUnique({
|
||||
where: { id },
|
||||
select: { _count: { select: { assignments: true, applications: true } } },
|
||||
});
|
||||
if (!crew) return { error: "Crew member not found" };
|
||||
if (crew._count.assignments > 0 || crew._count.applications > 0) {
|
||||
return { error: "Cannot delete: this crew member has assignments or applications. Remove those first." };
|
||||
}
|
||||
await db.crewAction.deleteMany({ where: { crewMemberId: id } });
|
||||
await db.crewMember.delete({ where: { id } });
|
||||
revalidatePath(PATH);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Direct placement (Manager) — assign crew to a vessel/site, no requisition ──
|
||||
|
||||
const placeSchema = z
|
||||
.object({
|
||||
crewMemberId: z.string().min(1, "Crew member is required"),
|
||||
rankId: z.string().min(1, "Rank is required"),
|
||||
vesselId: z.string().optional(),
|
||||
siteId: z.string().optional(),
|
||||
signOnDate: z.string().min(1, "Joining date is required"),
|
||||
})
|
||||
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required" });
|
||||
|
||||
export async function placeCrew(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = placeSchema.safeParse({
|
||||
crewMemberId: formData.get("crewMemberId"),
|
||||
rankId: formData.get("rankId"),
|
||||
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||
siteId: (formData.get("siteId") as string) || undefined,
|
||||
signOnDate: formData.get("signOnDate"),
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
|
||||
const crew = await db.crewMember.findUnique({
|
||||
where: { id: d.crewMemberId },
|
||||
include: { assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true } } },
|
||||
});
|
||||
if (!crew) return { error: "Crew member not found" };
|
||||
if (crew.assignments.length > 0) return { error: "This crew member already has an active assignment" };
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.crewAssignment.create({
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
signOnDate: new Date(d.signOnDate),
|
||||
crewMemberId: crew.id,
|
||||
rankId: d.rankId,
|
||||
vesselId: d.vesselId || null,
|
||||
siteId: d.siteId || null,
|
||||
},
|
||||
});
|
||||
// Promote a candidate/ex-hand to active crew (employee no.) on first placement.
|
||||
const data: { status: "EMPLOYEE"; currentRankId: string; employeeId?: string } = { status: "EMPLOYEE", currentRankId: d.rankId };
|
||||
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
|
||||
await tx.crewMember.update({ where: { id: crew.id }, data });
|
||||
await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } });
|
||||
// Management ranks (grantsLogin) become a SITE_STAFF login on placement.
|
||||
await maybeCreateSiteStaffLogin(tx, { name: crew.name, email: crew.email, employeeId: data.employeeId ?? crew.employeeId }, d.rankId, d.siteId || null);
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath("/crewing/crew");
|
||||
return { ok: true };
|
||||
}
|
||||
201
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal file
201
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } 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";
|
||||
const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50";
|
||||
|
||||
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
||||
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||
const TYPES: CandidateType[] = ["NEW", "EX_HAND"];
|
||||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
|
||||
type Opt = { id: string; name: string };
|
||||
type RankOpt = { id: string; code: string; name: string };
|
||||
type Crew = {
|
||||
id: string; name: string; status: CrewStatus; type: CandidateType; source: CandidateSource;
|
||||
email: string | null; phone: string | null; employeeId: string | null;
|
||||
appliedRankId: string | null; currentRankId: string | null; currentRank: string | null;
|
||||
experienceMonths: number; hasActiveAssignment: boolean; removable: boolean;
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<CrewStatus, "outline" | "default" | "success" | "secondary" | "danger"> = {
|
||||
PROSPECT: "outline", CANDIDATE: "default", EMPLOYEE: "success", EX_HAND: "secondary", BLACKLISTED: "danger",
|
||||
};
|
||||
|
||||
export function AdminCrewManager({ crew, ranks, vessels, sites }: { crew: Crew[]; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return crew.filter((c) => !q || `${c.name} ${c.employeeId ?? ""}`.toLowerCase().includes(q));
|
||||
}, [crew, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Crew management</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} crew records · create, edit, place onto a vessel/site, or remove</p>
|
||||
</div>
|
||||
<CrewFormButton ranks={ranks} />
|
||||
</div>
|
||||
|
||||
<input className={`${INPUT} mb-4 max-w-sm`} placeholder="Search name or employee no…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
|
||||
<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">Employee</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">No crew records.</td></tr>
|
||||
) : filtered.map((c) => <Row key={c.id} c={c} ranks={ranks} vessels={vessels} sites={sites} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ c, ranks, vessels, sites }: { c: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [placeOpen, setPlaceOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{c.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{label(c.status)}</Badge></td>
|
||||
<td className="px-4 py-3 text-neutral-700">{c.currentRank ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
{!c.hasActiveAssignment && <RowActionsItem onClick={() => setPlaceOpen(true)}>Place onto vessel/site</RowActionsItem>}
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
<CrewFormButton ranks={ranks} editing={c} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<PlaceDialog crew={c} ranks={ranks} vessels={vessels} sites={sites} open={placeOpen} onOpenChange={setPlaceOpen} />
|
||||
<DeleteConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} label={c.name} onConfirm={() => deleteCrewMember(c.id)} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt[]; editing?: Crew; open?: boolean; onOpenChange?: (v: boolean) => void }) {
|
||||
const router = useRouter();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const isControlled = open !== undefined;
|
||||
const isOpen = isControlled ? open : internalOpen;
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [f, setF] = useState({
|
||||
name: editing?.name ?? "", status: editing?.status ?? "CANDIDATE", type: editing?.type ?? "NEW", source: editing?.source ?? "CAREERS",
|
||||
email: editing?.email ?? "", phone: editing?.phone ?? "", appliedRankId: editing?.appliedRankId ?? "", currentRankId: editing?.currentRankId ?? "",
|
||||
experienceMonths: String(editing?.experienceMonths ?? 0),
|
||||
});
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
if (editing) fd.set("id", editing.id);
|
||||
Object.entries(f).forEach(([k, v]) => v !== "" && fd.set(k, String(v)));
|
||||
const res = await (editing ? updateCrewMember(fd) : createCrewMember(fd));
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isControlled && <button className={BTN} onClick={() => setOpen(true)}>+ Add crew</button>}
|
||||
<AdminDialog title={editing ? "Edit crew member" : "Add crew member"} open={isOpen} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||
<input className={INPUT} type="number" min={0} placeholder="Experience (months)" value={f.experienceMonths} onChange={(e) => setF({ ...f, experienceMonths: e.target.value })} />
|
||||
</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" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !f.name} className={BTN}>{pending ? "Saving…" : editing ? "Save changes" : "Add crew"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceDialog({ crew, ranks, vessels, sites, open, onOpenChange }: { crew: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[]; open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [f, setF] = useState({ rankId: crew.currentRankId ?? crew.appliedRankId ?? "", location: "", signOnDate: "" });
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crew.id);
|
||||
fd.set("rankId", f.rankId);
|
||||
if (f.location.startsWith("v:")) fd.set("vesselId", f.location.slice(2));
|
||||
else if (f.location.startsWith("s:")) fd.set("siteId", f.location.slice(2));
|
||||
fd.set("signOnDate", f.signOnDate);
|
||||
const res = await placeCrew(fd);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { onOpenChange(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminDialog title={`Place ${crew.name}`} open={open} onClose={() => onOpenChange(false)}>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<p className="text-sm text-neutral-600">Assign this crew member directly to a vessel/site — no requisition needed. A candidate is promoted to active crew with an employee number.</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
|
||||
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value="">— 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>
|
||||
<select className={INPUT} value={f.location} onChange={(e) => setF({ ...f, location: e.target.value })} required>
|
||||
<option value="">— Select —</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>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
|
||||
<input type="date" className={INPUT} value={f.signOnDate} onChange={(e) => setF({ ...f, signOnDate: 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" className={SECONDARY} onClick={() => onOpenChange(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !f.rankId || !f.location || !f.signOnDate} className={BTN}>{pending ? "Placing…" : "Place crew"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
56
App/app/(portal)/admin/crew/page.tsx
Normal file
56
App/app/(portal)/admin/crew/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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 { AdminCrewManager } from "./admin-crew-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Crew management" };
|
||||
|
||||
export default async function AdminCrewPage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
|
||||
|
||||
const [crew, ranks, vessels, sites] = await Promise.all([
|
||||
db.crewMember.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
currentRank: { select: { name: true } },
|
||||
appliedRank: { select: { name: true } },
|
||||
assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true }, take: 1 },
|
||||
_count: { select: { assignments: true, applications: 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 } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdminCrewManager
|
||||
crew={crew.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
status: c.status,
|
||||
type: c.type,
|
||||
source: c.source,
|
||||
email: c.email,
|
||||
phone: c.phone,
|
||||
employeeId: c.employeeId,
|
||||
appliedRankId: c.appliedRankId,
|
||||
currentRankId: c.currentRankId,
|
||||
currentRank: c.currentRank?.name ?? null,
|
||||
experienceMonths: c.experienceMonths,
|
||||
hasActiveAssignment: c.assignments.length > 0,
|
||||
removable: c._count.assignments === 0 && c._count.applications === 0,
|
||||
}))}
|
||||
ranks={ranks}
|
||||
vessels={vessels}
|
||||
sites={sites}
|
||||
/>
|
||||
);
|
||||
}
|
||||
120
App/app/(portal)/approvals/crewing-approvals.tsx
Normal file
120
App/app/(portal)/approvals/crewing-approvals.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import {
|
||||
approveSalary,
|
||||
returnSalary,
|
||||
selectCandidate,
|
||||
returnSelection,
|
||||
approveInterviewWaiver,
|
||||
declineInterviewWaiver,
|
||||
} from "../crewing/applications/actions";
|
||||
import { decideLeave } from "../crewing/leave/actions";
|
||||
import { approveAppraisal } from "../crewing/appraisals/actions";
|
||||
|
||||
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE" | "APPRAISAL";
|
||||
|
||||
export type CrewApprovalItem = {
|
||||
id: string; // applicationId, or leaveRequestId for LEAVE
|
||||
kind: CrewApprovalKind;
|
||||
candidateName: string;
|
||||
rank: string;
|
||||
requisitionCode: string;
|
||||
detail: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave", APPRAISAL: "Appraisal" };
|
||||
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning", APPRAISAL: "default" } as const;
|
||||
|
||||
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
|
||||
SALARY: approveSalary,
|
||||
SELECTION: selectCandidate,
|
||||
WAIVER: approveInterviewWaiver,
|
||||
LEAVE: (id) => decideLeave(id, true),
|
||||
APPRAISAL: (id) => approveAppraisal(id, true),
|
||||
};
|
||||
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
|
||||
SALARY: returnSalary,
|
||||
SELECTION: returnSelection,
|
||||
WAIVER: declineInterviewWaiver,
|
||||
LEAVE: (id, reason) => decideLeave(id, false, reason),
|
||||
APPRAISAL: (id, reason) => approveAppraisal(id, false, reason),
|
||||
};
|
||||
|
||||
function Row({ item }: { item: CrewApprovalItem }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [returnOpen, setReturnOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
async function approve() {
|
||||
setPending(true); setError("");
|
||||
const res = await approveFn[item.kind](item.id);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else router.refresh();
|
||||
}
|
||||
async function doReturn(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await returnFn[item.kind](item.id, reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
|
||||
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
|
||||
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
|
||||
<form onSubmit={doReturn} className="space-y-4 text-left">
|
||||
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>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">Return</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
|
||||
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { redirect } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||
import { ApprovalsSearch } from "./approvals-search";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
|
||||
import { Suspense } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -49,6 +51,88 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
|||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
|
||||
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
|
||||
const role = session.user.role;
|
||||
const showCrewing =
|
||||
CREWING_ENABLED &&
|
||||
(hasPermission(role, "approve_salary_structure") ||
|
||||
hasPermission(role, "select_candidate") ||
|
||||
hasPermission(role, "approve_interview_waiver") ||
|
||||
hasPermission(role, "decide_leave") ||
|
||||
hasPermission(role, "approve_appraisal"));
|
||||
|
||||
const crewGates = showCrewing
|
||||
? await db.applicationGate.findMany({
|
||||
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
application: {
|
||||
include: {
|
||||
crewMember: { select: { name: true } },
|
||||
requisition: { select: { code: true, rank: { select: { name: true } } } },
|
||||
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
|
||||
const sal = g.application.salaryStructures[0];
|
||||
const detail =
|
||||
g.gate === "SALARY" && sal
|
||||
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
|
||||
: g.gate === "WAIVER"
|
||||
? "Returning crew — interview waiver"
|
||||
: "Interview cleared";
|
||||
return {
|
||||
id: g.applicationId,
|
||||
kind: g.gate as CrewApprovalKind,
|
||||
candidateName: g.application.crewMember.name,
|
||||
rank: g.application.requisition.rank.name,
|
||||
requisitionCode: g.application.requisition.code,
|
||||
detail,
|
||||
link: `/crewing/applications/${g.applicationId}`,
|
||||
};
|
||||
});
|
||||
|
||||
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
|
||||
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
|
||||
? (await db.leaveRequest.findMany({
|
||||
where: { status: "APPLIED" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||
})).map((l) => ({
|
||||
id: l.id,
|
||||
kind: "LEAVE" as CrewApprovalKind,
|
||||
candidateName: l.assignment.crewMember.name,
|
||||
rank: l.assignment.rank.name,
|
||||
requisitionCode: `${l.fromDate.toLocaleDateString()}–${l.toDate.toLocaleDateString()}`,
|
||||
detail: l.type.toLowerCase(),
|
||||
link: "/crewing/leave",
|
||||
}))
|
||||
: [];
|
||||
|
||||
// MPO-verified appraisals awaiting Manager approval (§8.13/§8.14).
|
||||
const appraisalItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "approve_appraisal"))
|
||||
? (await db.appraisal.findMany({
|
||||
where: { status: "MPO_VERIFIED" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||
})).map((a) => ({
|
||||
id: a.id,
|
||||
kind: "APPRAISAL" as CrewApprovalKind,
|
||||
candidateName: a.assignment.crewMember.name,
|
||||
rank: a.assignment.rank.name,
|
||||
requisitionCode: a.period,
|
||||
detail: "MPO-verified appraisal",
|
||||
link: "/approvals",
|
||||
}))
|
||||
: [];
|
||||
|
||||
const allCrewItems = [...crewItems, ...leaveItems, ...appraisalItems];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
|
|
@ -137,6 +221,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
144
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal file
144
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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, Check } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ApplicationActionCard } from "../application-action-card";
|
||||
import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
|
||||
import { experienceLabel } from "../../candidates/candidate-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Application" };
|
||||
|
||||
export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const app = await db.application.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
||||
crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
|
||||
gates: true,
|
||||
salaryStructures: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
if (!app) notFound();
|
||||
|
||||
const gate = (t: string) => app.gates.find((g) => g.gate === t);
|
||||
const salaryPending = gate("SALARY")?.result === "PENDING";
|
||||
const waiverPending = gate("WAIVER")?.result === "PENDING";
|
||||
const selectionPending = gate("SELECTION")?.result === "PENDING";
|
||||
const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
|
||||
|
||||
const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
|
||||
const curIdx = stageIndex(app.stage);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} 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" /> Pipeline · {app.requisition.code}
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
|
||||
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
|
||||
{app.crewMember.type === "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>
|
||||
<p className="text-sm text-neutral-500 -mt-4 mb-6">
|
||||
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
|
||||
</p>
|
||||
|
||||
{/* 7-step stepper */}
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
{STAGE_ORDER.map((s, i) => {
|
||||
const done = curIdx > i || app.stage === "ONBOARDED";
|
||||
const current = curIdx === i;
|
||||
return (
|
||||
<div key={s} className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
|
||||
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
|
||||
)}>
|
||||
{done && <Check className="h-3 w-3" />}
|
||||
{STAGE_LABEL[s]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Adaptive action card */}
|
||||
<ApplicationActionCard
|
||||
id={app.id}
|
||||
stage={app.stage}
|
||||
isExHand={app.crewMember.type === "EX_HAND"}
|
||||
interviewResult={app.interviewResult}
|
||||
interviewWaived={app.interviewWaived}
|
||||
rejectedReason={app.rejectedReason}
|
||||
salaryPending={salaryPending}
|
||||
waiverPending={waiverPending}
|
||||
selectionPending={selectionPending}
|
||||
employeeNo={app.crewMember.employeeId}
|
||||
salary={proposed ? {
|
||||
rateBasis: proposed.rateBasis,
|
||||
basic: Number(proposed.basic),
|
||||
victualingPerDay: Number(proposed.victualingPerDay),
|
||||
currency: proposed.currency,
|
||||
approved: Boolean(proposed.approvedById),
|
||||
} : null}
|
||||
perms={{
|
||||
manage: hasPermission(role, "manage_candidates"),
|
||||
recordReference: hasPermission(role, "record_reference_check"),
|
||||
recordInterview: hasPermission(role, "record_interview_result"),
|
||||
requestWaiver: hasPermission(role, "request_interview_waiver"),
|
||||
approveSalary: hasPermission(role, "approve_salary_structure"),
|
||||
approveWaiver: hasPermission(role, "approve_interview_waiver"),
|
||||
select: hasPermission(role, "select_candidate"),
|
||||
onboard: hasPermission(role, "onboard_crew"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Profile */}
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
|
||||
<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">
|
||||
{([
|
||||
["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
|
||||
["Last rank held", app.crewMember.currentRank?.name ?? "—"],
|
||||
["Experience", experienceLabel(app.crewMember.experienceMonths)],
|
||||
["Source", app.crewMember.source],
|
||||
] as [string, string][]).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>
|
||||
{app.crewMember.type === "EX_HAND" && (
|
||||
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
|
||||
Returning crew — prior docs/bank/tour on file; interview may be waived with Manager approval.
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-3 border-t border-neutral-100">
|
||||
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
|
||||
View full candidate profile →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
680
App/app/(portal)/crewing/applications/actions.ts
Normal file
680
App/app/(portal)/crewing/applications/actions.ts
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
"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 {
|
||||
canPerformAction,
|
||||
canReject,
|
||||
getTransition,
|
||||
type ApplicationAction,
|
||||
} from "@/lib/application-pipeline";
|
||||
import { getManagerRecipients } from "@/lib/requisition-service";
|
||||
import { generateEmployeeId } from "@/lib/employee-number";
|
||||
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||
import { notifyCrew } from "@/lib/notifier";
|
||||
import { SalaryRateBasis } 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 appPath = (id: string) => `/crewing/applications/${id}`;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// Load an application with the bits the actions need; null if missing.
|
||||
async function loadApp(id: string) {
|
||||
return db.application.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
|
||||
crewMember: { select: { id: true, name: true, type: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function revalidateApp(applicationId: string, requisitionId: string) {
|
||||
revalidatePath(appPath(applicationId));
|
||||
revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
|
||||
revalidatePath("/approvals");
|
||||
}
|
||||
|
||||
// ── Add a candidate to a requisition's pipeline ────────────────────────────────
|
||||
|
||||
export async function addApplication(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("manage_candidates");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const requisitionId = formData.get("requisitionId") as string;
|
||||
const crewMemberId = formData.get("crewMemberId") as string;
|
||||
if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
|
||||
|
||||
const [requisition, candidate, existing] = await Promise.all([
|
||||
db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
|
||||
db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
|
||||
db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
|
||||
]);
|
||||
if (!requisition) return { error: "Requisition not found" };
|
||||
if (!candidate) return { error: "Candidate not found" };
|
||||
if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
|
||||
return { error: `Cannot add candidates to a ${requisition.status} requisition` };
|
||||
}
|
||||
if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
|
||||
|
||||
const application = await db.application.create({
|
||||
data: {
|
||||
requisitionId,
|
||||
crewMemberId,
|
||||
type: candidate.type,
|
||||
stage: "SHORTLISTED",
|
||||
actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
|
||||
},
|
||||
});
|
||||
|
||||
// First candidate moves the requisition from OPEN into sourcing.
|
||||
if (requisition.status === "OPEN") {
|
||||
await db.requisition.update({
|
||||
where: { id: requisitionId },
|
||||
data: {
|
||||
status: "SHORTLISTING",
|
||||
actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidateApp(application.id, requisitionId);
|
||||
return { ok: true, id: application.id };
|
||||
}
|
||||
|
||||
// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
|
||||
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
|
||||
// select have dedicated actions below.
|
||||
|
||||
export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
|
||||
if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
|
||||
return { error: "Use the dedicated action for this step" };
|
||||
}
|
||||
const g = await guard("manage_candidates");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
const transition = getTransition(app.stage, action);
|
||||
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
|
||||
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
|
||||
|
||||
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
|
||||
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
|
||||
// gate is completed by `verify_competency`.
|
||||
if (action === "verify_competency") {
|
||||
const references = await db.referenceCheck.count({ where: { applicationId: id } });
|
||||
if (references === 0) {
|
||||
return { error: "Record at least one reference check before completing competency & references" };
|
||||
}
|
||||
}
|
||||
|
||||
await db.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stage: transition.to,
|
||||
// Completing the competency & references stage records its gate.
|
||||
...(action === "verify_competency"
|
||||
? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
|
||||
: {}),
|
||||
actions: {
|
||||
create: {
|
||||
actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: app.crewMemberId,
|
||||
metadata: { from: app.stage, to: transition.to },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const referenceSchema = z.object({
|
||||
refereeName: z.string().trim().min(1, "Referee name is required"),
|
||||
refereeContact: z.string().optional(),
|
||||
outcome: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("record_reference_check");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const id = formData.get("applicationId") as string;
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
|
||||
const parsed = referenceSchema.safeParse({
|
||||
refereeName: formData.get("refereeName"),
|
||||
refereeContact: (formData.get("refereeContact") as string) || undefined,
|
||||
outcome: (formData.get("outcome") as string) || undefined,
|
||||
note: (formData.get("note") as string) || undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
|
||||
await db.referenceCheck.create({
|
||||
data: {
|
||||
applicationId: id,
|
||||
refereeName: parsed.data.refereeName,
|
||||
refereeContact: parsed.data.refereeContact ?? null,
|
||||
outcome: parsed.data.outcome ?? null,
|
||||
note: parsed.data.note ?? null,
|
||||
recordedById: g.userId,
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
|
||||
|
||||
const docsSchema = z.object({
|
||||
accountName: z.string().optional(),
|
||||
accountNumber: z.string().optional(),
|
||||
ifsc: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
uan: z.string().optional(),
|
||||
aadhaarLast4: z.string().optional(),
|
||||
pfNumber: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("manage_candidates");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const id = formData.get("applicationId") as string;
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
const transition = getTransition(app.stage, "verify_docs");
|
||||
if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
|
||||
|
||||
const parsed = docsSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
const crewMemberId = app.crewMember.id;
|
||||
|
||||
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
|
||||
// the seat's rank is EXPIRED.
|
||||
// Scope note (documented limitation): seafarer documents are collected on the
|
||||
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
|
||||
// usually has none on file, so a hard "missing document" block would stall the
|
||||
// whole funnel. We therefore gate on what is available (expiry of documents the
|
||||
// candidate already holds); the "all required documents present" check is
|
||||
// enforced post-onboarding in the verification queue (§8.11). Once careers
|
||||
// intake (A2) uploads documents pre-onboarding, tighten this to also require
|
||||
// presence of every mandatory docType.
|
||||
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
|
||||
if (reqRank) {
|
||||
const [required, candidateDocs] = await Promise.all([
|
||||
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
|
||||
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
|
||||
]);
|
||||
const requiredTypes = new Set(required.map((r) => r.docType));
|
||||
const now = new Date();
|
||||
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
|
||||
if (expired.length > 0) {
|
||||
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
|
||||
}
|
||||
}
|
||||
// C4 (experience check) is deferred: the Requisition has no min-experience
|
||||
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
|
||||
// the candidate's ExperienceRecord total against it here and flag a shortfall.
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
// Capture bank / EPF (PII — encryption deferred to Phase 4).
|
||||
await tx.bankDetail.upsert({
|
||||
where: { crewMemberId },
|
||||
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||
create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||
});
|
||||
await tx.epfDetail.upsert({
|
||||
where: { crewMemberId },
|
||||
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||
create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||
});
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stage: transition.to,
|
||||
gates: {
|
||||
create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
|
||||
},
|
||||
actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
|
||||
|
||||
const salarySchema = z.object({
|
||||
rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
|
||||
basic: z.coerce.number().positive("Basic must be greater than 0"),
|
||||
victualingPerDay: z.coerce.number().min(0).default(0),
|
||||
currency: z.string().default("INR"),
|
||||
});
|
||||
|
||||
export async function agreeSalary(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("manage_candidates");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const id = formData.get("applicationId") as string;
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
|
||||
|
||||
const parsed = salarySchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
// One live proposed structure per application — replace any prior draft.
|
||||
await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
|
||||
await tx.salaryStructure.create({
|
||||
data: {
|
||||
applicationId: id,
|
||||
rateBasis: d.rateBasis,
|
||||
basic: d.basic,
|
||||
victualingPerDay: d.victualingPerDay,
|
||||
currency: d.currency,
|
||||
},
|
||||
});
|
||||
// Salary gate goes PENDING for the Manager's queue.
|
||||
await tx.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
|
||||
update: { result: "PENDING", decidedById: null, note: null },
|
||||
create: { applicationId: id, gate: "SALARY", result: "PENDING" },
|
||||
});
|
||||
await tx.crewAction.create({
|
||||
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
|
||||
});
|
||||
});
|
||||
|
||||
const managers = await getManagerRecipients();
|
||||
await notifyCrew({
|
||||
event: "SALARY_FOR_APPROVAL",
|
||||
recipients: managers,
|
||||
subject: `Salary for approval — ${app.crewMember.name}`,
|
||||
body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
|
||||
link: appPath(id),
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function approveSalary(id: string): Promise<ActionResult> {
|
||||
const g = await guard("approve_salary_structure");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
|
||||
await tx.applicationGate.update({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
|
||||
data: { result: "VERIFIED", decidedById: g.userId },
|
||||
});
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stage: "PROPOSED",
|
||||
actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
|
||||
const g = await guard("approve_salary_structure");
|
||||
if ("error" in g) return g;
|
||||
if (!reason?.trim()) return { error: "A reason is required to return for revision" };
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
|
||||
await db.applicationGate.updateMany({
|
||||
where: { applicationId: id, gate: "SALARY" },
|
||||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
||||
});
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
|
||||
|
||||
export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
|
||||
const g = await guard("record_interview_result");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
|
||||
|
||||
if (!accepted) {
|
||||
// A failed interview rejects the application.
|
||||
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
|
||||
}
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
|
||||
await tx.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
|
||||
update: { result: "VERIFIED", decidedById: g.userId },
|
||||
create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
|
||||
});
|
||||
// Selection now pending for the Manager.
|
||||
await tx.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||
update: { result: "PENDING", decidedById: null },
|
||||
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
|
||||
});
|
||||
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
|
||||
});
|
||||
|
||||
const managers = await getManagerRecipients();
|
||||
await notifyCrew({
|
||||
event: "SELECTION_FOR_APPROVAL",
|
||||
recipients: managers,
|
||||
subject: `Selection for approval — ${app.crewMember.name}`,
|
||||
body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
|
||||
link: appPath(id),
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
|
||||
const g = await guard("request_interview_waiver");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
|
||||
if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
|
||||
|
||||
await db.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
|
||||
update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
|
||||
create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
|
||||
|
||||
const managers = await getManagerRecipients();
|
||||
await notifyCrew({
|
||||
event: "WAIVER_REQUESTED",
|
||||
recipients: managers,
|
||||
subject: `Interview waiver requested — ${app.crewMember.name}`,
|
||||
body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
|
||||
link: appPath(id),
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
|
||||
const g = await guard("approve_interview_waiver");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.application.update({ where: { id }, data: { interviewWaived: true } });
|
||||
await tx.applicationGate.update({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
|
||||
data: { result: "VERIFIED", decidedById: g.userId },
|
||||
});
|
||||
// Waived → selection is now pending.
|
||||
await tx.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||
update: { result: "PENDING", decidedById: null },
|
||||
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
|
||||
});
|
||||
await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
|
||||
const g = await guard("approve_interview_waiver");
|
||||
if ("error" in g) return g;
|
||||
if (!reason?.trim()) return { error: "A reason is required to decline" };
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
|
||||
await db.applicationGate.updateMany({
|
||||
where: { applicationId: id, gate: "WAIVER" },
|
||||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
||||
});
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function selectCandidate(id: string): Promise<ActionResult> {
|
||||
const g = await guard("select_candidate");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
|
||||
|
||||
const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
|
||||
if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
|
||||
return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
|
||||
}
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.applicationGate.upsert({
|
||||
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||
update: { result: "VERIFIED", decidedById: g.userId },
|
||||
create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
|
||||
});
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
|
||||
});
|
||||
// The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
|
||||
await tx.requisition.update({
|
||||
where: { id: app.requisition.id },
|
||||
data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
|
||||
});
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
|
||||
const g = await guard("select_candidate");
|
||||
if ("error" in g) return g;
|
||||
if (!reason?.trim()) return { error: "A reason is required to return" };
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
|
||||
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
|
||||
await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
|
||||
});
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
|
||||
|
||||
async function rejectApplicationInternal(
|
||||
id: string,
|
||||
crewMemberId: string,
|
||||
requisitionId: string,
|
||||
userId: string,
|
||||
reason: string
|
||||
): Promise<ActionResult> {
|
||||
await db.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stage: "REJECTED",
|
||||
rejectedReason: reason,
|
||||
rejectedAt: new Date(),
|
||||
actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
|
||||
},
|
||||
});
|
||||
revalidateApp(id, requisitionId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
|
||||
const g = await guard("manage_candidates");
|
||||
if ("error" in g) return g;
|
||||
if (!reason?.trim()) return { error: "A reason is required to reject" };
|
||||
|
||||
const app = await loadApp(id);
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
|
||||
|
||||
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
|
||||
}
|
||||
|
||||
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
|
||||
// One transaction off a SELECTED application: assign the employee number, create
|
||||
// the ACTIVE assignment, bind the approved salary, flip the application to
|
||||
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
|
||||
// Login-account creation for management ranks is a deferred follow-up.
|
||||
|
||||
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("onboard_crew");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const id = formData.get("applicationId") as string;
|
||||
const joiningStr = formData.get("joiningDate") as string;
|
||||
if (!joiningStr) return { error: "A joining date is required" };
|
||||
|
||||
const app = await db.application.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
|
||||
crewMember: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
if (!app) return { error: "Application not found" };
|
||||
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
|
||||
|
||||
// D1 (spec §8.5): onboarding is blocked until the salary structure is
|
||||
// Manager-approved. Without this guard a SELECTED application that somehow has
|
||||
// no approved structure would still "succeed" but bind zero salary rows
|
||||
// (the updateMany below would match nothing) — a silent payroll gap.
|
||||
const approvedSalary = await db.salaryStructure.findFirst({
|
||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
|
||||
|
||||
const joiningDate = new Date(joiningStr);
|
||||
|
||||
// Upload the optional contract letter BEFORE the transaction (storage I/O),
|
||||
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
|
||||
// event (spec §11). The blob key is keyed on the crew member (stable before the
|
||||
// assignment exists); if the tx fails we leave only a harmless orphan blob,
|
||||
// never a fully-onboarded crew member with no contract row.
|
||||
const file = formData.get("contract");
|
||||
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
|
||||
if (file instanceof File && file.size > 0) {
|
||||
const key = buildStorageKey("contract", app.crewMember.id, file.name);
|
||||
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||
contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
|
||||
}
|
||||
|
||||
const result = await db.$transaction(async (tx) => {
|
||||
const employeeId = await generateEmployeeId(tx);
|
||||
const assignment = await tx.crewAssignment.create({
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
signOnDate: joiningDate,
|
||||
crewMemberId: app.crewMember.id,
|
||||
rankId: app.requisition.rankId,
|
||||
vesselId: app.requisition.vesselId,
|
||||
siteId: app.requisition.siteId,
|
||||
requisitionId: app.requisition.id,
|
||||
},
|
||||
});
|
||||
// Bind the Manager-approved salary structure to the new assignment.
|
||||
await tx.salaryStructure.updateMany({
|
||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
|
||||
});
|
||||
if (contract) {
|
||||
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
|
||||
}
|
||||
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
|
||||
await tx.application.update({
|
||||
where: { id },
|
||||
data: {
|
||||
stage: "ONBOARDED",
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "CREW_ONBOARDED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: app.crewMember.id,
|
||||
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await tx.requisition.update({
|
||||
where: { id: app.requisition.id },
|
||||
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
|
||||
});
|
||||
await tx.crewMember.update({
|
||||
where: { id: app.crewMember.id },
|
||||
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
|
||||
});
|
||||
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
|
||||
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
|
||||
return { assignmentId: assignment.id, employeeId };
|
||||
});
|
||||
|
||||
revalidateApp(id, app.requisition.id);
|
||||
return { ok: true, id: result.employeeId };
|
||||
}
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import {
|
||||
advanceStage,
|
||||
agreeSalary,
|
||||
approveSalary,
|
||||
returnSalary,
|
||||
verifyDocuments,
|
||||
recordReferenceCheck,
|
||||
recordInterviewResult,
|
||||
requestInterviewWaiver,
|
||||
approveInterviewWaiver,
|
||||
selectCandidate,
|
||||
returnSelection,
|
||||
rejectApplication,
|
||||
onboardCandidate,
|
||||
} 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";
|
||||
const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
|
||||
const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
|
||||
|
||||
export type ActionCardProps = {
|
||||
id: string;
|
||||
stage: ApplicationStage;
|
||||
isExHand: boolean;
|
||||
interviewResult: InterviewOutcome;
|
||||
interviewWaived: boolean;
|
||||
rejectedReason: string | null;
|
||||
salaryPending: boolean;
|
||||
waiverPending: boolean;
|
||||
selectionPending: boolean;
|
||||
employeeNo: string | null;
|
||||
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
|
||||
perms: {
|
||||
manage: boolean;
|
||||
recordReference: boolean;
|
||||
recordInterview: boolean;
|
||||
requestWaiver: boolean;
|
||||
approveSalary: boolean;
|
||||
approveWaiver: boolean;
|
||||
select: boolean;
|
||||
onboard: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function useAction() {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
|
||||
setPending(true);
|
||||
setError("");
|
||||
const res = await fn();
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error);
|
||||
else router.refresh();
|
||||
return res;
|
||||
}
|
||||
return { pending, error, run };
|
||||
}
|
||||
|
||||
function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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">{title}</h2>
|
||||
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
<div className="p-4 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectButton({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await rejectApplication(id, reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
|
||||
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
|
||||
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
|
||||
{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">
|
||||
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>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 ? "Rejecting…" : "Reject"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Err({ msg }: { msg: string }) {
|
||||
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null;
|
||||
}
|
||||
|
||||
export function ApplicationActionCard(p: ActionCardProps) {
|
||||
const { run, pending, error } = useAction();
|
||||
const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
|
||||
|
||||
// Reference-check form state (COMPETENCY_AND_REFERENCES).
|
||||
const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
|
||||
// Bank/EPF form state (DOC_VERIFICATION).
|
||||
const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
|
||||
// Salary form state (SALARY_AGREEMENT).
|
||||
const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
|
||||
|
||||
function fdFrom(obj: Record<string, string>, extra?: Record<string, string>) {
|
||||
const fd = new FormData();
|
||||
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
|
||||
return fd;
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Err msg={error} />
|
||||
{canReject && (
|
||||
<div className="flex justify-end pt-1">
|
||||
<RejectButton id={p.id} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
switch (p.stage) {
|
||||
case "SHORTLISTED":
|
||||
return (
|
||||
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
|
||||
{p.perms.manage && (
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
|
||||
Start competency & references
|
||||
</button>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "COMPETENCY_AND_REFERENCES":
|
||||
return (
|
||||
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
|
||||
{p.perms.recordReference && (
|
||||
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
|
||||
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
|
||||
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
|
||||
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
|
||||
Save reference
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{p.perms.manage && (
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
|
||||
Verify & continue to documents
|
||||
</button>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "DOC_VERIFICATION":
|
||||
return (
|
||||
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
|
||||
{p.perms.manage ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
|
||||
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
|
||||
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
|
||||
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
|
||||
</div>
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
|
||||
Verify & continue to salary
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "SALARY_AGREEMENT":
|
||||
if (p.salaryPending) {
|
||||
return (
|
||||
<Card title="Salary" sub="Office-only; the Manager approves.">
|
||||
<p className="text-sm text-neutral-600">
|
||||
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
|
||||
</p>
|
||||
{p.perms.approveSalary ? (
|
||||
<div className="flex gap-2">
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
|
||||
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card title="Salary" sub="Office-only; the Manager approves.">
|
||||
{p.perms.manage ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
|
||||
<option value="MONTHLY">Per month</option>
|
||||
<option value="DAILY">Per day</option>
|
||||
</select>
|
||||
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
|
||||
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
|
||||
</div>
|
||||
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
|
||||
Agree salary & send for approval
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "PROPOSED":
|
||||
return (
|
||||
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
|
||||
{p.perms.manage && (
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
|
||||
Candidate accepted — schedule interview
|
||||
</button>
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "INTERVIEW":
|
||||
return (
|
||||
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
|
||||
{/* Interview result row */}
|
||||
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
|
||||
<div className="flex gap-2">
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
|
||||
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Waiver (ex-hand) */}
|
||||
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
|
||||
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver → Manager</button>
|
||||
)}
|
||||
{p.waiverPending && (
|
||||
p.perms.approveWaiver ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-warning-700">Waiver requested.</span>
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
|
||||
)
|
||||
)}
|
||||
{/* Selection row */}
|
||||
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
|
||||
p.perms.select ? (
|
||||
<div className="flex gap-2">
|
||||
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve — select</button>
|
||||
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} — awaiting Manager selection.</p>
|
||||
)
|
||||
)}
|
||||
{footer}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "SELECTED":
|
||||
return (
|
||||
<Card title="Selected" sub="Ready to onboard.">
|
||||
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
|
||||
{p.perms.onboard && <OnboardButton id={p.id} />}
|
||||
</Card>
|
||||
);
|
||||
|
||||
case "REJECTED":
|
||||
return (
|
||||
<Card title="Rejected">
|
||||
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
|
||||
</Card>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Card title="Onboarded">
|
||||
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">
|
||||
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function OnboardButton({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [joiningDate, setJoiningDate] = useState("");
|
||||
const [contract, setContract] = useState<File | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
fd.set("applicationId", id);
|
||||
fd.set("joiningDate", joiningDate);
|
||||
if (contract) fd.set("contract", contract);
|
||||
const res = await onboardCandidate(fd);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={PRIMARY} onClick={() => setOpen(true)}>Onboard to crew</button>
|
||||
<AdminDialog title="Onboard to crew" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
|
||||
<input type="date" className={INPUT} value={joiningDate} onChange={(e) => setJoiningDate(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Contract letter (optional)</label>
|
||||
<input 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" onChange={(e) => setContract(e.target.files?.[0] ?? null)} />
|
||||
</div>
|
||||
<div className="rounded-md bg-neutral-50 border border-neutral-200 p-3">
|
||||
<p className="text-xs font-medium text-neutral-600 mb-1">Starts automatically on confirm</p>
|
||||
<p className="text-xs text-neutral-500">Employee number · salary & victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)</p>
|
||||
</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">
|
||||
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !joiningDate} className={PRIMARY}>{pending ? "Onboarding…" : "Confirm onboarding"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await onReturn(reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
|
||||
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
||||
{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">
|
||||
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal file
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { ApplicationStage } from "@prisma/client";
|
||||
import type { BadgeProps } from "@/components/ui/badge";
|
||||
|
||||
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||
|
||||
// The 7 board columns in order (mirrors lib/application-pipeline BOARD_STAGES;
|
||||
// kept here as a client-safe constant for the stepper/board UI).
|
||||
export const STAGE_ORDER: ApplicationStage[] = [
|
||||
"SHORTLISTED",
|
||||
"COMPETENCY_AND_REFERENCES",
|
||||
"DOC_VERIFICATION",
|
||||
"SALARY_AGREEMENT",
|
||||
"PROPOSED",
|
||||
"INTERVIEW",
|
||||
"SELECTED",
|
||||
];
|
||||
|
||||
export const STAGE_LABEL: Record<ApplicationStage, string> = {
|
||||
SHORTLISTED: "Shortlisted",
|
||||
COMPETENCY_AND_REFERENCES: "Competency & references",
|
||||
DOC_VERIFICATION: "Documents",
|
||||
SALARY_AGREEMENT: "Salary",
|
||||
PROPOSED: "Proposed",
|
||||
INTERVIEW: "Interview",
|
||||
SELECTED: "Selected",
|
||||
REJECTED: "Rejected",
|
||||
ONBOARDED: "Onboarded",
|
||||
};
|
||||
|
||||
export const STAGE_VARIANT: Record<ApplicationStage, Variant> = {
|
||||
SHORTLISTED: "outline",
|
||||
COMPETENCY_AND_REFERENCES: "default",
|
||||
DOC_VERIFICATION: "default",
|
||||
SALARY_AGREEMENT: "warning",
|
||||
PROPOSED: "default",
|
||||
INTERVIEW: "warning",
|
||||
SELECTED: "success",
|
||||
REJECTED: "danger",
|
||||
ONBOARDED: "success",
|
||||
};
|
||||
|
||||
// Index of a stage within the 7-step flow (−1 for REJECTED; 7 for ONBOARDED).
|
||||
export function stageIndex(stage: ApplicationStage): number {
|
||||
if (stage === "REJECTED") return -1;
|
||||
if (stage === "ONBOARDED") return STAGE_ORDER.length;
|
||||
return STAGE_ORDER.indexOf(stage);
|
||||
}
|
||||
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal file
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"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 { canPerformAction, canReject } from "@/lib/appraisal-state-machine";
|
||||
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
|
||||
import { notifyCrew } from "@/lib/notifier";
|
||||
import type { Role } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function loadAppraisal(id: string) {
|
||||
return db.appraisal.findUnique({
|
||||
where: { id },
|
||||
include: { assignment: { include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } } } },
|
||||
});
|
||||
}
|
||||
|
||||
function revalidate(crewMemberId: string) {
|
||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||
revalidatePath("/crewing/verification");
|
||||
revalidatePath("/approvals");
|
||||
}
|
||||
|
||||
// ── Raise an appraisal (PM / site staff) ───────────────────────────────────────
|
||||
|
||||
const raiseSchema = z.object({
|
||||
assignmentId: z.string().min(1, "Crew assignment is required"),
|
||||
period: z.string().trim().min(1, "Period is required"),
|
||||
comments: z.string().optional(),
|
||||
competence: z.coerce.number().int().min(1).max(5).optional(),
|
||||
conduct: z.coerce.number().int().min(1).max(5).optional(),
|
||||
safety: z.coerce.number().int().min(1).max(5).optional(),
|
||||
});
|
||||
|
||||
export async function raiseAppraisal(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("raise_appraisal");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = raiseSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
|
||||
const assignment = await db.crewAssignment.findUnique({
|
||||
where: { id: d.assignmentId },
|
||||
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
|
||||
});
|
||||
if (!assignment) return { error: "Crew assignment not found" };
|
||||
|
||||
const appraisal = await db.appraisal.create({
|
||||
data: {
|
||||
assignmentId: d.assignmentId,
|
||||
period: d.period,
|
||||
comments: d.comments ?? null,
|
||||
ratings: { competence: d.competence ?? null, conduct: d.conduct ?? null, safety: d.safety ?? null },
|
||||
status: "SUBMITTED",
|
||||
addedById: g.userId,
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "APPRAISAL_SUBMITTED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
|
||||
|
||||
const mpos = await getMpoRecipients();
|
||||
await notifyCrew({
|
||||
event: "APPRAISAL_FOR_VERIFICATION",
|
||||
recipients: mpos,
|
||||
subject: `Appraisal to verify — ${assignment.crewMember.name}`,
|
||||
body: `An appraisal for ${assignment.crewMember.name} (${assignment.rank.name}, ${d.period}) awaits MPO verification.`,
|
||||
link: "/crewing/verification",
|
||||
});
|
||||
|
||||
revalidate(assignment.crewMember.id);
|
||||
return { ok: true, id: appraisal.id };
|
||||
}
|
||||
|
||||
// ── Verify (MPO) ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function verifyAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_appraisal");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const a = await loadAppraisal(id);
|
||||
if (!a) return { error: "Appraisal not found" };
|
||||
|
||||
if (!approve) {
|
||||
if (!canReject(a.status)) return { error: `Cannot reject from ${a.status}` };
|
||||
if (!remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
||||
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
||||
revalidate(a.assignment.crewMember.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!canPerformAction(a.status, "verify", g.role)) return { error: `Cannot verify from ${a.status}` };
|
||||
await db.appraisal.update({ where: { id }, data: { status: "MPO_VERIFIED", verifiedById: g.userId } });
|
||||
await db.crewAction.create({ data: { actionType: "APPRAISAL_VERIFIED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
||||
|
||||
const managers = await getManagerRecipients();
|
||||
await notifyCrew({
|
||||
event: "APPRAISAL_FOR_APPROVAL",
|
||||
recipients: managers,
|
||||
subject: `Appraisal for approval — ${a.assignment.crewMember.name}`,
|
||||
body: `${a.assignment.crewMember.name}'s appraisal (${a.assignment.rank.name}, ${a.period}) has been MPO-verified and awaits your approval.`,
|
||||
link: "/approvals",
|
||||
});
|
||||
|
||||
revalidate(a.assignment.crewMember.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Approve (Manager) ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function approveAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("approve_appraisal");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const a = await loadAppraisal(id);
|
||||
if (!a) return { error: "Appraisal not found" };
|
||||
|
||||
if (!approve) {
|
||||
if (!canReject(a.status)) return { error: `Cannot return from ${a.status}` };
|
||||
if (!remarks?.trim()) return { error: "A reason is required to return" };
|
||||
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
||||
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
||||
revalidate(a.assignment.crewMember.id);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (!canPerformAction(a.status, "approve", g.role)) return { error: `Cannot approve from ${a.status}` };
|
||||
await db.appraisal.update({ where: { id }, data: { status: "MANAGER_APPROVED", approvedById: g.userId } });
|
||||
await db.crewAction.create({ data: { actionType: "APPRAISAL_APPROVED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
||||
|
||||
revalidate(a.assignment.crewMember.id);
|
||||
return { ok: true };
|
||||
}
|
||||
46
App/app/(portal)/crewing/attendance/actions.ts
Normal file
46
App/app/(portal)/crewing/attendance/actions.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { AttendanceStatus } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
const markSchema = z.object({ date: z.string().min(1), status: z.nativeEnum(AttendanceStatus) });
|
||||
|
||||
// Bulk-save the dirty cells from the month calendar (Site staff). One upsert per
|
||||
// (assignment, date); a single ATTENDANCE_RECORDED audit row per save.
|
||||
export async function saveAttendance(assignmentId: string, marks: { date: string; status: AttendanceStatus }[]): Promise<ActionResult> {
|
||||
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "record_attendance")) return { error: "Unauthorized" };
|
||||
|
||||
if (!assignmentId) return { error: "Crew member is required" };
|
||||
const parsed = z.array(markSchema).max(40).safeParse(marks);
|
||||
if (!parsed.success) return { error: "Invalid attendance data" };
|
||||
if (parsed.data.length === 0) return { ok: true };
|
||||
|
||||
const assignment = await db.crewAssignment.findUnique({ where: { id: assignmentId }, select: { crewMemberId: true } });
|
||||
if (!assignment) return { error: "Crew assignment not found" };
|
||||
|
||||
await db.$transaction(
|
||||
parsed.data.map((m) =>
|
||||
db.attendance.upsert({
|
||||
where: { assignmentId_date: { assignmentId, date: new Date(m.date) } },
|
||||
update: { status: m.status, recordedById: session.user.id },
|
||||
create: { assignmentId, date: new Date(m.date), status: m.status, recordedById: session.user.id },
|
||||
})
|
||||
)
|
||||
);
|
||||
await db.crewAction.create({
|
||||
data: { actionType: "ATTENDANCE_RECORDED", actorId: session.user.id, crewMemberId: assignment.crewMemberId, metadata: { count: parsed.data.length } },
|
||||
});
|
||||
|
||||
revalidatePath("/crewing/attendance");
|
||||
return { ok: true };
|
||||
}
|
||||
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal file
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type { AttendanceStatus } from "@prisma/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { saveAttendance } from "./actions";
|
||||
|
||||
type Assignment = { id: string; crewName: string; rank: string; location: string; marks: Record<string, AttendanceStatus> };
|
||||
|
||||
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";
|
||||
|
||||
// Tap cycle (§8.10): Unmarked → Present → Absent → Leave → Half day → Unmarked.
|
||||
const CYCLE: (AttendanceStatus | null)[] = [null, "PRESENT", "ABSENT", "ON_LEAVE", "HALF_DAY"];
|
||||
const next = (s: AttendanceStatus | null) => CYCLE[(CYCLE.indexOf(s ?? null) + 1) % CYCLE.length];
|
||||
|
||||
const CELL: Record<AttendanceStatus, string> = {
|
||||
PRESENT: "bg-success-100 text-success-700 border-success-200",
|
||||
ABSENT: "bg-danger-100 text-danger-700 border-danger-200",
|
||||
ON_LEAVE: "bg-warning-100 text-warning-700 border-warning-200",
|
||||
HALF_DAY: "bg-primary-100 text-primary-700 border-primary-200",
|
||||
SIGN_OFF: "bg-neutral-200 text-neutral-600 border-neutral-300",
|
||||
};
|
||||
const ABBR: Record<AttendanceStatus, string> = { PRESENT: "P", ABSENT: "A", ON_LEAVE: "L", HALF_DAY: "½", SIGN_OFF: "S" };
|
||||
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
const iso = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
|
||||
export function AttendanceCalendar({ assignments, canEdit }: { assignments: Assignment[]; canEdit: boolean }) {
|
||||
const router = useRouter();
|
||||
const today = new Date();
|
||||
const [selectedId, setSelectedId] = useState(assignments[0]?.id ?? "");
|
||||
const [y, setY] = useState(today.getFullYear());
|
||||
const [m, setM] = useState(today.getMonth());
|
||||
const [edits, setEdits] = useState<Record<string, Record<string, AttendanceStatus | null>>>({});
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const selected = assignments.find((a) => a.id === selectedId) ?? null;
|
||||
const myEdits = edits[selectedId] ?? {};
|
||||
|
||||
const statusOf = (date: string): AttendanceStatus | null => {
|
||||
if (date in myEdits) return myEdits[date];
|
||||
return selected?.marks[date] ?? null;
|
||||
};
|
||||
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
const firstWeekday = new Date(y, m, 1).getDay();
|
||||
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
let present = 0, absent = 0, leave = 0;
|
||||
for (const d of days) {
|
||||
const s = (date => (date in myEdits ? myEdits[date] : selected?.marks[date] ?? null))(iso(y, m, d));
|
||||
if (s === "PRESENT") present++; else if (s === "ABSENT") absent++; else if (s === "ON_LEAVE") leave++;
|
||||
}
|
||||
return { present, absent, leave };
|
||||
}, [days, myEdits, selected, y, m]);
|
||||
|
||||
const unmarkedToDate = useMemo(() => {
|
||||
const isCurrentOrPast = y < today.getFullYear() || (y === today.getFullYear() && m <= today.getMonth());
|
||||
if (!isCurrentOrPast) return 0;
|
||||
const lastDay = (y === today.getFullYear() && m === today.getMonth()) ? today.getDate() : daysInMonth;
|
||||
let n = 0;
|
||||
for (let d = 1; d <= lastDay; d++) if (statusOf(iso(y, m, d)) === null) n++;
|
||||
return n;
|
||||
}, [y, m, daysInMonth, myEdits, selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const dirty = Object.keys(myEdits).length > 0;
|
||||
|
||||
function cycleDay(date: string) {
|
||||
if (!canEdit) return;
|
||||
setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] ?? {}), [date]: next(statusOf(date)) } }));
|
||||
}
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
const nm = m + delta;
|
||||
if (nm < 0) { setM(11); setY(y - 1); } else if (nm > 11) { setM(0); setY(y + 1); } else setM(nm);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setPending(true);
|
||||
// Null edits (cleared cells) are skipped — clearing a saved mark isn't supported here.
|
||||
const marks = Object.entries(myEdits).filter(([, s]) => s !== null).map(([date, status]) => ({ date, status: status as AttendanceStatus }));
|
||||
const res = await saveAttendance(selectedId, marks);
|
||||
setPending(false);
|
||||
if ("ok" in res) { setEdits((e) => ({ ...e, [selectedId]: {} })); router.refresh(); }
|
||||
}
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900 mb-2">Attendance</h1>
|
||||
<p className="text-neutral-400">No active crew to mark attendance for.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Attendance</h1>
|
||||
{canEdit && (
|
||||
<button onClick={save} disabled={!dirty || pending} className={cn("rounded-lg px-4 py-2 text-sm font-semibold text-white", dirty ? "bg-primary-600 hover:bg-primary-700" : "bg-neutral-300", "disabled:opacity-60")}>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<select className={INPUT} value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
|
||||
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => shiftMonth(-1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronLeft className="h-4 w-4" /></button>
|
||||
<span className="text-sm font-medium text-neutral-800 w-36 text-center">{MONTHS[m]} {y}</span>
|
||||
<button onClick={() => shiftMonth(1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronRight className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unmarkedToDate > 0 && (
|
||||
<div className="mb-4 rounded-lg border border-warning-200 bg-warning-50 px-4 py-2 text-sm text-warning-800">{unmarkedToDate} day{unmarkedToDate === 1 ? "" : "s"} still need marking.</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||
{([["Present", summary.present], ["Absent", summary.absent], ["On leave", summary.leave]] as const).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg border border-neutral-200 bg-white p-3 text-center">
|
||||
<p className="text-2xl font-semibold text-neutral-900">{v}</p>
|
||||
<p className="text-xs text-neutral-500">{k}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||
<div className="grid grid-cols-7 gap-1 mb-1 text-center text-xs font-medium text-neutral-400">
|
||||
{["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map((d) => <div key={d}>{d}</div>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: firstWeekday }).map((_, i) => <div key={`pad${i}`} />)}
|
||||
{days.map((d) => {
|
||||
const date = iso(y, m, d);
|
||||
const s = statusOf(date);
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => cycleDay(date)}
|
||||
disabled={!canEdit}
|
||||
className={cn(
|
||||
"aspect-square rounded-md border text-sm flex flex-col items-center justify-center",
|
||||
s ? CELL[s] : "border-dashed border-neutral-200 text-neutral-400",
|
||||
canEdit ? "hover:ring-2 hover:ring-primary-200 cursor-pointer" : "cursor-default"
|
||||
)}
|
||||
>
|
||||
<span className="text-[11px] leading-none">{d}</span>
|
||||
{s && <span className="text-xs font-semibold leading-none mt-0.5">{ABBR[s]}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-neutral-500">
|
||||
<span><span className="inline-block w-3 h-3 rounded bg-success-100 border border-success-200 align-middle" /> Present</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded bg-danger-100 border border-danger-200 align-middle" /> Absent</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded bg-warning-100 border border-warning-200 align-middle" /> Leave</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded bg-primary-100 border border-primary-200 align-middle" /> Half day</span>
|
||||
</div>
|
||||
</div>
|
||||
{!canEdit && <p className="mt-3 text-xs text-neutral-400">View only — attendance is marked by site staff.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
App/app/(portal)/crewing/attendance/page.tsx
Normal file
46
App/app/(portal)/crewing/attendance/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 { AttendanceCalendar } from "./attendance-calendar";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Attendance" };
|
||||
|
||||
export default async function AttendancePage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
if (!hasPermission(role, "view_attendance")) redirect("/dashboard"); // MPO has no attendance (R5)
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setMonth(cutoff.getMonth() - 4);
|
||||
|
||||
const assignments = await db.crewAssignment.findMany({
|
||||
where: { status: { not: "SIGNED_OFF" } },
|
||||
orderBy: { crewMember: { name: "asc" } },
|
||||
include: {
|
||||
crewMember: { select: { name: true } },
|
||||
rank: { select: { name: true } },
|
||||
vessel: { select: { name: true } },
|
||||
site: { select: { name: true } },
|
||||
attendance: { where: { date: { gte: cutoff } }, select: { date: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AttendanceCalendar
|
||||
assignments={assignments.map((a) => ({
|
||||
id: a.id,
|
||||
crewName: a.crewMember.name,
|
||||
rank: a.rank.name,
|
||||
location: a.vessel?.name ?? a.site?.name ?? "—",
|
||||
marks: Object.fromEntries(a.attendance.map((m) => [m.date.toISOString().slice(0, 10), m.status])),
|
||||
}))}
|
||||
canEdit={hasPermission(role, "record_attendance")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
137
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
137
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
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 } },
|
||||
// B3 AC3 — pull the returning hand's history so the callout shows real records.
|
||||
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
||||
documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: 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> The interview may be waived with Manager approval.{" "}
|
||||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||||
<span>No prior records are on file yet.</span>
|
||||
) : (
|
||||
<span>Prior records on file from earlier assignments:</span>
|
||||
)}
|
||||
|
||||
{c.experienceRecords.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
|
||||
<ul className="space-y-1">
|
||||
{c.experienceRecords.map((e) => (
|
||||
<li key={e.id} className="text-sm text-purple-900">
|
||||
{e.rank?.name ?? "—"}
|
||||
{e.vesselType ? ` · ${e.vesselType}` : ""}
|
||||
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
|
||||
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.documents.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.documents.map((doc) => (
|
||||
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
|
||||
{doc.docType}
|
||||
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
182
App/app/(portal)/crewing/candidates/actions.ts
Normal file
182
App/app/(portal)/crewing/candidates/actions.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"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);
|
||||
|
||||
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
||||
// candidate (not already tagged EX_HAND) is matched to their existing EX_HAND
|
||||
// pool record by a stable key — email when given, else an exact name match —
|
||||
// and the SAME row is reused (so their tour history, documents and bank stay on
|
||||
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
|
||||
// name-only match can in theory collide; email is preferred when available.)
|
||||
if (d.source !== "EX_HAND") {
|
||||
const match = await db.crewMember.findFirst({
|
||||
where: {
|
||||
status: "EX_HAND",
|
||||
...(d.email
|
||||
? { email: { equals: d.email, mode: "insensitive" } }
|
||||
: { name: { equals: d.name, mode: "insensitive" } }),
|
||||
},
|
||||
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
|
||||
});
|
||||
if (match) {
|
||||
const updated = await db.crewMember.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
// Keep EX_HAND type/status; refresh the application's details, never
|
||||
// discarding prior history (take the larger recorded experience).
|
||||
appliedRankId: d.appliedRankId || match.appliedRankId,
|
||||
currentRankId: d.currentRankId || match.currentRankId,
|
||||
email: d.email || match.email,
|
||||
phone: d.phone || match.phone,
|
||||
notes: d.notes || match.notes,
|
||||
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
|
||||
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
|
||||
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
|
||||
},
|
||||
});
|
||||
const cvKey = await storeCv(formData, updated.id);
|
||||
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
|
||||
revalidatePath(LIST_PATH);
|
||||
return { ok: true, id: updated.id };
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
54
App/app/(portal)/crewing/candidates/page.tsx
Normal file
54
App/app/(portal)/crewing/candidates/page.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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),
|
||||
}));
|
||||
|
||||
// B3 AC2 — ex-hands (proven crew) surface above new candidates by default.
|
||||
// Stable sort preserves the createdAt-desc order within each group.
|
||||
rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND"));
|
||||
|
||||
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
||||
}
|
||||
423
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal file
423
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis, AppraisalStatus } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
uploadDocument, deleteDocument, saveBankEpf,
|
||||
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew,
|
||||
} from "../actions";
|
||||
import { raiseAppraisal } from "../../appraisals/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";
|
||||
const BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||
const LINKBTN = "text-xs font-medium text-danger-600 hover:underline";
|
||||
|
||||
const DOC_TYPES: SeafarerDocType[] = ["STCW","AADHAAR","PAN","PASSPORT","CDC","COC","PHOTOGRAPH","DRIVING_LICENSE","MEDICAL_FITNESS","CONTRACT_LETTER"];
|
||||
const PPE_ITEMS: PpeItem[] = ["BOILER_SUIT","SAFETY_SHOES","HELMET","VEST","GLOVES","MASK","GOGGLES","TIFFIN","TORCH","WALKIE_TALKIE"];
|
||||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
||||
|
||||
type Doc = { id: string; docType: SeafarerDocType; number: string | null; issueDate: string | null; expiryDate: string | null; verificationStatus: GateResult; hasFile: boolean };
|
||||
type Nok = { id: string; name: string; relationship: string | null; phone: string | null; address: string | null; isEmergency: boolean };
|
||||
type Ppe = { id: string; item: PpeItem; size: string | null; quantity: number; issuedDate: string; returnedDate: string | null };
|
||||
type Exp = { id: string; vesselType: string | null; rank: string | null; fromDate: string | null; toDate: string | null; durationMonths: number | null; source: string };
|
||||
|
||||
type Props = {
|
||||
crew: { id: string; name: string; employeeId: string; rank: string; location: string; status: AssignmentStatus | null };
|
||||
documents: Doc[];
|
||||
bank: { accountName: string | null; accountNumber: string; ifsc: string | null; bankName: string | null };
|
||||
epf: { uan: string | null; aadhaar: string; pfNumber: string | null };
|
||||
nextOfKin: Nok[];
|
||||
ppe: Ppe[];
|
||||
experience: Exp[];
|
||||
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
|
||||
ranks: { id: string; name: string }[];
|
||||
perms: { editRecords: boolean; issuePpe: boolean };
|
||||
signOff: { assignmentId: string | null; canSignOff: boolean };
|
||||
appraisals: Appr[];
|
||||
appraisalCtx: { assignmentId: string | null; canRaise: boolean };
|
||||
};
|
||||
|
||||
type Appr = { id: string; period: string; status: AppraisalStatus; comments: string | null; ratings: { competence: number | null; conduct: number | null; safety: number | null } | null };
|
||||
|
||||
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status", "Appraisals"] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
|
||||
const APPRAISAL_VARIANT: Record<AppraisalStatus, "outline" | "warning" | "default" | "success" | "danger"> = {
|
||||
DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger",
|
||||
};
|
||||
|
||||
export function CrewProfile(p: Props) {
|
||||
const [tab, setTab] = useState<Tab>("Documents");
|
||||
const router = useRouter();
|
||||
const refresh = () => router.refresh();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<Link href="/crewing/crew" 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" /> Crew
|
||||
</Link>
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
|
||||
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
|
||||
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
|
||||
</div>
|
||||
{p.signOff.canSignOff && p.signOff.assignmentId && <SignOffButton assignmentId={p.signOff.assignmentId} crewName={p.crew.name} />}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
|
||||
|
||||
<div className="mb-5 flex flex-wrap gap-1 border-b border-neutral-200">
|
||||
{TABS.map((t) => (
|
||||
<button key={t} onClick={() => setTab(t)} className={cn("px-3 py-2 text-sm font-medium border-b-2 -mb-px", tab === t ? "border-primary-600 text-primary-700" : "border-transparent text-neutral-500 hover:text-neutral-800")}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "Documents" && <Documents crewId={p.crew.id} docs={p.documents} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||
{tab === "Bank & EPF" && <BankEpf crewId={p.crew.id} bank={p.bank} epf={p.epf} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||
{tab === "Next of kin" && <NextOfKinTab crewId={p.crew.id} rows={p.nextOfKin} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
|
||||
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
|
||||
{tab === "Appraisals" && <Appraisals rows={p.appraisals} ctx={p.appraisalCtx} onDone={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Appraisals({ rows, ctx, onDone }: { rows: Appr[]; ctx: { assignmentId: string | null; canRaise: boolean }; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [f, setF] = useState({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" });
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!ctx.assignmentId) return;
|
||||
const fd = new FormData();
|
||||
fd.set("assignmentId", ctx.assignmentId);
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
run(() => raiseAppraisal(fd), () => setF({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No appraisals.</p> : rows.map((a) => (
|
||||
<div key={a.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-900">{a.period} <Badge variant={APPRAISAL_VARIANT[a.status]}>{a.status.replace(/_/g, " ").toLowerCase()}</Badge></p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"}
|
||||
{a.comments ? ` · ${a.comments}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{ctx.canRaise && ctx.assignmentId && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<input className={INPUT} placeholder="Period (e.g. 2026 or 2026-Q2)" value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} required />
|
||||
<input className={INPUT} placeholder="Comments" value={f.comments} onChange={(e) => setF({ ...f, comments: e.target.value })} />
|
||||
{(["competence", "conduct", "safety"] as const).map((k) => (
|
||||
<label key={k} className="text-xs text-neutral-500 capitalize">{k}
|
||||
<select className={INPUT} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })}>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}</select>
|
||||
</label>
|
||||
))}
|
||||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.period}>{pending ? "Submitting…" : "Submit appraisal"}</button></div>
|
||||
</form>
|
||||
)}
|
||||
{!ctx.canRaise && <p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ children }: { children: React.ReactNode }) {
|
||||
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
|
||||
}
|
||||
function Err({ msg }: { msg: string }) { return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null; }
|
||||
|
||||
function useRun(onDone: () => void) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function run(fn: () => Promise<{ ok: true } | { error: string }>, after?: () => void) {
|
||||
setPending(true); setError("");
|
||||
const res = await fn();
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { after?.(); onDone(); }
|
||||
}
|
||||
return { pending, error, run };
|
||||
}
|
||||
|
||||
function docStatus(d: Doc): { label: string; variant: "success" | "warning" | "danger" | "secondary" } {
|
||||
if (d.expiryDate && new Date(d.expiryDate) < new Date()) return { label: "Expired", variant: "danger" };
|
||||
if (d.verificationStatus === "VERIFIED") return { label: "Verified", variant: "success" };
|
||||
if (d.verificationStatus === "REJECTED") return { label: "Rejected", variant: "danger" };
|
||||
return { label: "Pending", variant: "warning" };
|
||||
}
|
||||
|
||||
function Documents({ crewId, docs, canEdit, onDone }: { crewId: string; docs: Doc[]; canEdit: boolean; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [f, setF] = useState({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" });
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crewId);
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
if (file) fd.set("file", file);
|
||||
run(() => uploadDocument(fd), () => { setF({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" }); setFile(null); });
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{docs.length === 0 ? <p className="text-sm text-neutral-400">No documents.</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Document</th><th>Number</th><th>Issued</th><th>Expires</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{docs.map((d) => { const s = docStatus(d); return (
|
||||
<tr key={d.id} className="border-b border-neutral-50 last:border-0">
|
||||
<td className="py-2 text-neutral-800">{label(d.docType)}{d.hasFile && <span className="ml-1 text-xs text-neutral-400">file</span>}</td>
|
||||
<td className="text-neutral-600">{d.number ?? "—"}</td>
|
||||
<td className="text-neutral-600">{fmtDate(d.issueDate)}</td>
|
||||
<td className="text-neutral-600">{fmtDate(d.expiryDate)}</td>
|
||||
<td><Badge variant={s.variant}>{s.label}</Badge></td>
|
||||
<td className="text-right">{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteDocument(d.id))}>Remove</button>}</td>
|
||||
</tr>
|
||||
); })}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{canEdit && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<select className={INPUT} value={f.docType} onChange={(e) => setF({ ...f, docType: e.target.value })}>
|
||||
{DOC_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
||||
</select>
|
||||
<input className={INPUT} placeholder="Number" value={f.number} onChange={(e) => setF({ ...f, number: e.target.value })} />
|
||||
<label className="text-xs text-neutral-500">Issue date<input type="date" className={INPUT} value={f.issueDate} onChange={(e) => setF({ ...f, issueDate: e.target.value })} /></label>
|
||||
<label className="text-xs text-neutral-500">Expiry date<input type="date" className={INPUT} value={f.expiryDate} onChange={(e) => setF({ ...f, expiryDate: e.target.value })} /></label>
|
||||
<input type="file" className="col-span-2 text-sm" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add document"}</button></div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: string | null }) {
|
||||
return <div className="flex justify-between gap-4 py-1.5 border-b border-neutral-50 last:border-0"><span className="text-sm text-neutral-500">{k}</span><span className="text-sm text-neutral-900 font-mono">{v ?? "—"}</span></div>;
|
||||
}
|
||||
|
||||
function BankEpf({ crewId, bank, epf, canEdit, onDone }: { crewId: string; bank: Props["bank"]; epf: Props["epf"]; canEdit: boolean; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [f, setF] = useState({ accountName: bank.accountName ?? "", accountNumber: "", ifsc: bank.ifsc ?? "", bankName: bank.bankName ?? "", uan: epf.uan ?? "", aadhaarLast4: "", pfNumber: epf.pfNumber ?? "" });
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crewId);
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
run(() => saveBankEpf(fd), () => setEdit(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="rounded-md bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">Sensitive — account and Aadhaar numbers are masked unless you are Accounts.</div>
|
||||
<Row k="Account name" v={bank.accountName} />
|
||||
<Row k="Account number" v={bank.accountNumber} />
|
||||
<Row k="IFSC" v={bank.ifsc} />
|
||||
<Row k="Bank" v={bank.bankName} />
|
||||
<Row k="UAN" v={epf.uan} />
|
||||
<Row k="Aadhaar" v={epf.aadhaar} />
|
||||
<Row k="PF number" v={epf.pfNumber} />
|
||||
{canEdit && !edit && <button className="text-sm text-primary-600 hover:underline" onClick={() => setEdit(true)}>Edit bank & EPF</button>}
|
||||
{canEdit && edit && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<input className={INPUT} placeholder="Account name" value={f.accountName} onChange={(e) => setF({ ...f, accountName: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Account number" value={f.accountNumber} onChange={(e) => setF({ ...f, accountNumber: e.target.value })} />
|
||||
<input className={INPUT} placeholder="IFSC" value={f.ifsc} onChange={(e) => setF({ ...f, ifsc: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Bank name" value={f.bankName} onChange={(e) => setF({ ...f, bankName: e.target.value })} />
|
||||
<input className={INPUT} placeholder="UAN" value={f.uan} onChange={(e) => setF({ ...f, uan: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Aadhaar (last 4)" value={f.aadhaarLast4} onChange={(e) => setF({ ...f, aadhaarLast4: e.target.value })} />
|
||||
<input className={INPUT} placeholder="PF number" value={f.pfNumber} onChange={(e) => setF({ ...f, pfNumber: e.target.value })} />
|
||||
<div className="col-span-2"><Err msg={error} /><div className="flex gap-2"><button className={BTN} disabled={pending}>{pending ? "Saving…" : "Save"}</button><button type="button" className="text-sm text-neutral-500" onClick={() => setEdit(false)}>Cancel</button></div></div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NextOfKinTab({ crewId, rows, canEdit, onDone }: { crewId: string; rows: Nok[]; canEdit: boolean; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [f, setF] = useState({ name: "", relationship: "", phone: "", address: "", isEmergency: false });
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crewId);
|
||||
fd.set("name", f.name); if (f.relationship) fd.set("relationship", f.relationship); if (f.phone) fd.set("phone", f.phone); if (f.address) fd.set("address", f.address); if (f.isEmergency) fd.set("isEmergency", "true");
|
||||
run(() => addNextOfKin(fd), () => setF({ name: "", relationship: "", phone: "", address: "", isEmergency: false }));
|
||||
}
|
||||
return (
|
||||
<Section>
|
||||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No next of kin recorded.</p> : rows.map((n) => (
|
||||
<div key={n.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-900">{n.name} {n.isEmergency && <Badge variant="danger">Emergency</Badge>}</p>
|
||||
<p className="text-xs text-neutral-500">{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}</p>
|
||||
</div>
|
||||
{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteNextOfKin(n.id))}>Remove</button>}
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||
<input className={INPUT} placeholder="Relationship" value={f.relationship} onChange={(e) => setF({ ...f, relationship: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Address" value={f.address} onChange={(e) => setF({ ...f, address: e.target.value })} />
|
||||
<label className="col-span-2 flex items-center gap-2 text-sm text-neutral-600"><input type="checkbox" checked={f.isEmergency} onChange={(e) => setF({ ...f, isEmergency: e.target.checked })} /> Emergency contact</label>
|
||||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.name}>{pending ? "Adding…" : "Add"}</button></div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PpeTab({ crewId, rows, canIssue, onDone }: { crewId: string; rows: Ppe[]; canIssue: boolean; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [f, setF] = useState({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" });
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crewId);
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
run(() => issuePpe(fd), () => setF({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" }));
|
||||
}
|
||||
return (
|
||||
<Section>
|
||||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No PPE issued.</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Item</th><th>Size</th><th>Qty</th><th>Issued</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-neutral-50 last:border-0">
|
||||
<td className="py-2 text-neutral-800">{label(r.item)}</td>
|
||||
<td className="text-neutral-600">{r.size ?? "—"}</td>
|
||||
<td className="text-neutral-600">{r.quantity}</td>
|
||||
<td className="text-neutral-600">{fmtDate(r.issuedDate)}</td>
|
||||
<td>{r.returnedDate ? <Badge variant="secondary">Returned</Badge> : <Badge variant="success">Issued</Badge>}</td>
|
||||
<td className="text-right">{canIssue && !r.returnedDate && <button className="text-xs text-primary-600 hover:underline" onClick={() => run(() => returnPpe(r.id))}>Mark returned</button>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{canIssue && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<select className={INPUT} value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })}>{PPE_ITEMS.map((i) => <option key={i} value={i}>{label(i)}</option>)}</select>
|
||||
<input className={INPUT} placeholder="Size" value={f.size} onChange={(e) => setF({ ...f, size: e.target.value })} />
|
||||
<input className={INPUT} type="number" min={1} placeholder="Qty" value={f.quantity} onChange={(e) => setF({ ...f, quantity: e.target.value })} />
|
||||
<input className={INPUT} placeholder="Comment" value={f.comment} onChange={(e) => setF({ ...f, comment: e.target.value })} />
|
||||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Issuing…" : "Issue PPE"}</button></div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ExperienceTab({ crewId, rows, ranks, canEdit, onDone }: { crewId: string; rows: Exp[]; ranks: { id: string; name: string }[]; canEdit: boolean; onDone: () => void }) {
|
||||
const { pending, error, run } = useRun(onDone);
|
||||
const [f, setF] = useState({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" });
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("crewMemberId", crewId);
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
run(() => addExperience(fd), () => setF({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" }));
|
||||
}
|
||||
return (
|
||||
<Section>
|
||||
{rows.length === 0 ? <p className="text-sm text-neutral-400">No experience records.</p> : rows.map((r) => (
|
||||
<div key={r.id} className="border-b border-neutral-50 last:border-0 py-2">
|
||||
<p className="text-sm text-neutral-900">{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}</p>
|
||||
<p className="text-xs text-neutral-500">{fmtDate(r.fromDate)} – {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}</p>
|
||||
</div>
|
||||
))}
|
||||
{canEdit && (
|
||||
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })}><option value="">Rank…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select>
|
||||
<input className={INPUT} placeholder="Vessel type" value={f.vesselType} onChange={(e) => setF({ ...f, vesselType: e.target.value })} />
|
||||
<label className="text-xs text-neutral-500">From<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} /></label>
|
||||
<label className="text-xs text-neutral-500">To<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} /></label>
|
||||
<input className={INPUT} type="number" min={0} placeholder="Duration (months)" value={f.durationMonths} onChange={(e) => setF({ ...f, durationMonths: e.target.value })} />
|
||||
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add experience"}</button></div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
|
||||
return (
|
||||
<Section>
|
||||
{!paystatus.showSalary ? (
|
||||
<p className="text-sm text-neutral-500">Net pay is visible to office roles only. Site staff see pay <em>status</em> once monthly wage reports are generated.</p>
|
||||
) : paystatus.salary ? (
|
||||
<>
|
||||
<Row k="Basic" v={`${paystatus.salary.currency} ${paystatus.salary.basic.toLocaleString("en-IN")} / ${paystatus.salary.rateBasis.toLowerCase()}`} />
|
||||
<Row k="Victualing / day" v={`${paystatus.salary.currency} ${paystatus.salary.victualingPerDay.toLocaleString("en-IN")}`} />
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-400">No salary structure on file.</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOffButton({ assignmentId, crewName }: { assignmentId: string; crewName: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState("");
|
||||
const [remarks, setRemarks] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await signOffCrew(assignmentId, date, remarks);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error);
|
||||
else { setOpen(false); router.push("/crewing/crew"); }
|
||||
}
|
||||
|
||||
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">Sign off</button>
|
||||
<AdminDialog title={`Sign off ${crewName}`} open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<p className="text-sm text-neutral-600">Ends this tour: the assignment closes, a tour record is added to Experience, and the crew member returns to the Candidates pool as an ex-hand. A backfill requisition is auto-raised.</p>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Sign-off date *</label>
|
||||
<input type="date" className={INPUT} value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Remarks</label>
|
||||
<input className={INPUT} value={remarks} onChange={(e) => setRemarks(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">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !date} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Signing off…" : "Sign off"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal file
113
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { CrewProfile } from "./crew-profile";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Crew profile" };
|
||||
|
||||
export default async function CrewProfilePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
if (!hasPermission(role, "view_crew_records")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const c = await db.crewMember.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
currentRank: { select: { name: true } },
|
||||
documents: { orderBy: { createdAt: "desc" } },
|
||||
bankDetail: true,
|
||||
epfDetail: true,
|
||||
nextOfKin: { orderBy: { createdAt: "asc" } },
|
||||
ppeIssues: { orderBy: { issuedDate: "desc" } },
|
||||
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
||||
assignments: {
|
||||
where: { status: { not: "SIGNED_OFF" } },
|
||||
orderBy: { signOnDate: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
vessel: { select: { name: true } },
|
||||
site: { select: { name: true } },
|
||||
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!c) notFound();
|
||||
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
|
||||
|
||||
const assignment = c.assignments[0] ?? null;
|
||||
const showSalary = canViewSalary(role);
|
||||
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
|
||||
|
||||
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
||||
|
||||
const appraisals = await db.appraisal.findMany({
|
||||
where: { assignment: { crewMemberId: c.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, period: true, status: true, comments: true, ratings: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<CrewProfile
|
||||
crew={{
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
employeeId: c.employeeId ?? "—",
|
||||
rank: c.currentRank?.name ?? "—",
|
||||
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
|
||||
status: assignment?.status ?? null,
|
||||
}}
|
||||
documents={c.documents.map((d) => ({
|
||||
id: d.id,
|
||||
docType: d.docType,
|
||||
number: documentNumberValue(d.number, d.docType, role),
|
||||
issueDate: d.issueDate?.toISOString() ?? null,
|
||||
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||
verificationStatus: d.verificationStatus,
|
||||
hasFile: Boolean(d.fileKey),
|
||||
}))}
|
||||
bank={{
|
||||
accountName: c.bankDetail?.accountName ?? null,
|
||||
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
|
||||
ifsc: c.bankDetail?.ifsc ?? null,
|
||||
bankName: c.bankDetail?.bankName ?? null,
|
||||
}}
|
||||
epf={{
|
||||
uan: c.epfDetail?.uan ?? null,
|
||||
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
|
||||
pfNumber: c.epfDetail?.pfNumber ?? null,
|
||||
}}
|
||||
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
|
||||
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
|
||||
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
|
||||
paystatus={{
|
||||
showSalary,
|
||||
salary: showSalary && currentSalary
|
||||
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
|
||||
: null,
|
||||
}}
|
||||
ranks={ranks}
|
||||
perms={{
|
||||
editRecords: hasPermission(role, "upload_crew_records"),
|
||||
issuePpe: hasPermission(role, "issue_ppe"),
|
||||
}}
|
||||
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
|
||||
appraisals={appraisals.map((a) => ({
|
||||
id: a.id,
|
||||
period: a.period,
|
||||
status: a.status,
|
||||
comments: a.comments,
|
||||
ratings: (a.ratings ?? null) as { competence: number | null; conduct: number | null; safety: number | null } | null,
|
||||
}))}
|
||||
appraisalCtx={{ assignmentId: assignment?.id ?? null, canRaise: hasPermission(role, "raise_appraisal") && Boolean(assignment) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
326
App/app/(portal)/crewing/crew/actions.ts
Normal file
326
App/app/(portal)/crewing/crew/actions.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"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 { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
|
||||
import { SeafarerDocType, PpeItem } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
// Whole months between two dates (floored), min 0 — for the experience record.
|
||||
function monthsBetween(from: Date, to: Date): number {
|
||||
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0);
|
||||
return Math.max(0, months);
|
||||
}
|
||||
|
||||
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||
|
||||
const crewPath = (id: string) => `/crewing/crew/${id}`;
|
||||
|
||||
async function guard(permission: Permission): Promise<{ error: string } | { userId: string }> {
|
||||
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 };
|
||||
}
|
||||
|
||||
async function requireCrew(id: string) {
|
||||
return db.crewMember.findUnique({ where: { id }, select: { id: true } });
|
||||
}
|
||||
|
||||
// ── Documents ──────────────────────────────────────────────────────────────
|
||||
|
||||
const docSchema = z.object({
|
||||
crewMemberId: z.string().min(1),
|
||||
docType: z.nativeEnum(SeafarerDocType),
|
||||
number: z.string().optional(),
|
||||
issueDate: z.string().optional(),
|
||||
expiryDate: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function uploadDocument(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = docSchema.safeParse({
|
||||
crewMemberId: formData.get("crewMemberId"),
|
||||
docType: formData.get("docType"),
|
||||
number: (formData.get("number") as string) || undefined,
|
||||
issueDate: (formData.get("issueDate") as string) || undefined,
|
||||
expiryDate: (formData.get("expiryDate") as string) || undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||
|
||||
let fileKey: string | null = null;
|
||||
const file = formData.get("file");
|
||||
if (file instanceof File && file.size > 0) {
|
||||
fileKey = buildStorageKey("crew-document", d.crewMemberId, file.name);
|
||||
await uploadBuffer(fileKey, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||
}
|
||||
|
||||
await db.seafarerDocument.create({
|
||||
data: {
|
||||
crewMemberId: d.crewMemberId,
|
||||
docType: d.docType,
|
||||
number: d.number ?? null,
|
||||
fileKey,
|
||||
issueDate: d.issueDate ? new Date(d.issueDate) : null,
|
||||
expiryDate: d.expiryDate ? new Date(d.expiryDate) : null,
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "DOCUMENT_UPLOADED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { docType: d.docType } } });
|
||||
|
||||
revalidatePath(crewPath(d.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteDocument(id: string): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
|
||||
if (!doc) return { error: "Document not found" };
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.seafarerDocument.delete({ where: { id } });
|
||||
await tx.crewAction.create({
|
||||
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
|
||||
});
|
||||
});
|
||||
revalidatePath(crewPath(doc.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Bank & EPF ───────────────────────────────────────────────────────────────
|
||||
|
||||
const bankEpfSchema = z.object({
|
||||
crewMemberId: z.string().min(1),
|
||||
accountName: z.string().optional(),
|
||||
accountNumber: z.string().optional(),
|
||||
ifsc: z.string().optional(),
|
||||
bankName: z.string().optional(),
|
||||
uan: z.string().optional(),
|
||||
aadhaarLast4: z.string().optional(),
|
||||
pfNumber: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function saveBankEpf(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = bankEpfSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.bankDetail.upsert({
|
||||
where: { crewMemberId: d.crewMemberId },
|
||||
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||
create: { crewMemberId: d.crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||
});
|
||||
await tx.epfDetail.upsert({
|
||||
where: { crewMemberId: d.crewMemberId },
|
||||
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||
create: { crewMemberId: d.crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||
});
|
||||
await tx.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "bank_epf" } } });
|
||||
});
|
||||
|
||||
revalidatePath(crewPath(d.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Next of kin / emergency ────────────────────────────────────────────────
|
||||
|
||||
const nokSchema = z.object({
|
||||
crewMemberId: z.string().min(1),
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
relationship: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
isEmergency: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function addNextOfKin(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = nokSchema.safeParse({
|
||||
crewMemberId: formData.get("crewMemberId"),
|
||||
name: formData.get("name"),
|
||||
relationship: (formData.get("relationship") as string) || undefined,
|
||||
phone: (formData.get("phone") as string) || undefined,
|
||||
address: (formData.get("address") as string) || undefined,
|
||||
isEmergency: formData.get("isEmergency") === "on" || formData.get("isEmergency") === "true",
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||
|
||||
await db.nextOfKin.create({
|
||||
data: {
|
||||
crewMemberId: d.crewMemberId,
|
||||
name: d.name,
|
||||
relationship: d.relationship ?? null,
|
||||
phone: d.phone ?? null,
|
||||
address: d.address ?? null,
|
||||
isEmergency: d.isEmergency ?? false,
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "next_of_kin" } } });
|
||||
|
||||
revalidatePath(crewPath(d.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteNextOfKin(id: string): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
||||
if (!nok) return { error: "Record not found" };
|
||||
await db.$transaction(async (tx) => {
|
||||
await tx.nextOfKin.delete({ where: { id } });
|
||||
await tx.crewAction.create({
|
||||
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
|
||||
});
|
||||
});
|
||||
revalidatePath(crewPath(nok.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── PPE ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ppeSchema = z.object({
|
||||
crewMemberId: z.string().min(1),
|
||||
item: z.nativeEnum(PpeItem),
|
||||
size: z.string().optional(),
|
||||
quantity: z.coerce.number().int().min(1).default(1),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function issuePpe(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("issue_ppe");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = ppeSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||
|
||||
await db.ppeIssue.create({
|
||||
data: { crewMemberId: d.crewMemberId, item: d.item, size: d.size ?? null, quantity: d.quantity, comment: d.comment ?? null, issuedById: g.userId },
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "PPE_ISSUED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { item: d.item } } });
|
||||
|
||||
revalidatePath(crewPath(d.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function returnPpe(id: string): Promise<ActionResult> {
|
||||
const g = await guard("issue_ppe");
|
||||
if ("error" in g) return g;
|
||||
const ppe = await db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, returnedDate: true } });
|
||||
if (!ppe) return { error: "PPE record not found" };
|
||||
if (ppe.returnedDate) return { error: "Already returned" };
|
||||
await db.ppeIssue.update({ where: { id }, data: { returnedDate: new Date() } });
|
||||
await db.crewAction.create({ data: { actionType: "PPE_RETURNED", actorId: g.userId, crewMemberId: ppe.crewMemberId } });
|
||||
revalidatePath(crewPath(ppe.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Experience ─────────────────────────────────────────────────────────────
|
||||
|
||||
const expSchema = z.object({
|
||||
crewMemberId: z.string().min(1),
|
||||
vesselType: z.string().optional(),
|
||||
rankId: z.string().optional(),
|
||||
fromDate: z.string().optional(),
|
||||
toDate: z.string().optional(),
|
||||
durationMonths: z.coerce.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export async function addExperience(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("upload_crew_records");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = expSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||
|
||||
await db.experienceRecord.create({
|
||||
data: {
|
||||
crewMemberId: d.crewMemberId,
|
||||
vesselType: d.vesselType ?? null,
|
||||
rankId: d.rankId || null,
|
||||
fromDate: d.fromDate ? new Date(d.fromDate) : null,
|
||||
toDate: d.toDate ? new Date(d.toDate) : null,
|
||||
durationMonths: d.durationMonths ?? null,
|
||||
source: "declared",
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "EXPERIENCE_ADDED", actorId: g.userId, crewMemberId: d.crewMemberId } });
|
||||
|
||||
revalidatePath(crewPath(d.crewMemberId));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Sign off (Phase 4c, Epic K) ────────────────────────────────────────────────
|
||||
// Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD,
|
||||
// flip the crew member back to EX_HAND (so they return to the Candidates pool), and
|
||||
// auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper).
|
||||
|
||||
export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("sign_off_crew");
|
||||
if ("error" in g) return g;
|
||||
if (!signOffDate) return { error: "A sign-off date is required" };
|
||||
|
||||
const assignment = await db.crewAssignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||
});
|
||||
if (!assignment) return { error: "Assignment not found" };
|
||||
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" };
|
||||
|
||||
const off = new Date(signOffDate);
|
||||
|
||||
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
|
||||
// seat can never become vacant without its backfill being raised.
|
||||
const backfill = await db.$transaction(async (tx) => {
|
||||
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
|
||||
await tx.experienceRecord.create({
|
||||
data: {
|
||||
crewMemberId: assignment.crewMemberId,
|
||||
rankId: assignment.rankId,
|
||||
vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null,
|
||||
fromDate: assignment.signOnDate,
|
||||
toDate: off,
|
||||
durationMonths: monthsBetween(assignment.signOnDate, off),
|
||||
source: "internal",
|
||||
},
|
||||
});
|
||||
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
|
||||
await tx.crewMember.update({
|
||||
where: { id: assignment.crewMemberId },
|
||||
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
|
||||
});
|
||||
await tx.crewAction.create({
|
||||
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
||||
});
|
||||
return autoRaiseRequisition(
|
||||
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
|
||||
tx
|
||||
);
|
||||
});
|
||||
// Notify the office after the transaction commits.
|
||||
await notifyAutoRaised(backfill);
|
||||
|
||||
revalidatePath(crewPath(assignment.crewMemberId));
|
||||
revalidatePath("/crewing/crew");
|
||||
return { ok: true };
|
||||
}
|
||||
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal file
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { AssignmentStatus } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type CrewRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
employeeId: string;
|
||||
rank: string;
|
||||
location: string;
|
||||
status: AssignmentStatus | null;
|
||||
};
|
||||
|
||||
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 StatusBadge({ status }: { status: AssignmentStatus | null }) {
|
||||
if (status === "ACTIVE") return <Badge variant="success">Active</Badge>;
|
||||
if (status === "ON_LEAVE") return <Badge variant="warning">On leave</Badge>;
|
||||
return <Badge variant="secondary">—</Badge>;
|
||||
}
|
||||
|
||||
export function CrewDirectory({ crew }: { crew: CrewRow[] }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [location, setLocation] = useState("ALL");
|
||||
|
||||
const locations = useMemo(
|
||||
() => Array.from(new Set(crew.map((c) => c.location).filter((l) => l !== "—"))).sort(),
|
||||
[crew]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return crew.filter((c) => {
|
||||
if (location !== "ALL" && c.location !== location) return false;
|
||||
if (q && !`${c.name} ${c.employeeId} ${c.rank}`.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [crew, search, location]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Crew</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} active crew member{crew.length === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name, employee no or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<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>
|
||||
|
||||
<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">Employee</th>
|
||||
<th className="px-4 py-3">Rank</th>
|
||||
<th className="px-4 py-3">Vessel / site</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">
|
||||
{crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."}
|
||||
</td></tr>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<tr key={c.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/crewing/crew/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{c.rank}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{c.location}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
App/app/(portal)/crewing/crew/page.tsx
Normal file
55
App/app/(portal)/crewing/crew/page.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
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 { CrewDirectory } from "./crew-directory";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Crew" };
|
||||
|
||||
export default async function CrewPage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
|
||||
|
||||
// Own-site scoping (§8.7): a site-staff user with a home site sees only crew whose
|
||||
// active assignment is at that site. Without a home site they remain unscoped.
|
||||
let siteScopeId: string | null = null;
|
||||
if (session.user.role === "SITE_STAFF") {
|
||||
siteScopeId = (await db.user.findUnique({ where: { id: session.user.id }, select: { siteId: true } }))?.siteId ?? null;
|
||||
}
|
||||
|
||||
const crew = await db.crewMember.findMany({
|
||||
where: {
|
||||
status: "EMPLOYEE",
|
||||
...(siteScopeId ? { assignments: { some: { status: { not: "SIGNED_OFF" }, siteId: siteScopeId } } } : {}),
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
currentRank: { select: { name: true } },
|
||||
assignments: {
|
||||
where: { status: { not: "SIGNED_OFF" } },
|
||||
orderBy: { signOnDate: "desc" },
|
||||
take: 1,
|
||||
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = crew.map((c) => {
|
||||
const a = c.assignments[0];
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
employeeId: c.employeeId ?? "—",
|
||||
rank: c.currentRank?.name ?? "—",
|
||||
location: a?.vessel?.name ?? a?.site?.name ?? "—",
|
||||
status: a?.status ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return <CrewDirectory crew={rows} />;
|
||||
}
|
||||
138
App/app/(portal)/crewing/leave/actions.ts
Normal file
138
App/app/(portal)/crewing/leave/actions.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"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 { leaveCausesClash } from "@/lib/leave-clash";
|
||||
import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service";
|
||||
import { notifyCrew } from "@/lib/notifier";
|
||||
import { LeaveType } 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 LEAVE_PATH = "/crewing/leave";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function revalidate() {
|
||||
revalidatePath(LEAVE_PATH);
|
||||
revalidatePath("/approvals");
|
||||
}
|
||||
|
||||
// ── Apply for leave (Site staff, on behalf of a crew member) ───────────────────
|
||||
|
||||
const applySchema = z
|
||||
.object({
|
||||
assignmentId: z.string().min(1, "Crew member is required"),
|
||||
type: z.nativeEnum(LeaveType).default("ANNUAL"),
|
||||
fromDate: z.string().min(1, "From date is required"),
|
||||
toDate: z.string().min(1, "To date is required"),
|
||||
reason: z.string().optional(),
|
||||
})
|
||||
.refine((d) => new Date(d.toDate) >= new Date(d.fromDate), { message: "To date must be on or after the from date" });
|
||||
|
||||
export async function applyLeave(formData: FormData): Promise<ActionResult> {
|
||||
const g = await guard("apply_leave");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = applySchema.safeParse({
|
||||
assignmentId: formData.get("assignmentId"),
|
||||
type: (formData.get("type") as string) || undefined,
|
||||
fromDate: formData.get("fromDate"),
|
||||
toDate: formData.get("toDate"),
|
||||
reason: (formData.get("reason") as string) || undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const d = parsed.data;
|
||||
|
||||
const assignment = await db.crewAssignment.findUnique({
|
||||
where: { id: d.assignmentId },
|
||||
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
|
||||
});
|
||||
if (!assignment) return { error: "Crew assignment not found" };
|
||||
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has signed off" };
|
||||
|
||||
const leave = await db.leaveRequest.create({
|
||||
data: {
|
||||
assignmentId: d.assignmentId,
|
||||
type: d.type,
|
||||
fromDate: new Date(d.fromDate),
|
||||
toDate: new Date(d.toDate),
|
||||
reason: d.reason ?? null,
|
||||
appliedById: g.userId,
|
||||
},
|
||||
});
|
||||
await db.crewAction.create({ data: { actionType: "LEAVE_APPLIED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
|
||||
|
||||
const managers = await getManagerRecipients();
|
||||
await notifyCrew({
|
||||
event: "LEAVE_FOR_APPROVAL",
|
||||
recipients: managers,
|
||||
subject: `Leave for approval — ${assignment.crewMember.name}`,
|
||||
body: `${assignment.crewMember.name} (${assignment.rank.name}) has a leave request from ${d.fromDate} to ${d.toDate} awaiting your decision.`,
|
||||
link: LEAVE_PATH,
|
||||
});
|
||||
|
||||
revalidate();
|
||||
return { ok: true, id: leave.id };
|
||||
}
|
||||
|
||||
// ── Decide leave (Manager) ─────────────────────────────────────────────────────
|
||||
// On approval the assignment goes ON_LEAVE and a clash check runs; if it would
|
||||
// leave the vessel with no same-rank cover, a LEAVE requisition is auto-raised.
|
||||
|
||||
export async function decideLeave(id: string, approve: boolean, note?: string): Promise<ActionResult> {
|
||||
const g = await guard("decide_leave");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const leave = await db.leaveRequest.findUnique({
|
||||
where: { id },
|
||||
include: { assignment: { select: { id: true, crewMemberId: true, rankId: true, vesselId: true, siteId: true } } },
|
||||
});
|
||||
if (!leave) return { error: "Leave request not found" };
|
||||
if (leave.status !== "APPLIED") return { error: `This leave request is already ${leave.status}` };
|
||||
if (!approve && !note?.trim()) return { error: "A reason is required to decline" };
|
||||
|
||||
if (!approve) {
|
||||
await db.leaveRequest.update({ where: { id }, data: { status: "REJECTED", decidedById: g.userId, decidedAt: new Date(), reason: note?.trim() || leave.reason } });
|
||||
await db.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, note: note?.trim() || null, metadata: { decision: "REJECTED" } } });
|
||||
revalidate();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Leave approval + the clash check + any backfill requisition commit atomically
|
||||
// (spec §5.3/§11): an approved leave can never leave a cover gap un-raised.
|
||||
const backfill = await db.$transaction(async (tx) => {
|
||||
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
|
||||
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
|
||||
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
|
||||
const clash = await leaveCausesClash(tx, {
|
||||
assignmentId: leave.assignment.id,
|
||||
rankId: leave.assignment.rankId,
|
||||
vesselId: leave.assignment.vesselId,
|
||||
fromDate: leave.fromDate,
|
||||
toDate: leave.toDate,
|
||||
});
|
||||
if (!clash) return null;
|
||||
return autoRaiseRequisition(
|
||||
{ rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" },
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
// Notify the office after the transaction commits.
|
||||
if (backfill) await notifyAutoRaised(backfill);
|
||||
|
||||
revalidate();
|
||||
return { ok: true };
|
||||
}
|
||||
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal file
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { LeaveStatus, LeaveType } from "@prisma/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { applyLeave, decideLeave } 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";
|
||||
const LEAVE_TYPES: LeaveType[] = ["ANNUAL", "MEDICAL", "EMERGENCY", "UNPAID", "OTHER"];
|
||||
const fmt = (iso: string) => new Date(iso).toLocaleDateString();
|
||||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
|
||||
type Assignment = { id: string; crewName: string; rank: string; location: string };
|
||||
type Request = { id: string; crewName: string; rank: string; location: string; type: LeaveType; status: LeaveStatus; fromDate: string; toDate: string; reason: string | null };
|
||||
|
||||
const STATUS_VARIANT: Record<LeaveStatus, "warning" | "success" | "danger" | "secondary"> = {
|
||||
APPLIED: "warning", APPROVED: "success", REJECTED: "danger", CANCELLED: "secondary",
|
||||
};
|
||||
|
||||
export function LeaveManager({ assignments, requests, canApply, canDecide }: { assignments: Assignment[]; requests: Request[]; canApply: boolean; canDecide: boolean }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [f, setF] = useState({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" });
|
||||
|
||||
const duration = f.fromDate && f.toDate ? Math.max(0, Math.round((new Date(f.toDate).getTime() - new Date(f.fromDate).getTime()) / 86400000) + 1) : 0;
|
||||
|
||||
async function submitApply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||
const res = await applyLeave(fd);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error);
|
||||
else { setOpen(false); setF({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" }); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Leave</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Site staff apply on behalf of crew · the Manager approves.</p>
|
||||
</div>
|
||||
{canApply && <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">Apply for leave</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">Crew</th>
|
||||
<th className="px-4 py-3">Rank / location</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Dates</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{requests.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-12 text-center text-neutral-400">No leave requests.</td></tr>
|
||||
) : requests.map((r) => (
|
||||
<DecisionRow key={r.id} r={r} canDecide={canDecide} onDone={() => router.refresh()} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AdminDialog title="Apply for leave" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={submitApply} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Crew member *</label>
|
||||
<select className={INPUT} value={f.assignmentId} onChange={(e) => setF({ ...f, assignmentId: e.target.value })} required>
|
||||
<option value="">— Select crew —</option>
|
||||
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Type</label>
|
||||
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
|
||||
{LEAVE_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div></div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">From *</label>
|
||||
<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">To *</label>
|
||||
<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} required />
|
||||
</div>
|
||||
</div>
|
||||
{duration > 0 && <p className="text-xs text-neutral-500 bg-neutral-50 rounded-md px-3 py-2">{duration} day{duration === 1 ? "" : "s"} of leave.</p>}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||
<input className={INPUT} value={f.reason} onChange={(e) => setF({ ...f, reason: 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">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !f.assignmentId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Applying…" : "Apply"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DecisionRow({ r, canDecide, onDone }: { r: Request; canDecide: boolean; onDone: () => void }) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [declineOpen, setDeclineOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
async function approve() {
|
||||
setPending(true); setError("");
|
||||
const res = await decideLeave(r.id, true);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else onDone();
|
||||
}
|
||||
async function decline(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await decideLeave(r.id, false, reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setDeclineOpen(false); onDone(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{r.rank} · {r.location}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{label(r.type)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{fmt(r.fromDate)} – {fmt(r.toDate)}</td>
|
||||
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[r.status]}>{label(r.status)}</Badge></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{r.status === "APPLIED" && (canDecide ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
|
||||
<button onClick={() => setDeclineOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Decline</button>
|
||||
</div>
|
||||
) : <span className="text-xs text-neutral-400">Awaiting manager</span>)}
|
||||
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||
<AdminDialog title="Decline leave" open={declineOpen} onClose={() => setDeclineOpen(false)}>
|
||||
<form onSubmit={decline} className="space-y-4 text-left">
|
||||
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setDeclineOpen(false)}>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">Decline</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
52
App/app/(portal)/crewing/leave/page.tsx
Normal file
52
App/app/(portal)/crewing/leave/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 { LeaveManager } from "./leave-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Leave" };
|
||||
|
||||
export default async function LeavePage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
const canApply = hasPermission(role, "apply_leave");
|
||||
const canDecide = hasPermission(role, "decide_leave");
|
||||
if (!canApply && !canDecide) redirect("/dashboard"); // MPO has no leave screen (R1)
|
||||
|
||||
const [assignments, requests] = await Promise.all([
|
||||
db.crewAssignment.findMany({
|
||||
where: { status: { not: "SIGNED_OFF" } },
|
||||
orderBy: { crewMember: { name: "asc" } },
|
||||
include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||
}),
|
||||
db.leaveRequest.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<LeaveManager
|
||||
assignments={assignments.map((a) => ({ id: a.id, crewName: a.crewMember.name, rank: a.rank.name, location: a.vessel?.name ?? a.site?.name ?? "—" }))}
|
||||
requests={requests.map((r) => ({
|
||||
id: r.id,
|
||||
crewName: r.assignment.crewMember.name,
|
||||
rank: r.assignment.rank.name,
|
||||
location: r.assignment.vessel?.name ?? r.assignment.site?.name ?? "—",
|
||||
type: r.type,
|
||||
status: r.status,
|
||||
fromDate: r.fromDate.toISOString(),
|
||||
toDate: r.toDate.toISOString(),
|
||||
reason: r.reason,
|
||||
}))}
|
||||
canApply={canApply}
|
||||
canDecide={canDecide}
|
||||
/>
|
||||
);
|
||||
}
|
||||
138
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
138
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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 } } } },
|
||||
_count: { select: { applications: 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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/crewing/requisitions/${req.id}/pipeline`}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
Open pipeline
|
||||
</Link>
|
||||
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
|
||||
</div>
|
||||
</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 — the 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">Candidates</h2>
|
||||
</div>
|
||||
<div className="px-4 py-8 text-center">
|
||||
<p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
|
||||
<p className="text-sm text-neutral-500 mt-0.5 mb-4">
|
||||
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
|
||||
</p>
|
||||
<Link href={`/crewing/requisitions/${req.id}/pipeline`} className="text-sm font-medium text-primary-600 hover:underline">
|
||||
Open recruitment pipeline →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal file
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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 { PipelineBoard } from "./pipeline-board";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Recruitment pipeline" };
|
||||
|
||||
export default async function PipelinePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
if (!hasPermission(role, "view_requisitions")) redirect("/dashboard");
|
||||
|
||||
const { id } = await params;
|
||||
const requisition = await db.requisition.findUnique({
|
||||
where: { id },
|
||||
include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||
});
|
||||
if (!requisition) notFound();
|
||||
|
||||
const applications = await db.application.findMany({
|
||||
where: { requisitionId: id },
|
||||
include: { crewMember: { select: { id: true, name: true, type: true, experienceMonths: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
const canManage = hasPermission(role, "manage_candidates");
|
||||
// Candidates available to add: in the pool (not employees) and not already applied here.
|
||||
const appliedIds = new Set(applications.map((a) => a.crewMemberId));
|
||||
const pool = canManage
|
||||
? (await db.crewMember.findMany({
|
||||
where: { status: { not: "EMPLOYEE" } },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true, type: true },
|
||||
})).filter((c) => !appliedIds.has(c.id))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<PipelineBoard
|
||||
requisition={{
|
||||
id: requisition.id,
|
||||
code: requisition.code,
|
||||
rank: requisition.rank.name,
|
||||
location: requisition.vessel?.name ?? requisition.site?.name ?? "—",
|
||||
status: requisition.status,
|
||||
}}
|
||||
applications={applications.map((a) => ({
|
||||
id: a.id,
|
||||
stage: a.stage,
|
||||
crewName: a.crewMember.name,
|
||||
isExHand: a.crewMember.type === "EX_HAND",
|
||||
experienceMonths: a.crewMember.experienceMonths,
|
||||
}))}
|
||||
pool={pool}
|
||||
canManage={canManage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { ApplicationStage, RequisitionStatus } from "@prisma/client";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { STAGE_ORDER, STAGE_LABEL } from "../../../applications/application-ui";
|
||||
import { addApplication } from "../../../applications/actions";
|
||||
|
||||
type AppCard = { id: string; stage: ApplicationStage; crewName: string; isExHand: boolean; experienceMonths: number };
|
||||
type PoolItem = { id: string; name: string; type: string };
|
||||
|
||||
export function PipelineBoard({
|
||||
requisition,
|
||||
applications,
|
||||
pool,
|
||||
canManage,
|
||||
}: {
|
||||
requisition: { id: string; code: string; rank: string; location: string; status: RequisitionStatus };
|
||||
applications: AppCard[];
|
||||
pool: PoolItem[];
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [crewMemberId, setCrewMemberId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function add(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const fd = new FormData();
|
||||
fd.set("requisitionId", requisition.id);
|
||||
fd.set("crewMemberId", crewMemberId);
|
||||
const res = await addApplication(fd);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error);
|
||||
else { setOpen(false); setCrewMemberId(""); router.refresh(); }
|
||||
}
|
||||
|
||||
const byStage = (s: ApplicationStage) => applications.filter((a) => a.stage === s);
|
||||
const rejected = applications.filter((a) => a.stage === "REJECTED");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link href={`/crewing/requisitions/${requisition.id}`} 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" /> Requisition
|
||||
</Link>
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">{requisition.rank} — {requisition.location}</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Recruitment pipeline · <span className="font-mono">{requisition.code}</span> · {applications.length} candidate{applications.length === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button onClick={() => setOpen(true)} className="rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-50">
|
||||
+ Add candidate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{STAGE_ORDER.map((s) => {
|
||||
const cards = byStage(s);
|
||||
return (
|
||||
<div key={s} className="w-56 shrink-0">
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">{STAGE_LABEL[s]}</span>
|
||||
<span className="text-xs text-neutral-400">{cards.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2 min-h-[60px] rounded-lg bg-neutral-50 p-2">
|
||||
{cards.map((a) => (
|
||||
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="block rounded-md border border-neutral-200 bg-white p-3 hover:border-primary-300 hover:shadow-sm transition">
|
||||
<p className="text-sm font-medium text-neutral-900">{a.crewName}</p>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
{Math.floor(a.experienceMonths / 12)} yrs
|
||||
{a.isExHand && <span className="ml-1 text-purple-600">· ex-hand</span>}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
{cards.length === 0 && <p className="text-center text-xs text-neutral-300 py-2">—</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{rejected.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<p className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Rejected ({rejected.length})</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rejected.map((a) => (
|
||||
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-50">
|
||||
{a.crewName}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminDialog title="Add candidate to pipeline" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={add} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Candidate</label>
|
||||
<select className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" value={crewMemberId} onChange={(e) => setCrewMemberId(e.target.value)} required>
|
||||
<option value="">— Select from the pool —</option>
|
||||
{pool.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}{c.type === "EX_HAND" ? " (ex-hand)" : ""}</option>
|
||||
))}
|
||||
</select>
|
||||
{pool.length === 0 && <p className="mt-1 text-xs text-neutral-400">No available candidates. Add candidates from the Candidates page first.</p>}
|
||||
</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">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="submit" disabled={pending || !crewMemberId} 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 to pipeline"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</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 };
|
||||
}
|
||||
80
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
80
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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 } },
|
||||
_count: { select: { applications: 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",
|
||||
candidateCount: r._count.applications,
|
||||
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`;
|
||||
}
|
||||
231
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
231
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
"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;
|
||||
candidateCount: number;
|
||||
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 [rank, setRank] = useState("ALL");
|
||||
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
|
||||
|
||||
const locations = useMemo(
|
||||
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
||||
[requisitions]
|
||||
);
|
||||
const rankNames = useMemo(
|
||||
() => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(),
|
||||
[requisitions]
|
||||
);
|
||||
const reasons = useMemo(
|
||||
() => Array.from(new Set(requisitions.map((r) => r.reason))),
|
||||
[requisitions]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return requisitions.filter((r) => {
|
||||
if (status !== "ALL" && r.status !== status) return false;
|
||||
if (location !== "ALL" && r.location !== location) return false;
|
||||
if (rank !== "ALL" && r.rankName !== rank) return false;
|
||||
if (reason !== "ALL" && r.reason !== reason) return false;
|
||||
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [requisitions, search, status, location, rank, reason]);
|
||||
|
||||
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>
|
||||
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
|
||||
<option value="ALL">All ranks</option>
|
||||
{rankNames.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||
<option value="ALL">All reasons</option>
|
||||
{reasons.map((r) => (
|
||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Requisitions table */}
|
||||
<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">Candidates</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={7} 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-700 tabular-nums">{r.candidateCount}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
165
App/app/(portal)/crewing/verification/actions.ts
Normal file
165
App/app/(portal)/crewing/verification/actions.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"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 type { Role } from "@prisma/client";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
const PATH = "/crewing/verification";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── Document verification (MPO / Manager) ──────────────────────────────────────
|
||||
|
||||
export async function verifyDocument(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_site_records");
|
||||
if ("error" in g) return g;
|
||||
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
|
||||
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } });
|
||||
if (!doc) return { error: "Document not found" };
|
||||
if (doc.verificationStatus !== "PENDING") return { error: `This document is already ${doc.verificationStatus.toLowerCase()}` };
|
||||
|
||||
await db.seafarerDocument.update({
|
||||
where: { id },
|
||||
data: { verificationStatus: approve ? "VERIFIED" : "REJECTED", verifiedById: g.userId },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: {
|
||||
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||
actorId: g.userId,
|
||||
crewMemberId: doc.crewMemberId,
|
||||
note: remarks?.trim() || null,
|
||||
metadata: { record: "document" },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${doc.crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Bank / EPF verification (Accounts) ─────────────────────────────────────────
|
||||
|
||||
export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf", approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_bank_epf");
|
||||
if ("error" in g) return g;
|
||||
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
|
||||
const status = approve ? "VERIFIED" : "REJECTED";
|
||||
if (kind === "bank") {
|
||||
const rec = await db.bankDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||
if (!rec) return { error: "Bank details not found" };
|
||||
if (rec.verificationStatus !== "PENDING") return { error: `Bank details already ${rec.verificationStatus.toLowerCase()}` };
|
||||
await db.bankDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||
} else {
|
||||
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||
if (!rec) return { error: "EPF details not found" };
|
||||
if (rec.verificationStatus !== "PENDING") return { error: `EPF details already ${rec.verificationStatus.toLowerCase()}` };
|
||||
await db.epfDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||
}
|
||||
|
||||
await db.crewAction.create({
|
||||
data: {
|
||||
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||
actorId: g.userId,
|
||||
crewMemberId,
|
||||
note: remarks?.trim() || null,
|
||||
metadata: { record: kind },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── PPE / next-of-kin verification (MPO) ───────────────────────────────────────
|
||||
|
||||
async function verifyRecord(
|
||||
load: () => Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>,
|
||||
set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise<unknown>,
|
||||
recordLabel: string,
|
||||
approve: boolean,
|
||||
remarks: string | undefined,
|
||||
userId: string
|
||||
): Promise<ActionResult> {
|
||||
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||
const rec = await load();
|
||||
if (!rec) return { error: "Record not found" };
|
||||
if (rec.verificationStatus !== "PENDING") return { error: `This record is already ${rec.verificationStatus.toLowerCase()}` };
|
||||
|
||||
await set(approve ? "VERIFIED" : "REJECTED", userId);
|
||||
await db.crewAction.create({
|
||||
data: { actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED", actorId: userId, crewMemberId: rec.crewMemberId, note: remarks?.trim() || null, metadata: { record: recordLabel } },
|
||||
});
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${rec.crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function verifyPpe(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_site_records");
|
||||
if ("error" in g) return g;
|
||||
return verifyRecord(
|
||||
() => db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
|
||||
(status, userId) => db.ppeIssue.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
|
||||
"ppe",
|
||||
approve,
|
||||
remarks,
|
||||
g.userId
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyNextOfKin(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||
const g = await guard("verify_site_records");
|
||||
if ("error" in g) return g;
|
||||
return verifyRecord(
|
||||
() => db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
|
||||
(status, userId) => db.nextOfKin.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
|
||||
"next_of_kin",
|
||||
approve,
|
||||
remarks,
|
||||
g.userId
|
||||
);
|
||||
}
|
||||
|
||||
// ── EPFO assisted lookup (Accounts) ────────────────────────────────────────────
|
||||
// Records the result of an EpfoService UAN check on the crew member's EpfDetail
|
||||
// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo;
|
||||
// this just persists the returned member name + a timestamp for the audit trail.
|
||||
|
||||
export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise<ActionResult> {
|
||||
const g = await guard("verify_bank_epf");
|
||||
if ("error" in g) return g;
|
||||
|
||||
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } });
|
||||
if (!rec) return { error: "EPF details not found" };
|
||||
|
||||
await db.epfDetail.update({
|
||||
where: { crewMemberId },
|
||||
data: { epfoMemberName: memberName, epfoCheckedAt: new Date() },
|
||||
});
|
||||
await db.crewAction.create({
|
||||
data: {
|
||||
actionType: "RECORD_UPDATED",
|
||||
actorId: g.userId,
|
||||
crewMemberId,
|
||||
note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match",
|
||||
metadata: { record: "epfo_check" },
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(PATH);
|
||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
82
App/app/(portal)/crewing/verification/page.tsx
Normal file
82
App/app/(portal)/crewing/verification/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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 { VerificationManager } from "./verification-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Verification" };
|
||||
|
||||
export default async function VerificationPage() {
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
const role = session.user.role;
|
||||
const canDocs = hasPermission(role, "verify_site_records");
|
||||
const canBankEpf = hasPermission(role, "verify_bank_epf");
|
||||
const canAppraisals = hasPermission(role, "verify_appraisal");
|
||||
if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard");
|
||||
|
||||
const [docs, bank, epf, appraisals, ppe, nok] = await Promise.all([
|
||||
canDocs
|
||||
? db.seafarerDocument.findMany({
|
||||
where: { verificationStatus: "PENDING" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
crewMember: {
|
||||
select: {
|
||||
name: true,
|
||||
assignments: { where: { status: { not: "SIGNED_OFF" } }, take: 1, include: { vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [],
|
||||
canBankEpf
|
||||
? db.bankDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
canBankEpf
|
||||
? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
canAppraisals
|
||||
? db.appraisal.findMany({
|
||||
where: { status: "SUBMITTED" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||
})
|
||||
: [],
|
||||
canDocs
|
||||
? db.ppeIssue.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { issuedDate: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
canDocs
|
||||
? db.nextOfKin.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||
: [],
|
||||
]);
|
||||
|
||||
return (
|
||||
<VerificationManager
|
||||
docs={docs.map((d) => {
|
||||
const a = d.crewMember.assignments[0];
|
||||
return {
|
||||
id: d.id,
|
||||
crewName: d.crewMember.name,
|
||||
location: a?.vessel?.name ?? a?.site?.name ?? "—",
|
||||
docType: d.docType,
|
||||
number: d.number,
|
||||
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||
submitted: d.createdAt.toISOString(),
|
||||
};
|
||||
})}
|
||||
bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))}
|
||||
epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))}
|
||||
appraisals={appraisals.map((a) => ({ id: a.id, crewName: a.assignment.crewMember.name, rank: a.assignment.rank.name, period: a.period, comments: a.comments }))}
|
||||
ppe={ppe.map((p) => ({ id: p.id, crewName: p.crewMember.name, item: p.item, size: p.size }))}
|
||||
nok={nok.map((n) => ({ id: n.id, crewName: n.crewMember.name, name: n.name, relationship: n.relationship }))}
|
||||
canDocs={canDocs}
|
||||
canBankEpf={canBankEpf}
|
||||
canAppraisals={canAppraisals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
283
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
283
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { SeafarerDocType } from "@prisma/client";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
|
||||
import { verifyAppraisal } from "../appraisals/actions";
|
||||
import type { PpeItem } from "@prisma/client";
|
||||
|
||||
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
||||
const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Date());
|
||||
|
||||
type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string };
|
||||
type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null };
|
||||
type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null };
|
||||
type Appr = { id: string; crewName: string; rank: string; period: string; comments: string | null };
|
||||
type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null };
|
||||
type Nok = { id: string; crewName: string; name: string; relationship: string | null };
|
||||
|
||||
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||
const router = useRouter();
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
async function verify() {
|
||||
setPending(true); setError("");
|
||||
const res = await onVerify();
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else router.refresh();
|
||||
}
|
||||
async function reject(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPending(true); setError("");
|
||||
const res = await onReject(reason);
|
||||
setPending(false);
|
||||
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={verify} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Verify</button>
|
||||
<button onClick={() => setOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Reject</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||
<AdminDialog title="Reject record" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={reject} className="space-y-4 text-left">
|
||||
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for rejection" />
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>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">Reject</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo,
|
||||
// then record the returned member name onto the EpfDetail (A3). Aadhaar is not
|
||||
// checked here (UIDAI-restricted — stays manual).
|
||||
function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<"start" | "otp" | "result">("start");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [mobileHint, setMobileHint] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
if (!uan) return null;
|
||||
|
||||
function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); }
|
||||
|
||||
async function requestOtp() {
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || "Failed to request OTP");
|
||||
setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp");
|
||||
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
||||
setPending(false);
|
||||
}
|
||||
async function verify() {
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.error || "Lookup failed");
|
||||
setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result");
|
||||
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
||||
setPending(false);
|
||||
}
|
||||
async function record() {
|
||||
setPending(true);
|
||||
await recordEpfoCheck(crewMemberId, result?.name ?? null);
|
||||
setPending(false); setOpen(false); reset(); router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => { reset(); setOpen(true); }} className="rounded-md border border-primary-300 px-3 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50">EPFO check</button>
|
||||
<AdminDialog title="EPFO / UAN check" open={open} onClose={() => setOpen(false)}>
|
||||
<div className="space-y-4 text-left">
|
||||
<p className="text-sm text-neutral-600">Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. <span className="text-neutral-400">(Aadhaar is verified manually — not via this check.)</span></p>
|
||||
<p className="text-xs text-neutral-500">UAN: <span className="font-mono">{uan}</span></p>
|
||||
|
||||
{step === "start" && (
|
||||
<button onClick={requestOtp} 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 ? "Requesting…" : "Request OTP"}</button>
|
||||
)}
|
||||
{step === "otp" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-neutral-500">OTP sent to {mobileHint || "the registered mobile"}.</p>
|
||||
<input className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" placeholder="Enter OTP" value={otp} onChange={(e) => setOtp(e.target.value)} />
|
||||
<button onClick={verify} disabled={pending || !otp} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Checking…" : "Submit OTP"}</button>
|
||||
</div>
|
||||
)}
|
||||
{step === "result" && (
|
||||
<div className="space-y-2">
|
||||
{result?.matched ? (
|
||||
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Matched — EPFO member: <strong>{result.name}</strong></p>
|
||||
) : (
|
||||
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">No matching EPFO member for this UAN.</p>
|
||||
)}
|
||||
<button onClick={record} 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 ? "Recording…" : "Record result"}</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
</div>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
|
||||
<p className="text-xs text-neutral-500 mt-0.5 mb-3">{sub}</p>
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
{empty ? <p className="px-4 py-10 text-center text-sm text-neutral-400">Nothing awaiting verification.</p> : (
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) {
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Verification</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Site-entered records awaiting office verification.</p>
|
||||
</div>
|
||||
|
||||
{canDocs && (
|
||||
<Card title="Documents" sub="Verify or reject crew documents (MPO)." empty={docs.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">Vessel / site</th><th className="px-4 py-3">Document</th><th className="px-4 py-3">Expiry</th><th className="px-4 py-3">Submitted</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{d.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{d.location}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{label(d.docType)}{d.number ? ` · ${d.number}` : ""}</td>
|
||||
<td className="px-4 py-3">{d.expiryDate ? <span className={isExpired(d.expiryDate) ? "text-danger-700 font-medium" : "text-neutral-600"}>{fmt(d.expiryDate)}{isExpired(d.expiryDate) ? " · expired" : ""}</span> : "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-500">{fmt(d.submitted)}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyDocument(d.id, true)} onReject={(r) => verifyDocument(d.id, false, r)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canDocs && (
|
||||
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{ppe.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 font-medium text-neutral-900">{r.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canDocs && (
|
||||
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{nok.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 font-medium text-neutral-900">{r.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canBankEpf && (
|
||||
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">Account</th><th className="px-4 py-3">IFSC</th><th className="px-4 py-3">Bank</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{bank.map((b) => (
|
||||
<tr key={b.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{b.crewName}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{b.accountNumber ?? "—"}{b.accountName ? ` (${b.accountName})` : ""}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{b.ifsc ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{b.bankName ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(b.crewMemberId, "bank", true)} onReject={(r) => verifyBankEpf(b.crewMemberId, "bank", false, r)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canBankEpf && (
|
||||
<Card title="EPF details" sub="Verify or reject crew EPF / identity details (Accounts)." empty={epf.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">UAN</th><th className="px-4 py-3">Aadhaar</th><th className="px-4 py-3">PF no.</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{epf.map((e) => (
|
||||
<tr key={e.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{e.crewName}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<EpfoAssist crewMemberId={e.crewMemberId} uan={e.uan} />
|
||||
<Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{canAppraisals && (
|
||||
<Card title="Appraisals" sub="Verify or reject submitted appraisals (MPO)." empty={appraisals.length === 0}>
|
||||
<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">Crew</th><th className="px-4 py-3">Rank</th><th className="px-4 py-3">Period</th><th className="px-4 py-3">Comments</th><th className="px-4 py-3 w-32"></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{appraisals.map((a) => (
|
||||
<tr key={a.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{a.crewName}</td>
|
||||
<td className="px-4 py-3 text-neutral-600">{a.rank}</td>
|
||||
<td className="px-4 py-3 text-neutral-700">{a.period}</td>
|
||||
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{a.comments ?? "—"}</td>
|
||||
<td className="px-4 py-3"><Actions onVerify={() => verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
App/app/api/epfo/otp/route.ts
Normal file
30
App/app/api/epfo/otp/route.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { auth } from "@/auth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
|
||||
|
||||
/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!hasPermission(session.user.role, "verify_bank_epf")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const res = await fetch(`${EPFO_SERVICE}/otp`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ uan: body.uan }),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
32
App/app/api/epfo/route.ts
Normal file
32
App/app/api/epfo/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { auth } from "@/auth";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
|
||||
|
||||
/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!hasPermission(session.user.role, "verify_bank_epf")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
if (!body.sessionId || !body.uan || !body.otp) {
|
||||
return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${EPFO_SERVICE}/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }),
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,14 @@ import {
|
|||
UserCircle,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
ClipboardList,
|
||||
UserSearch,
|
||||
Contact,
|
||||
CalendarDays,
|
||||
CalendarCheck,
|
||||
UserCog,
|
||||
Gauge,
|
||||
BadgeCheck,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
|
|
@ -69,11 +77,20 @@ const PURCHASING_MGMT: NavItem[] = [
|
|||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||
|
||||
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||
// Scaffold for the Crewing module. Phase 1 (Foundations) adds no top-level items
|
||||
// here — its only screen, "Ranks & documents", lives under Administration. Later
|
||||
// phases append Requisitions / Candidates / Crew / Leave / Attendance /
|
||||
// Verification with their per-role visibility (see Crewing-Implementation-Spec §7).
|
||||
const CREWING_ITEMS: NavItem[] = [];
|
||||
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
||||
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
|
||||
// / Attendance / Verification with their per-role visibility. "Ranks & documents"
|
||||
// lives under Administration.
|
||||
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"] },
|
||||
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
|
||||
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
||||
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
|
||||
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
|
||||
]
|
||||
: [];
|
||||
|
||||
// ── Administration section ────────────────────────────────────────────────────
|
||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||
|
|
@ -82,7 +99,11 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
|||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||
...(CREWING_ENABLED
|
||||
? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }]
|
||||
? [
|
||||
{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] },
|
||||
{ href: "/admin/crew", label: "Crew management", icon: UserCog, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
|
||||
{ href: "/admin/crew-strength", label: "Crew strength", icon: Gauge, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
|
|||
99
App/lib/application-pipeline.ts
Normal file
99
App/lib/application-pipeline.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ApplicationStage, Role } from "@prisma/client";
|
||||
|
||||
// Recruitment pipeline state machine (Crewing-Implementation-Spec §5.1) — mirrors
|
||||
// po-state-machine / requisition-state-machine. The 7 board stages advance in
|
||||
// order; ONBOARDED is the terminal system state set at onboarding (Phase 3c);
|
||||
// REJECTED is an orthogonal branch reachable from any active stage.
|
||||
//
|
||||
// Stage advances are modelled here. The within-stage work — recording reference
|
||||
// checks, capturing bank/EPF, agreeing the salary, recording the interview
|
||||
// result, requesting a waiver — happens in server actions; this machine governs
|
||||
// when a candidate may move to the next column and who may move them.
|
||||
//
|
||||
// Manager-gated advances (spec §6): SALARY_AGREEMENT → PROPOSED (salary approval)
|
||||
// and INTERVIEW → SELECTED (final selection) are Manager-only. The interview
|
||||
// waiver is a separate Manager-approved action (R2), never automatic.
|
||||
|
||||
export type ApplicationAction =
|
||||
| "start_competency" // SHORTLISTED → COMPETENCY_AND_REFERENCES
|
||||
| "verify_competency" // COMPETENCY_AND_REFERENCES → DOC_VERIFICATION
|
||||
| "verify_docs" // DOC_VERIFICATION → SALARY_AGREEMENT
|
||||
| "approve_salary" // SALARY_AGREEMENT → PROPOSED (Manager)
|
||||
| "propose_accepted" // PROPOSED → INTERVIEW
|
||||
| "select" // INTERVIEW → SELECTED (Manager)
|
||||
| "onboard"; // SELECTED → ONBOARDED (Phase 3c)
|
||||
|
||||
interface Transition {
|
||||
to: ApplicationStage;
|
||||
allowedRoles: Role[];
|
||||
}
|
||||
|
||||
type TransitionMap = Partial<Record<ApplicationAction, Transition>>;
|
||||
|
||||
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
||||
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
||||
|
||||
const TRANSITIONS: Partial<Record<ApplicationStage, TransitionMap>> = {
|
||||
SHORTLISTED: {
|
||||
start_competency: { to: "COMPETENCY_AND_REFERENCES", allowedRoles: SOURCING_ROLES },
|
||||
},
|
||||
COMPETENCY_AND_REFERENCES: {
|
||||
verify_competency: { to: "DOC_VERIFICATION", allowedRoles: SOURCING_ROLES },
|
||||
},
|
||||
DOC_VERIFICATION: {
|
||||
verify_docs: { to: "SALARY_AGREEMENT", allowedRoles: SOURCING_ROLES },
|
||||
},
|
||||
SALARY_AGREEMENT: {
|
||||
// Manager approves the agreed salary structure (spec §6).
|
||||
approve_salary: { to: "PROPOSED", allowedRoles: MANAGER_ROLES },
|
||||
},
|
||||
PROPOSED: {
|
||||
propose_accepted: { to: "INTERVIEW", allowedRoles: SOURCING_ROLES },
|
||||
},
|
||||
INTERVIEW: {
|
||||
// Final selection is a Manager approval (spec §6). The action enforces that
|
||||
// the interview was accepted or a Manager-approved waiver is in place (R2).
|
||||
select: { to: "SELECTED", allowedRoles: MANAGER_ROLES },
|
||||
},
|
||||
SELECTED: {
|
||||
// The onboarding side-effect (Phase 3c) moves SELECTED → ONBOARDED.
|
||||
onboard: { to: "ONBOARDED", allowedRoles: SOURCING_ROLES },
|
||||
},
|
||||
};
|
||||
|
||||
// The 7 visible board columns, in order (spec §8.4). ONBOARDED/REJECTED are not
|
||||
// board columns — they are terminal/branch states.
|
||||
export const BOARD_STAGES: ApplicationStage[] = [
|
||||
"SHORTLISTED",
|
||||
"COMPETENCY_AND_REFERENCES",
|
||||
"DOC_VERIFICATION",
|
||||
"SALARY_AGREEMENT",
|
||||
"PROPOSED",
|
||||
"INTERVIEW",
|
||||
"SELECTED",
|
||||
];
|
||||
|
||||
export function getTransition(from: ApplicationStage, action: ApplicationAction): Transition | null {
|
||||
return TRANSITIONS[from]?.[action] ?? null;
|
||||
}
|
||||
|
||||
export function canPerformAction(from: ApplicationStage, action: ApplicationAction, role: Role): boolean {
|
||||
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
|
||||
}
|
||||
|
||||
export function getAvailableActions(stage: ApplicationStage, role: Role): ApplicationAction[] {
|
||||
const map = TRANSITIONS[stage];
|
||||
if (!map) return [];
|
||||
return (Object.keys(map) as ApplicationAction[]).filter((a) => canPerformAction(stage, a, role));
|
||||
}
|
||||
|
||||
// ── Rejection (orthogonal) ───────────────────────────────────────────────────
|
||||
// A candidate may be rejected with remarks from any active stage (not once
|
||||
// SELECTED/ONBOARDED, and not again if already REJECTED), by MPO or Manager.
|
||||
|
||||
export const REJECT_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
|
||||
const TERMINAL: ApplicationStage[] = ["SELECTED", "ONBOARDED", "REJECTED"];
|
||||
|
||||
export function canReject(from: ApplicationStage, role: Role): boolean {
|
||||
return !TERMINAL.includes(from) && REJECT_ROLES.includes(role);
|
||||
}
|
||||
40
App/lib/appraisal-state-machine.ts
Normal file
40
App/lib/appraisal-state-machine.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { AppraisalStatus, Role } from "@prisma/client";
|
||||
|
||||
// Appraisal lifecycle (Crewing-Implementation-Spec §5.4) — mirrors the other
|
||||
// crewing state machines. A PM raises the appraisal directly into SUBMITTED; this
|
||||
// machine governs the two review advances. Rejection is orthogonal (handled in
|
||||
// the actions: an MPO or Manager declines → REJECTED with remarks).
|
||||
|
||||
export type AppraisalAction = "verify" | "approve";
|
||||
|
||||
interface Transition {
|
||||
to: AppraisalStatus;
|
||||
allowedRoles: Role[];
|
||||
}
|
||||
|
||||
const VERIFY_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"]; // verify_appraisal
|
||||
const APPROVE_ROLES: Role[] = ["MANAGER", "SUPERUSER"]; // approve_appraisal
|
||||
|
||||
const TRANSITIONS: Partial<Record<AppraisalStatus, Partial<Record<AppraisalAction, Transition>>>> = {
|
||||
SUBMITTED: {
|
||||
verify: { to: "MPO_VERIFIED", allowedRoles: VERIFY_ROLES },
|
||||
},
|
||||
MPO_VERIFIED: {
|
||||
approve: { to: "MANAGER_APPROVED", allowedRoles: APPROVE_ROLES },
|
||||
},
|
||||
};
|
||||
|
||||
export function getTransition(from: AppraisalStatus, action: AppraisalAction): Transition | null {
|
||||
return TRANSITIONS[from]?.[action] ?? null;
|
||||
}
|
||||
|
||||
export function canPerformAction(from: AppraisalStatus, action: AppraisalAction, role: Role): boolean {
|
||||
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
|
||||
}
|
||||
|
||||
// A review may be declined while the appraisal is still in flight.
|
||||
const REJECTABLE_FROM: AppraisalStatus[] = ["SUBMITTED", "MPO_VERIFIED"];
|
||||
|
||||
export function canReject(from: AppraisalStatus): boolean {
|
||||
return REJECTABLE_FROM.includes(from);
|
||||
}
|
||||
37
App/lib/crew-login.ts
Normal file
37
App/lib/crew-login.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
// Promote a crew member to a portal login when their rank grants one (PM /
|
||||
// Assistant PM / Site In-charge — Rank.grantsLogin, spec §3/§4.1). Called from
|
||||
// onboarding and direct placement, inside their transaction. Creates a SITE_STAFF
|
||||
// User with no password (set later via the profile / SSO). No-op when the rank
|
||||
// doesn't grant a login, the crew member has no email/employee no., or a matching
|
||||
// user already exists. Returns true when a login was created.
|
||||
|
||||
export async function maybeCreateSiteStaffLogin(
|
||||
tx: Prisma.TransactionClient,
|
||||
crew: { name: string; email: string | null; employeeId: string | null },
|
||||
rankId: string,
|
||||
siteId?: string | null
|
||||
): Promise<boolean> {
|
||||
const rank = await tx.rank.findUnique({ where: { id: rankId }, select: { grantsLogin: true } });
|
||||
if (!rank?.grantsLogin) return false;
|
||||
if (!crew.email || !crew.employeeId) return false;
|
||||
|
||||
const existing = await tx.user.findFirst({
|
||||
where: { OR: [{ email: crew.email }, { employeeId: crew.employeeId }] },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return false;
|
||||
|
||||
await tx.user.create({
|
||||
data: {
|
||||
employeeId: crew.employeeId,
|
||||
email: crew.email,
|
||||
name: crew.name,
|
||||
role: "SITE_STAFF",
|
||||
passwordHash: null,
|
||||
siteId: siteId ?? null,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
48
App/lib/crew-pii.ts
Normal file
48
App/lib/crew-pii.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { Role, SeafarerDocType } from "@prisma/client";
|
||||
|
||||
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
||||
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
|
||||
// masked for everyone else. Salary is hidden from site staff (office-only).
|
||||
|
||||
export function canViewFullBankEpf(role: Role): boolean {
|
||||
return role === "ACCOUNTS" || role === "SUPERUSER";
|
||||
}
|
||||
|
||||
// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated
|
||||
// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents
|
||||
// (passport, CDC, STCW, COC, medical…) are not number-restricted.
|
||||
const RESTRICTED_DOC_TYPES = new Set<SeafarerDocType>(["AADHAAR", "PAN"]);
|
||||
|
||||
export function canViewSalary(role: Role): boolean {
|
||||
// Office roles see salary; site staff see status only (§6, R7).
|
||||
return role !== "SITE_STAFF";
|
||||
}
|
||||
|
||||
// "•••• 4471" — keep only the last `visible` chars; null/short values render "—".
|
||||
export function maskTail(value: string | null | undefined, visible = 4): string {
|
||||
if (!value) return "—";
|
||||
const v = value.trim();
|
||||
if (v.length <= visible) return "••••";
|
||||
return `•••• ${v.slice(-visible)}`;
|
||||
}
|
||||
|
||||
// Show the value in full only when allowed, else mask it.
|
||||
export function bankEpfValue(value: string | null | undefined, role: Role): string {
|
||||
if (!value) return "—";
|
||||
return canViewFullBankEpf(role) ? value : maskTail(value);
|
||||
}
|
||||
|
||||
// A seafarer document number, masked for non-privileged roles when the document
|
||||
// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass
|
||||
// through unchanged. Preserves the `string | null` contract the profile expects.
|
||||
export function documentNumberValue(
|
||||
value: string | null | undefined,
|
||||
docType: SeafarerDocType,
|
||||
role: Role
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) {
|
||||
return maskTail(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
29
App/lib/employee-number.ts
Normal file
29
App/lib/employee-number.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Crew employee-number generator. Format: CRW-<id>, e.g. CRW-1000.
|
||||
*
|
||||
* Sequential, floored at 1000, scanning existing CrewMember.employeeId values.
|
||||
* Assigned at onboarding (Phase 3c). Call inside the onboarding transaction to
|
||||
* minimise the race window (the unique constraint is the backstop).
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const PREFIX = "CRW-";
|
||||
const FLOOR = 999; // first generated id is 1000
|
||||
|
||||
export async function generateEmployeeId(
|
||||
client: Prisma.TransactionClient | typeof db = db
|
||||
): Promise<string> {
|
||||
const rows = await client.crewMember.findMany({
|
||||
where: { employeeId: { startsWith: PREFIX } },
|
||||
select: { employeeId: true },
|
||||
});
|
||||
let maxId = FLOOR;
|
||||
for (const { employeeId } of rows) {
|
||||
if (!employeeId) continue;
|
||||
const n = parseInt(employeeId.slice(PREFIX.length), 10);
|
||||
if (!isNaN(n) && n > maxId) maxId = n;
|
||||
}
|
||||
return `${PREFIX}${maxId + 1}`;
|
||||
}
|
||||
54
App/lib/leave-clash.ts
Normal file
54
App/lib/leave-clash.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6 — Option A).
|
||||
// Approving a leave is a clash when the remaining ACTIVE same-rank cover on the
|
||||
// vessel over the leave window would fall BELOW the rank's required strength for
|
||||
// that vessel (VesselRankRequirement.minStrength, default 1 when unconfigured).
|
||||
// A clash auto-raises a LEAVE requisition.
|
||||
|
||||
interface ClashInput {
|
||||
assignmentId: string;
|
||||
rankId: string;
|
||||
vesselId: string | null;
|
||||
fromDate: Date;
|
||||
toDate: Date;
|
||||
}
|
||||
|
||||
export async function leaveCausesClash(
|
||||
tx: Prisma.TransactionClient,
|
||||
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
|
||||
): Promise<boolean> {
|
||||
// No vessel cost axis → no rank-cover check.
|
||||
if (!vesselId) return false;
|
||||
|
||||
const requirement = await tx.vesselRankRequirement.findUnique({
|
||||
where: { vesselId_rankId: { vesselId, rankId } },
|
||||
select: { minStrength: true },
|
||||
});
|
||||
const requiredStrength = requirement?.minStrength ?? 1;
|
||||
if (requiredStrength <= 0) return false;
|
||||
|
||||
// Other not-signed-off same-rank crew on the vessel (excludes the one going on leave).
|
||||
const others = await tx.crewAssignment.findMany({
|
||||
where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let remainingCover = 0;
|
||||
if (others.length > 0) {
|
||||
const otherIds = others.map((o) => o.id);
|
||||
const overlapping = await tx.leaveRequest.findMany({
|
||||
where: {
|
||||
assignmentId: { in: otherIds },
|
||||
status: "APPROVED",
|
||||
fromDate: { lte: toDate },
|
||||
toDate: { gte: fromDate },
|
||||
},
|
||||
select: { assignmentId: true },
|
||||
});
|
||||
const out = new Set(overlapping.map((l) => l.assignmentId));
|
||||
remainingCover = otherIds.filter((id) => !out.has(id)).length;
|
||||
}
|
||||
|
||||
return remainingCover < requiredStrength;
|
||||
}
|
||||
|
|
@ -3,7 +3,10 @@ import { db } from "@/lib/db";
|
|||
import type { PurchaseOrder, User } from "@prisma/client";
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const resend = isDev ? null : new Resend(process.env.RESEND_API_KEY);
|
||||
// Construct the Resend client only when a key is actually present — in dev, CI,
|
||||
// or any env without RESEND_API_KEY we fall back to console logging (the Resend
|
||||
// v4 constructor throws on a missing key). `canSend` gates the real send path.
|
||||
const resend = !isDev && process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
|
||||
const FROM = `${process.env.EMAIL_FROM_NAME ?? "PPMS"} <${process.env.EMAIL_FROM ?? "noreply@ppms.pelagiamarine.com"}>`;
|
||||
const APP_URL = (process.env.NEXTAUTH_URL ?? "https://portal.pelagiamarine.com").replace(/\/$/, "");
|
||||
|
||||
|
|
@ -21,6 +24,22 @@ export type NotificationEvent =
|
|||
| "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"
|
||||
| "CANDIDATE_PROPOSED"
|
||||
| "SALARY_FOR_APPROVAL"
|
||||
| "SELECTION_FOR_APPROVAL"
|
||||
| "WAIVER_REQUESTED"
|
||||
| "LEAVE_FOR_APPROVAL"
|
||||
| "APPRAISAL_FOR_VERIFICATION"
|
||||
| "APPRAISAL_FOR_APPROVAL";
|
||||
|
||||
interface NotifyParams {
|
||||
event: NotificationEvent;
|
||||
po: PurchaseOrder & { submitter: User };
|
||||
|
|
@ -70,13 +89,13 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
|
|||
const link = buildInAppLink(event, po, recipient);
|
||||
|
||||
let status = "sent";
|
||||
if (isDev) {
|
||||
if (!resend) {
|
||||
console.log(
|
||||
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n Link: ${APP_URL}${link}\n`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const { error } = await resend!.emails.send({
|
||||
const { error } = await resend.emails.send({
|
||||
from: FROM,
|
||||
to: recipient.email,
|
||||
subject,
|
||||
|
|
@ -398,3 +417,106 @@ function buildHtml(
|
|||
</body>
|
||||
</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",
|
||||
CANDIDATE_PROPOSED: "View Candidate",
|
||||
SALARY_FOR_APPROVAL: "Review Salary",
|
||||
SELECTION_FOR_APPROVAL: "Review Selection",
|
||||
WAIVER_REQUESTED: "Review Waiver",
|
||||
LEAVE_FOR_APPROVAL: "Review Leave",
|
||||
APPRAISAL_FOR_VERIFICATION: "Verify Appraisal",
|
||||
APPRAISAL_FOR_APPROVAL: "Review Appraisal",
|
||||
};
|
||||
|
||||
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
|
||||
await Promise.allSettled(
|
||||
recipients.map(async (recipient) => {
|
||||
let status = "sent";
|
||||
if (!resend) {
|
||||
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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,10 @@ export type Permission =
|
|||
| "generate_wage_report"
|
||||
| "approve_wage_report"
|
||||
| "view_wage_report"
|
||||
| "manage_ranks";
|
||||
| "manage_ranks"
|
||||
// Office/admin crew management — direct placement (no requisition), crew CRUD,
|
||||
// and per-vessel rank-strength config. Held by Manager + Admin (+ SuperUser).
|
||||
| "manage_crew";
|
||||
|
||||
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a
|
||||
// crewing-only role and holds no purchasing permissions.
|
||||
|
|
@ -176,6 +179,7 @@ const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"approve_wage_report",
|
||||
"view_wage_report",
|
||||
"manage_ranks",
|
||||
"manage_crew",
|
||||
],
|
||||
SUPERUSER: [
|
||||
"raise_requisition",
|
||||
|
|
@ -207,9 +211,10 @@ const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"generate_wage_report",
|
||||
"approve_wage_report",
|
||||
"view_wage_report",
|
||||
"manage_crew",
|
||||
],
|
||||
AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"],
|
||||
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks"],
|
||||
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks", "manage_crew"],
|
||||
};
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = Object.fromEntries(
|
||||
|
|
|
|||
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}`;
|
||||
}
|
||||
128
App/lib/requisition-service.ts
Normal file
128
App/lib/requisition-service.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* 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"] } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Manager recipients — for the approval gates (salary / selection / waiver). */
|
||||
export function getManagerRecipients(): Promise<User[]> {
|
||||
return db.user.findMany({
|
||||
where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Notify the office that a requisition was auto-raised. Call AFTER the
|
||||
* creating transaction commits (notifications are not part of the atomic write). */
|
||||
export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise<void> {
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* System auto-raise: an OPEN requisition with no human actor (autoRaised).
|
||||
* Sign-off, end-of-contract and the leave-clash detector funnel through here.
|
||||
* See spec §5.2/§5.3 (R6).
|
||||
*
|
||||
* Pass `tx` to create the backfill **atomically inside the caller's transaction**
|
||||
* (so an approved leave / sign-off can never commit without its backfill) — the
|
||||
* caller then owns the post-commit `notifyAutoRaised`. Called without `tx`, it
|
||||
* runs its own transaction and notifies itself.
|
||||
*/
|
||||
export async function autoRaiseRequisition(
|
||||
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">,
|
||||
tx?: Tx
|
||||
): Promise<RequisitionWithRefs> {
|
||||
const data = { ...input, raisedById: null, autoRaised: true };
|
||||
if (tx) {
|
||||
// Caller's transaction — caller is responsible for notifyAutoRaised after commit.
|
||||
return createRequisitionTx(tx, data);
|
||||
}
|
||||
const requisition = await db.$transaction((t) => createRequisitionTx(t, data));
|
||||
await notifyAutoRaised(requisition);
|
||||
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(
|
||||
type: "po-document" | "receipt",
|
||||
poId: string,
|
||||
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
||||
// phases — see Crewing-Implementation-Spec §4.5.
|
||||
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
|
||||
ownerId: string,
|
||||
fileName: string
|
||||
): string {
|
||||
const timestamp = Date.now();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "ApplicationStage" AS ENUM ('SHORTLISTED', 'COMPETENCY_AND_REFERENCES', 'DOC_VERIFICATION', 'SALARY_AGREEMENT', 'PROPOSED', 'INTERVIEW', 'SELECTED', 'REJECTED', 'ONBOARDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ApplicationGateType" AS ENUM ('COMPETENCY_REFERENCE', 'DOCUMENT', 'SALARY', 'INTERVIEW', 'WAIVER', 'SELECTION');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GateResult" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InterviewOutcome" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SalaryRateBasis" AS ENUM ('MONTHLY', 'DAILY');
|
||||
|
||||
-- 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 'APPLICATION_CREATED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_PASSED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_FAILED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'REFERENCE_RECORDED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_AGREED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_APPROVED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_PROPOSED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'INTERVIEW_RECORDED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_REQUESTED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_APPROVED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_SELECTED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_REJECTED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CrewAction" ADD COLUMN "applicationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Application" (
|
||||
"id" TEXT NOT NULL,
|
||||
"stage" "ApplicationStage" NOT NULL DEFAULT 'SHORTLISTED',
|
||||
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
|
||||
"interviewResult" "InterviewOutcome" NOT NULL DEFAULT 'PENDING',
|
||||
"interviewWaived" BOOLEAN NOT NULL DEFAULT false,
|
||||
"rejectedReason" TEXT,
|
||||
"rejectedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"requisitionId" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Application_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ApplicationGate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"gate" "ApplicationGateType" NOT NULL,
|
||||
"result" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
"note" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ApplicationGate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReferenceCheck" (
|
||||
"id" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"refereeName" TEXT NOT NULL,
|
||||
"refereeContact" TEXT,
|
||||
"outcome" TEXT,
|
||||
"note" TEXT,
|
||||
"recordedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ReferenceCheck_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SalaryStructure" (
|
||||
"id" TEXT NOT NULL,
|
||||
"applicationId" TEXT NOT NULL,
|
||||
"rateBasis" "SalaryRateBasis" NOT NULL DEFAULT 'MONTHLY',
|
||||
"basic" DECIMAL(12,2) NOT NULL,
|
||||
"victualingPerDay" DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
"allowances" JSONB,
|
||||
"currency" TEXT NOT NULL DEFAULT 'INR',
|
||||
"effectiveFrom" TIMESTAMP(3),
|
||||
"effectiveTo" TIMESTAMP(3),
|
||||
"approvedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SalaryStructure_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BankDetail" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"accountName" TEXT,
|
||||
"accountNumber" TEXT,
|
||||
"ifsc" TEXT,
|
||||
"bankName" TEXT,
|
||||
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
"verifiedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "BankDetail_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "EpfDetail" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"uan" TEXT,
|
||||
"aadhaarLast4" TEXT,
|
||||
"pfNumber" TEXT,
|
||||
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
"verifiedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "EpfDetail_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Application_requisitionId_crewMemberId_key" ON "Application"("requisitionId", "crewMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ApplicationGate_applicationId_gate_key" ON "ApplicationGate"("applicationId", "gate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BankDetail_crewMemberId_key" ON "BankDetail"("crewMemberId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EpfDetail_crewMemberId_key" ON "EpfDetail"("crewMemberId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Application" ADD CONSTRAINT "Application_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Application" ADD CONSTRAINT "Application_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ApplicationGate" ADD CONSTRAINT "ApplicationGate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReferenceCheck" ADD CONSTRAINT "ReferenceCheck_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "BankDetail" ADD CONSTRAINT "BankDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EpfDetail" ADD CONSTRAINT "EpfDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "AssignmentStatus" AS ENUM ('ACTIVE', 'ON_LEAVE', 'SIGNED_OFF');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_ONBOARDED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SalaryStructure" ADD COLUMN "assignmentId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CrewAssignment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "AssignmentStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"signOnDate" TIMESTAMP(3) NOT NULL,
|
||||
"signOffDate" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"rankId" TEXT NOT NULL,
|
||||
"vesselId" TEXT,
|
||||
"siteId" TEXT,
|
||||
"requisitionId" TEXT,
|
||||
|
||||
CONSTRAINT "CrewAssignment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContractLetter" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"fileKey" TEXT NOT NULL,
|
||||
"salaryRestricted" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ContractLetter_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "CrewAssignment_requisitionId_key" ON "CrewAssignment"("requisitionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ContractLetter_assignmentId_key" ON "ContractLetter"("assignmentId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ContractLetter" ADD CONSTRAINT "ContractLetter_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "PpeItem" AS ENUM ('BOILER_SUIT', 'SAFETY_SHOES', 'HELMET', 'VEST', 'GLOVES', 'MASK', 'GOGGLES', 'TIFFIN', 'TORCH', 'WALKIE_TALKIE');
|
||||
|
||||
-- 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 'DOCUMENT_UPLOADED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_UPDATED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_ISSUED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_RETURNED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'EXPERIENCE_ADDED';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SeafarerDocument" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"docType" "SeafarerDocType" NOT NULL,
|
||||
"number" TEXT,
|
||||
"fileKey" TEXT,
|
||||
"issueDate" TIMESTAMP(3),
|
||||
"expiryDate" TIMESTAMP(3),
|
||||
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
"verifiedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SeafarerDocument_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NextOfKin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"relationship" TEXT,
|
||||
"phone" TEXT,
|
||||
"address" TEXT,
|
||||
"isEmergency" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "NextOfKin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExperienceRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"vesselType" TEXT,
|
||||
"rankId" TEXT,
|
||||
"fromDate" TIMESTAMP(3),
|
||||
"toDate" TIMESTAMP(3),
|
||||
"durationMonths" INTEGER,
|
||||
"source" TEXT NOT NULL DEFAULT 'declared',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ExperienceRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PpeIssue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"crewMemberId" TEXT NOT NULL,
|
||||
"item" "PpeItem" NOT NULL,
|
||||
"size" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"issuedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"returnedDate" TIMESTAMP(3),
|
||||
"issuedById" TEXT,
|
||||
"comment" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PpeIssue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SeafarerDocument" ADD CONSTRAINT "SeafarerDocument_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NextOfKin" ADD CONSTRAINT "NextOfKin_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PpeIssue" ADD CONSTRAINT "PpeIssue_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "LeaveType" AS ENUM ('ANNUAL', 'MEDICAL', 'EMERGENCY', 'UNPAID', 'OTHER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "LeaveStatus" AS ENUM ('APPLIED', 'APPROVED', 'REJECTED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AttendanceStatus" AS ENUM ('PRESENT', 'ABSENT', 'HALF_DAY', 'ON_LEAVE', 'SIGN_OFF');
|
||||
|
||||
-- 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 'LEAVE_APPLIED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'LEAVE_DECIDED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'ATTENDANCE_RECORDED';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LeaveRequest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"type" "LeaveType" NOT NULL DEFAULT 'ANNUAL',
|
||||
"fromDate" TIMESTAMP(3) NOT NULL,
|
||||
"toDate" TIMESTAMP(3) NOT NULL,
|
||||
"reason" TEXT,
|
||||
"status" "LeaveStatus" NOT NULL DEFAULT 'APPLIED',
|
||||
"appliedById" TEXT NOT NULL,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Attendance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"date" DATE NOT NULL,
|
||||
"status" "AttendanceStatus" NOT NULL,
|
||||
"note" TEXT,
|
||||
"recordedById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Attendance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Attendance_assignmentId_date_key" ON "Attendance"("assignmentId", "date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "VesselRankRequirement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"vesselId" TEXT NOT NULL,
|
||||
"rankId" TEXT NOT NULL,
|
||||
"minStrength" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VesselRankRequirement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VesselRankRequirement_vesselId_rankId_key" ON "VesselRankRequirement"("vesselId", "rankId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterEnum
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_SIGNED_OFF';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
-- 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 'RECORD_VERIFIED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_REJECTED';
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "AppraisalStatus" AS ENUM ('DRAFT', 'SUBMITTED', 'MPO_VERIFIED', 'MANAGER_APPROVED', 'REJECTED');
|
||||
|
||||
-- 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 'APPRAISAL_SUBMITTED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_VERIFIED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_APPROVED';
|
||||
ALTER TYPE "CrewActionType" ADD VALUE 'APPRAISAL_REJECTED';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Appraisal" (
|
||||
"id" TEXT NOT NULL,
|
||||
"assignmentId" TEXT NOT NULL,
|
||||
"period" TEXT NOT NULL,
|
||||
"ratings" JSONB,
|
||||
"comments" TEXT,
|
||||
"status" "AppraisalStatus" NOT NULL DEFAULT 'SUBMITTED',
|
||||
"rejectedReason" TEXT,
|
||||
"addedById" TEXT NOT NULL,
|
||||
"verifiedById" TEXT,
|
||||
"approvedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Appraisal_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Appraisal" ADD CONSTRAINT "Appraisal_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "NextOfKin" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
ADD COLUMN "verifiedById" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "PpeIssue" ADD COLUMN "verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
|
||||
ADD COLUMN "verifiedById" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "siteId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EpfDetail" ADD COLUMN "epfoCheckedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "epfoMemberName" TEXT;
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
-- Recreate CrewActionType: add explicit return/decline/delete audit types and
|
||||
-- drop the unused GATE_FAILED value (see Crewing audit-trail consistency cleanup,
|
||||
-- spec §11). One recreate adds + removes in a single migration.
|
||||
BEGIN;
|
||||
CREATE TYPE "CrewActionType_new" AS ENUM (
|
||||
'REQUISITION_RAISED',
|
||||
'REQUISITION_ADVANCED',
|
||||
'REQUISITION_FILLED',
|
||||
'REQUISITION_CANCELLED',
|
||||
'RELIEF_REQUESTED',
|
||||
'RELIEF_CONVERTED',
|
||||
'RELIEF_CANCELLED',
|
||||
'CANDIDATE_ADDED',
|
||||
'CANDIDATE_UPDATED',
|
||||
'APPLICATION_CREATED',
|
||||
'GATE_PASSED',
|
||||
'REFERENCE_RECORDED',
|
||||
'SALARY_AGREED',
|
||||
'SALARY_APPROVED',
|
||||
'SALARY_RETURNED',
|
||||
'CANDIDATE_PROPOSED',
|
||||
'INTERVIEW_RECORDED',
|
||||
'WAIVER_REQUESTED',
|
||||
'WAIVER_APPROVED',
|
||||
'WAIVER_DECLINED',
|
||||
'CANDIDATE_SELECTED',
|
||||
'SELECTION_RETURNED',
|
||||
'APPLICATION_REJECTED',
|
||||
'CREW_ONBOARDED',
|
||||
'DOCUMENT_UPLOADED',
|
||||
'RECORD_UPDATED',
|
||||
'RECORD_DELETED',
|
||||
'PPE_ISSUED',
|
||||
'PPE_RETURNED',
|
||||
'EXPERIENCE_ADDED',
|
||||
'LEAVE_APPLIED',
|
||||
'LEAVE_DECIDED',
|
||||
'ATTENDANCE_RECORDED',
|
||||
'CREW_SIGNED_OFF',
|
||||
'RECORD_VERIFIED',
|
||||
'RECORD_REJECTED',
|
||||
'APPRAISAL_SUBMITTED',
|
||||
'APPRAISAL_VERIFIED',
|
||||
'APPRAISAL_APPROVED',
|
||||
'APPRAISAL_REJECTED'
|
||||
);
|
||||
ALTER TABLE "CrewAction" ALTER COLUMN "actionType" TYPE "CrewActionType_new" USING ("actionType"::text::"CrewActionType_new");
|
||||
ALTER TYPE "CrewActionType" RENAME TO "CrewActionType_old";
|
||||
ALTER TYPE "CrewActionType_new" RENAME TO "CrewActionType";
|
||||
DROP TYPE "CrewActionType_old";
|
||||
COMMIT;
|
||||
|
|
@ -87,6 +87,219 @@ enum SeafarerDocType {
|
|||
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
|
||||
APPLICATION_CREATED
|
||||
GATE_PASSED
|
||||
REFERENCE_RECORDED
|
||||
SALARY_AGREED
|
||||
SALARY_APPROVED
|
||||
SALARY_RETURNED
|
||||
CANDIDATE_PROPOSED
|
||||
INTERVIEW_RECORDED
|
||||
WAIVER_REQUESTED
|
||||
WAIVER_APPROVED
|
||||
WAIVER_DECLINED
|
||||
CANDIDATE_SELECTED
|
||||
SELECTION_RETURNED
|
||||
APPLICATION_REJECTED
|
||||
CREW_ONBOARDED
|
||||
DOCUMENT_UPLOADED
|
||||
RECORD_UPDATED
|
||||
RECORD_DELETED
|
||||
PPE_ISSUED
|
||||
PPE_RETURNED
|
||||
EXPERIENCE_ADDED
|
||||
LEAVE_APPLIED
|
||||
LEAVE_DECIDED
|
||||
ATTENDANCE_RECORDED
|
||||
CREW_SIGNED_OFF
|
||||
RECORD_VERIFIED
|
||||
RECORD_REJECTED
|
||||
APPRAISAL_SUBMITTED
|
||||
APPRAISAL_VERIFIED
|
||||
APPRAISAL_APPROVED
|
||||
APPRAISAL_REJECTED
|
||||
}
|
||||
|
||||
// ─── Crewing appraisal (Phase 5b, Epic H) ───────────────────────────────────
|
||||
// Appraisal lifecycle (Crewing-Implementation-Spec §5.4/§8.14): a PM raises
|
||||
// (→ SUBMITTED), the MPO verifies (→ MPO_VERIFIED), the Manager approves
|
||||
// (→ MANAGER_APPROVED); → REJECTED with remarks from either review.
|
||||
enum AppraisalStatus {
|
||||
DRAFT
|
||||
SUBMITTED
|
||||
MPO_VERIFIED
|
||||
MANAGER_APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// ─── Crewing leave & attendance (Phase 4b, Epic G) ──────────────────────────
|
||||
// Leave is applied by the Site In-charge on a crew member and decided by the
|
||||
// Manager (the MPO has no leave role — R1). See Crewing-Data-Model §1/§4.
|
||||
enum LeaveType {
|
||||
ANNUAL
|
||||
MEDICAL
|
||||
EMERGENCY
|
||||
UNPAID
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum LeaveStatus {
|
||||
APPLIED
|
||||
APPROVED
|
||||
REJECTED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// Daily attendance (§8.10). v1 is the daily model; hours/overtime is deferred (A7).
|
||||
enum AttendanceStatus {
|
||||
PRESENT
|
||||
ABSENT
|
||||
HALF_DAY
|
||||
ON_LEAVE
|
||||
SIGN_OFF
|
||||
}
|
||||
|
||||
// PPE kit items issued to crew (Phase 4a, Epic F). See Crewing-Data-Model §1.
|
||||
enum PpeItem {
|
||||
BOILER_SUIT
|
||||
SAFETY_SHOES
|
||||
HELMET
|
||||
VEST
|
||||
GLOVES
|
||||
MASK
|
||||
GOGGLES
|
||||
TIFFIN
|
||||
TORCH
|
||||
WALKIE_TALKIE
|
||||
}
|
||||
|
||||
// ─── Crewing recruitment pipeline (Phase 3b: Epic C) ────────────────────────
|
||||
// The gated 7-stage application pipeline (Crewing-Implementation-Spec §5.1).
|
||||
// ONBOARDED is the terminal system state set at onboarding (Phase 3c);
|
||||
// REJECTED is the branch reachable from any active stage.
|
||||
enum ApplicationStage {
|
||||
SHORTLISTED
|
||||
COMPETENCY_AND_REFERENCES
|
||||
DOC_VERIFICATION
|
||||
SALARY_AGREEMENT
|
||||
PROPOSED
|
||||
INTERVIEW
|
||||
SELECTED
|
||||
REJECTED
|
||||
ONBOARDED
|
||||
}
|
||||
|
||||
// A vetting gate on an application. SALARY / SELECTION / WAIVER are the
|
||||
// Manager-decided gates that surface in the central Approvals queue (§8.13).
|
||||
enum ApplicationGateType {
|
||||
COMPETENCY_REFERENCE
|
||||
DOCUMENT
|
||||
SALARY
|
||||
INTERVIEW
|
||||
WAIVER
|
||||
SELECTION
|
||||
}
|
||||
|
||||
enum GateResult {
|
||||
PENDING
|
||||
VERIFIED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// MPO's recorded interview outcome (Manager then approves selection).
|
||||
enum InterviewOutcome {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
// Salary capture basis — the other is derived (R10/A4). Effective-dated.
|
||||
enum SalaryRateBasis {
|
||||
MONTHLY
|
||||
DAILY
|
||||
}
|
||||
|
||||
// A crew member's tour of duty (Phase 3c, Epic D). Created at onboarding; the
|
||||
// leave/sign-off transitions land in Phase 4. See Crewing-Data-Model §4.
|
||||
enum AssignmentStatus {
|
||||
ACTIVE
|
||||
ON_LEAVE
|
||||
SIGNED_OFF
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
id String @id @default(cuid())
|
||||
employeeId String @unique
|
||||
|
|
@ -99,12 +312,19 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||
actions POAction[]
|
||||
notifications Notification[]
|
||||
consumption ItemConsumption[]
|
||||
superUserRequests SuperUserRequest[] @relation("Requester")
|
||||
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
|
||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||
actions POAction[]
|
||||
notifications Notification[]
|
||||
consumption ItemConsumption[]
|
||||
superUserRequests SuperUserRequest[] @relation("Requester")
|
||||
resolvedRequests SuperUserRequest[] @relation("RequestResolver")
|
||||
requisitionsRaised Requisition[] @relation("RequisitionRaiser")
|
||||
reliefRequested ReliefRequest[] @relation("ReliefRequester")
|
||||
crewActions CrewAction[]
|
||||
|
||||
// Site-staff home site (Crewing §8.7 own-site scoping). Null = unscoped.
|
||||
siteId String?
|
||||
site Site? @relation(fields: [siteId], references: [id])
|
||||
}
|
||||
|
||||
model SuperUserRequest {
|
||||
|
|
@ -133,15 +353,23 @@ model Site {
|
|||
purchaseOrders PurchaseOrder[]
|
||||
inventory ItemInventory[]
|
||||
consumption ItemConsumption[]
|
||||
requisitions Requisition[]
|
||||
reliefRequests ReliefRequest[]
|
||||
assignments CrewAssignment[]
|
||||
staff User[]
|
||||
}
|
||||
|
||||
model Vessel {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
isActive Boolean @default(true)
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String @unique
|
||||
isActive Boolean @default(true)
|
||||
|
||||
purchaseOrders PurchaseOrder[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
requisitions Requisition[]
|
||||
reliefRequests ReliefRequest[]
|
||||
assignments CrewAssignment[]
|
||||
rankRequirements VesselRankRequirement[]
|
||||
}
|
||||
|
||||
model Company {
|
||||
|
|
@ -155,8 +383,8 @@ model Company {
|
|||
email String?
|
||||
invoiceEmail String?
|
||||
invoiceAddress String?
|
||||
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)
|
||||
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)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -180,12 +408,12 @@ model Account {
|
|||
}
|
||||
|
||||
model VendorContact {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
role String?
|
||||
mobile String?
|
||||
email String?
|
||||
isPrimary Boolean @default(false)
|
||||
isPrimary Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
vendorId String
|
||||
|
|
@ -193,17 +421,17 @@ model VendorContact {
|
|||
}
|
||||
|
||||
model Vendor {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
vendorId String? @unique
|
||||
address String?
|
||||
pincode String?
|
||||
gstin String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
isVerified Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
vendorId String? @unique
|
||||
address String?
|
||||
pincode String?
|
||||
gstin String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
isVerified Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
contacts VendorContact[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
|
|
@ -272,51 +500,51 @@ model ItemConsumption {
|
|||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
id String @id @default(cuid())
|
||||
poNumber String @unique
|
||||
title String
|
||||
status POStatus @default(DRAFT)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
currency String @default("INR")
|
||||
dateRequired DateTime?
|
||||
projectCode String?
|
||||
managerNote String?
|
||||
paymentRef String?
|
||||
paymentDate DateTime?
|
||||
paidAmount Decimal? @db.Decimal(12, 2)
|
||||
piQuotationNo String?
|
||||
piQuotationDate DateTime?
|
||||
requisitionNo String?
|
||||
requisitionDate DateTime?
|
||||
placeOfDelivery String?
|
||||
tcDelivery String?
|
||||
tcDispatch String?
|
||||
tcInspection String?
|
||||
tcTransitInsurance String?
|
||||
tcPaymentTerms String?
|
||||
tcOthers String?
|
||||
poDate DateTime?
|
||||
submittedAt DateTime?
|
||||
approvedAt DateTime?
|
||||
paidAt DateTime?
|
||||
closedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
cancellationReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
poNumber String @unique
|
||||
title String
|
||||
status POStatus @default(DRAFT)
|
||||
totalAmount Decimal @db.Decimal(12, 2)
|
||||
currency String @default("INR")
|
||||
dateRequired DateTime?
|
||||
projectCode String?
|
||||
managerNote String?
|
||||
paymentRef String?
|
||||
paymentDate DateTime?
|
||||
paidAmount Decimal? @db.Decimal(12, 2)
|
||||
piQuotationNo String?
|
||||
piQuotationDate DateTime?
|
||||
requisitionNo String?
|
||||
requisitionDate DateTime?
|
||||
placeOfDelivery String?
|
||||
tcDelivery String?
|
||||
tcDispatch String?
|
||||
tcInspection String?
|
||||
tcTransitInsurance String?
|
||||
tcPaymentTerms String?
|
||||
tcOthers String?
|
||||
poDate DateTime?
|
||||
submittedAt DateTime?
|
||||
approvedAt DateTime?
|
||||
paidAt DateTime?
|
||||
closedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
cancellationReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
submitterId String
|
||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||
vesselId String
|
||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||
accountId String
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
companyId String?
|
||||
company Company? @relation(fields: [companyId], references: [id])
|
||||
vendorId String?
|
||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||
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.
|
||||
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
||||
|
|
@ -423,10 +651,17 @@ model Rank {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
parentId String?
|
||||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||||
children Rank[] @relation("RankHierarchy")
|
||||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||||
children Rank[] @relation("RankHierarchy")
|
||||
|
||||
docRequirements RankDocRequirement[]
|
||||
docRequirements RankDocRequirement[]
|
||||
requisitions Requisition[]
|
||||
reliefRequests ReliefRequest[]
|
||||
crewCurrentRank CrewMember[] @relation("CrewCurrentRank")
|
||||
crewAppliedRank CrewMember[] @relation("CrewAppliedRank")
|
||||
assignments CrewAssignment[]
|
||||
experienceRecords ExperienceRecord[]
|
||||
vesselRequirements VesselRankRequirement[]
|
||||
}
|
||||
|
||||
// Which documents a rank is required (or conditionally required) to hold.
|
||||
|
|
@ -442,3 +677,423 @@ model RankDocRequirement {
|
|||
|
||||
@@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[]
|
||||
applications Application[]
|
||||
assignment CrewAssignment?
|
||||
}
|
||||
|
||||
// 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])
|
||||
applicationId String?
|
||||
application Application? @relation(fields: [applicationId], 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[]
|
||||
applications Application[]
|
||||
bankDetail BankDetail?
|
||||
epfDetail EpfDetail?
|
||||
assignments CrewAssignment[]
|
||||
documents SeafarerDocument[]
|
||||
nextOfKin NextOfKin[]
|
||||
experienceRecords ExperienceRecord[]
|
||||
ppeIssues PpeIssue[]
|
||||
}
|
||||
|
||||
// ─── Crewing recruitment pipeline models (Phase 3b) ─────────────────────────
|
||||
|
||||
// A candidate's application against one requisition — the gated pipeline spine
|
||||
// (spec §5.1/§8.4–8.5). One application per (requisition, candidate).
|
||||
model Application {
|
||||
id String @id @default(cuid())
|
||||
stage ApplicationStage @default(SHORTLISTED)
|
||||
type CandidateType @default(NEW)
|
||||
interviewResult InterviewOutcome @default(PENDING)
|
||||
interviewWaived Boolean @default(false) // set true only on Manager-approved waiver (R2)
|
||||
rejectedReason String?
|
||||
rejectedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
requisitionId String
|
||||
requisition Requisition @relation(fields: [requisitionId], references: [id])
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
|
||||
|
||||
gates ApplicationGate[]
|
||||
referenceChecks ReferenceCheck[]
|
||||
salaryStructures SalaryStructure[]
|
||||
actions CrewAction[]
|
||||
|
||||
@@unique([requisitionId, crewMemberId])
|
||||
}
|
||||
|
||||
// One row per vetting gate. SALARY / SELECTION / WAIVER gates with result PENDING
|
||||
// are the Manager's central Approvals-queue items (§8.13). `decidedById` is a
|
||||
// denormalised actor id — the audited actor lives on the CrewAction.
|
||||
model ApplicationGate {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
gate ApplicationGateType
|
||||
result GateResult @default(PENDING)
|
||||
note String?
|
||||
decidedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([applicationId, gate])
|
||||
}
|
||||
|
||||
// Competency & reference checks recorded by the MPO at the COMPETENCY_AND_REFERENCES gate.
|
||||
model ReferenceCheck {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
refereeName String
|
||||
refereeContact String?
|
||||
outcome String? // free-text / "positive" | "negative"
|
||||
note String?
|
||||
recordedById String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// The salary agreed at SALARY_AGREEMENT, sent for Manager approval. Effective-dated
|
||||
// (R10/A4) and attached to the Application in 3b; onboarding (3c) binds it to the
|
||||
// CrewAssignment. `approvedById` is set when the Manager approves the SALARY gate.
|
||||
model SalaryStructure {
|
||||
id String @id @default(cuid())
|
||||
applicationId String
|
||||
application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade)
|
||||
rateBasis SalaryRateBasis @default(MONTHLY)
|
||||
basic Decimal @db.Decimal(12, 2)
|
||||
victualingPerDay Decimal @default(0) @db.Decimal(12, 2)
|
||||
allowances Json?
|
||||
currency String @default("INR")
|
||||
effectiveFrom DateTime?
|
||||
effectiveTo DateTime?
|
||||
approvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Bound to the assignment at onboarding (Phase 3c); null while still a proposal.
|
||||
assignmentId String?
|
||||
assignment CrewAssignment? @relation(fields: [assignmentId], references: [id])
|
||||
}
|
||||
|
||||
// Bank details captured at DOC_VERIFICATION (needed downstream for payroll).
|
||||
// NOTE: PII — field-level encryption/masking is a Phase-4 task (§11); stored
|
||||
// plainly for now behind the crewing flag.
|
||||
model BankDetail {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String @unique
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
accountName String?
|
||||
accountNumber String?
|
||||
ifsc String?
|
||||
bankName String?
|
||||
verificationStatus GateResult @default(PENDING) // verified by Accounts in a later phase
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// EPF / identity details captured at DOC_VERIFICATION. PII note as BankDetail.
|
||||
model EpfDetail {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String @unique
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
uan String?
|
||||
aadhaarLast4 String?
|
||||
pfNumber String?
|
||||
verificationStatus GateResult @default(PENDING)
|
||||
verifiedById String?
|
||||
// EPFO assisted-lookup result (recorded from the EpfoService check, A3).
|
||||
epfoMemberName String?
|
||||
epfoCheckedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─── Crewing onboarding (Phase 3c: Epic D) ──────────────────────────────────
|
||||
|
||||
// A single tour of duty, created at onboarding. Flips the requisition to FILLED
|
||||
// and the crew member to EMPLOYEE. Leave/sign-off transitions arrive in Phase 4.
|
||||
model CrewAssignment {
|
||||
id String @id @default(cuid())
|
||||
status AssignmentStatus @default(ACTIVE)
|
||||
signOnDate DateTime
|
||||
signOffDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id])
|
||||
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])
|
||||
// The requisition this assignment fills (one assignment per requisition).
|
||||
requisitionId String? @unique
|
||||
requisition Requisition? @relation(fields: [requisitionId], references: [id])
|
||||
|
||||
salaryStructures SalaryStructure[]
|
||||
contractLetter ContractLetter?
|
||||
leaveRequests LeaveRequest[]
|
||||
attendance Attendance[]
|
||||
appraisals Appraisal[]
|
||||
}
|
||||
|
||||
// A periodic appraisal on a tour of duty (Phase 5b). Actor ids are denormalised
|
||||
// strings — the audited actor lives on the CrewAction.
|
||||
model Appraisal {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String
|
||||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
period String // e.g. "2026" or "2026-Q2"
|
||||
ratings Json?
|
||||
comments String?
|
||||
status AppraisalStatus @default(SUBMITTED)
|
||||
rejectedReason String?
|
||||
addedById String
|
||||
verifiedById String?
|
||||
approvedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Leave applied by the Site In-charge on a crew member's assignment, decided by
|
||||
// the Manager (§8.9, R1). Actor ids are denormalised strings — the audited actor
|
||||
// lives on the CrewAction.
|
||||
model LeaveRequest {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String
|
||||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
type LeaveType @default(ANNUAL)
|
||||
fromDate DateTime
|
||||
toDate DateTime
|
||||
reason String?
|
||||
status LeaveStatus @default(APPLIED)
|
||||
appliedById String
|
||||
decidedById String?
|
||||
decidedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// One attendance mark per assignment per day (§8.10). Site staff + Manager only.
|
||||
model Attendance {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String
|
||||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
date DateTime @db.Date
|
||||
status AttendanceStatus
|
||||
note String?
|
||||
recordedById String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([assignmentId, date])
|
||||
}
|
||||
|
||||
// Required crew strength per rank, per vessel (Phase 4b, Option A). Drives
|
||||
// leave-clash detection (§5.3, R6): approving a leave is a clash when the active
|
||||
// same-rank cover over the window would fall below this. Managed by the office
|
||||
// (manage_crew). Absent a row, the clash check falls back to a strength of 1.
|
||||
model VesselRankRequirement {
|
||||
id String @id @default(cuid())
|
||||
vesselId String
|
||||
vessel Vessel @relation(fields: [vesselId], references: [id], onDelete: Cascade)
|
||||
rankId String
|
||||
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
|
||||
minStrength Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([vesselId, rankId])
|
||||
}
|
||||
|
||||
// The signed contract for an assignment. `salaryRestricted` hides salary from
|
||||
// site staff on the crew profile (Phase 4 display gating).
|
||||
model ContractLetter {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String @unique
|
||||
assignment CrewAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
fileKey String
|
||||
salaryRestricted Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ─── Crewing crew records (Phase 4a, Epics E + F) ───────────────────────────
|
||||
|
||||
// A held document on the crew profile (medical, passport, CDC, STCW, …). The
|
||||
// verify queue (MPO/Accounts) lands in Phase 5; here we capture + display, with
|
||||
// `verificationStatus` carried and "expired" derived from expiryDate in the UI.
|
||||
model SeafarerDocument {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
docType SeafarerDocType
|
||||
number String? // PII — masked in the UI for non-privileged roles
|
||||
fileKey String?
|
||||
issueDate DateTime?
|
||||
expiryDate DateTime?
|
||||
verificationStatus GateResult @default(PENDING)
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Next of kin / emergency contacts (§8.8). `isEmergency` marks the emergency row.
|
||||
model NextOfKin {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
relationship String?
|
||||
phone String?
|
||||
address String?
|
||||
isEmergency Boolean @default(false)
|
||||
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// A tour-of-duty experience row — added manually or auto-appended at sign-off
|
||||
// (Phase 4c). `source` is "internal" (a PPMS assignment) or "declared".
|
||||
model ExperienceRecord {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
vesselType String?
|
||||
rankId String?
|
||||
rank Rank? @relation(fields: [rankId], references: [id])
|
||||
fromDate DateTime?
|
||||
toDate DateTime?
|
||||
durationMonths Int?
|
||||
source String @default("declared")
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// PPE issued to a crew member (§8.8). A reissue is a new row; `returnedDate`
|
||||
// marks a returned item. Optional ItemInventory draw-down is a later refinement.
|
||||
model PpeIssue {
|
||||
id String @id @default(cuid())
|
||||
crewMemberId String
|
||||
crewMember CrewMember @relation(fields: [crewMemberId], references: [id], onDelete: Cascade)
|
||||
item PpeItem
|
||||
size String?
|
||||
quantity Int @default(1)
|
||||
issuedDate DateTime @default(now())
|
||||
returnedDate DateTime?
|
||||
issuedById String?
|
||||
comment String?
|
||||
verificationStatus GateResult @default(PENDING) // MPO verifies (Phase 5 follow-up)
|
||||
verifiedById String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
|
|
|||
246
App/tests/integration/applications.test.ts
Normal file
246
App/tests/integration/applications.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Integration tests for the Crewing Phase 3b recruitment pipeline actions.
|
||||
* The Application/Gate/Salary/Bank/EPF tables are introduced in this phase, so
|
||||
* afterEach wipes the crewing lifecycle tables wholesale.
|
||||
*/
|
||||
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 {
|
||||
addApplication,
|
||||
advanceStage,
|
||||
recordReferenceCheck,
|
||||
verifyDocuments,
|
||||
agreeSalary,
|
||||
approveSalary,
|
||||
recordInterviewResult,
|
||||
requestInterviewWaiver,
|
||||
approveInterviewWaiver,
|
||||
selectCandidate,
|
||||
rejectApplication,
|
||||
} from "@/app/(portal)/crewing/applications/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { ApplicationStage, Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let manningId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itapp.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
let seq = 0;
|
||||
async function freshRequisition() {
|
||||
seq += 1;
|
||||
return db.requisition.create({ data: { code: `REQ-T${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "OPEN" } });
|
||||
}
|
||||
async function freshCandidate(type: "NEW" | "EX_HAND" = "NEW") {
|
||||
return db.crewMember.create({ data: { name: type === "EX_HAND" ? "Ex Hand" : "New Cand", type, status: type === "EX_HAND" ? "EX_HAND" : "CANDIDATE", source: type === "EX_HAND" ? "EX_HAND" : "CAREERS", appliedRankId: rankId } });
|
||||
}
|
||||
async function newApplication(type: "NEW" | "EX_HAND" = "NEW") {
|
||||
const [req, cand] = await Promise.all([freshRequisition(), freshCandidate(type)]);
|
||||
as(managerId, "MANAGER");
|
||||
const res = await addApplication(fd({ requisitionId: req.id, crewMemberId: cand.id }));
|
||||
if (!("ok" in res)) throw new Error("addApplication failed");
|
||||
return { applicationId: res.id!, requisitionId: req.id, crewMemberId: cand.id };
|
||||
}
|
||||
const setStage = (id: string, stage: ApplicationStage) => db.application.update({ where: { id }, data: { stage } });
|
||||
|
||||
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: "ITAPP-SS", email: SS_EMAIL, name: "SS App", 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.salaryStructure.deleteMany({});
|
||||
await db.applicationGate.deleteMany({});
|
||||
await db.referenceCheck.deleteMany({});
|
||||
await db.seafarerDocument.deleteMany({});
|
||||
await db.application.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("addApplication", () => {
|
||||
it("creates a SHORTLISTED application and moves the requisition into SHORTLISTING", async () => {
|
||||
const { applicationId, requisitionId } = await newApplication();
|
||||
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
|
||||
expect(app.stage).toBe("SHORTLISTED");
|
||||
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SHORTLISTING");
|
||||
});
|
||||
|
||||
it("rejects a duplicate candidate on the same requisition", async () => {
|
||||
const { requisitionId, crewMemberId } = await newApplication();
|
||||
as(managerId, "MANAGER");
|
||||
const res = await addApplication(fd({ requisitionId, crewMemberId }));
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("happy path to PROPOSED", () => {
|
||||
it("walks shortlist → competency → docs(+bank/EPF) → salary → manager approval", async () => {
|
||||
const { applicationId, crewMemberId } = await newApplication();
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await advanceStage(applicationId, "start_competency"))).toBe(true);
|
||||
await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
|
||||
expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
||||
expect("ok" in (await verifyDocuments(fd({ applicationId, accountNumber: "123456", ifsc: "HDFC0001", uan: "UAN99" })))).toBe(true);
|
||||
|
||||
// Bank/EPF captured at the docs gate
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId } })).accountNumber).toBe("123456");
|
||||
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId } })).uan).toBe("UAN99");
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
|
||||
|
||||
// MPO agrees salary → SALARY gate pending
|
||||
await agreeSalary(fd({ applicationId, rateBasis: "MONTHLY", basic: "45000" }));
|
||||
const gate = await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SALARY" } });
|
||||
expect(gate.result).toBe("PENDING");
|
||||
|
||||
// MPO cannot approve salary
|
||||
as(manningId, "MANNING");
|
||||
expect(await approveSalary(applicationId)).toEqual({ error: "Unauthorized" });
|
||||
|
||||
// Manager approves → PROPOSED, structure approved
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await approveSalary(applicationId))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("PROPOSED");
|
||||
expect((await db.salaryStructure.findFirstOrThrow({ where: { applicationId } })).approvedById).toBe(managerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interview → selection", () => {
|
||||
it("MPO records pass → Manager selects → SELECTED + requisition SELECTED", async () => {
|
||||
const { applicationId, requisitionId } = await newApplication();
|
||||
await setStage(applicationId, "INTERVIEW");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await recordInterviewResult(applicationId, true))).toBe(true);
|
||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "SELECTION" } })).result).toBe("PENDING");
|
||||
|
||||
// MPO cannot select
|
||||
expect(await selectCandidate(applicationId)).toEqual({ error: "Unauthorized" });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
|
||||
expect((await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).status).toBe("SELECTED");
|
||||
});
|
||||
|
||||
it("a failed interview rejects the application", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
await setStage(applicationId, "INTERVIEW");
|
||||
as(manningId, "MANNING");
|
||||
await recordInterviewResult(applicationId, false, "Did not meet the bar");
|
||||
const app = await db.application.findUniqueOrThrow({ where: { id: applicationId } });
|
||||
expect(app.stage).toBe("REJECTED");
|
||||
expect(app.rejectedReason).toBe("Did not meet the bar");
|
||||
});
|
||||
|
||||
it("cannot select before an interview result or waiver", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
await setStage(applicationId, "INTERVIEW");
|
||||
as(managerId, "MANAGER");
|
||||
const res = await selectCandidate(applicationId);
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interview waiver (ex-hands, R2)", () => {
|
||||
it("MPO requests, Manager approves, then selection works without an interview", async () => {
|
||||
const { applicationId } = await newApplication("EX_HAND");
|
||||
await setStage(applicationId, "INTERVIEW");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await requestInterviewWaiver(applicationId, "20 yrs with us"))).toBe(true);
|
||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId, gate: "WAIVER" } })).result).toBe("PENDING");
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await approveInterviewWaiver(applicationId))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).interviewWaived).toBe(true);
|
||||
|
||||
expect("ok" in (await selectCandidate(applicationId))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SELECTED");
|
||||
});
|
||||
|
||||
it("is refused for a non-ex-hand candidate", async () => {
|
||||
const { applicationId } = await newApplication("NEW");
|
||||
await setStage(applicationId, "INTERVIEW");
|
||||
as(manningId, "MANNING");
|
||||
const res = await requestInterviewWaiver(applicationId);
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vetting gates (C3/C5)", () => {
|
||||
it("blocks completing competency & references until a reference is recorded (C5)", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
as(manningId, "MANNING");
|
||||
await advanceStage(applicationId, "start_competency"); // → COMPETENCY_AND_REFERENCES
|
||||
// No reference recorded yet → cannot advance.
|
||||
expect("error" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("COMPETENCY_AND_REFERENCES");
|
||||
// Record one → now it advances.
|
||||
await recordReferenceCheck(fd({ applicationId, refereeName: "Capt. Rao", outcome: "positive" }));
|
||||
expect("ok" in (await advanceStage(applicationId, "verify_competency"))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION");
|
||||
});
|
||||
|
||||
it("blocks document verification when a required document on file is expired (C3)", async () => {
|
||||
const { applicationId, requisitionId, crewMemberId } = await newApplication();
|
||||
await setStage(applicationId, "DOC_VERIFICATION");
|
||||
const reqRank = (await db.requisition.findUniqueOrThrow({ where: { id: requisitionId } })).rankId;
|
||||
await db.rankDocRequirement.upsert({
|
||||
where: { rankId_docType: { rankId: reqRank, docType: "MEDICAL_FITNESS" } },
|
||||
update: { isMandatory: true },
|
||||
create: { rankId: reqRank, docType: "MEDICAL_FITNESS", isMandatory: true },
|
||||
});
|
||||
await db.seafarerDocument.create({ data: { crewMemberId, docType: "MEDICAL_FITNESS", expiryDate: new Date("2020-01-01") } });
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyDocuments(fd({ applicationId })))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("DOC_VERIFICATION");
|
||||
|
||||
// Renew the document → advancement proceeds.
|
||||
await db.seafarerDocument.updateMany({ where: { crewMemberId }, data: { expiryDate: new Date("2030-01-01") } });
|
||||
expect("ok" in (await verifyDocuments(fd({ applicationId })))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("SALARY_AGREEMENT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejection", () => {
|
||||
it("MPO rejects from a mid stage", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
await setStage(applicationId, "DOC_VERIFICATION");
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await rejectApplication(applicationId, "Docs not in order"))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: applicationId } })).stage).toBe("REJECTED");
|
||||
});
|
||||
|
||||
it("site staff cannot drive the pipeline", async () => {
|
||||
const { applicationId } = await newApplication();
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await advanceStage(applicationId, "start_competency")).toEqual({ error: "Unauthorized" });
|
||||
expect(await rejectApplication(applicationId, "x")).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
108
App/tests/integration/appraisal.test.ts
Normal file
108
App/tests/integration/appraisal.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Integration tests for Crewing Phase 5b appraisal: the
|
||||
* raise (PM) → verify (MPO) → approve (Manager) lifecycle, with rejection paths
|
||||
* and role gating per §5.4/§6.
|
||||
*/
|
||||
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 { raiseAppraisal, verifyAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions";
|
||||
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@itapp2.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function assignment() {
|
||||
const c = await db.crewMember.create({ data: { name: "Appraisee", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
|
||||
return { crewId: c.id, assignmentId: a.id };
|
||||
}
|
||||
|
||||
async function raise(assignmentId: string) {
|
||||
as(siteStaffId, "SITE_STAFF"); // PM / site staff raise (raise_appraisal)
|
||||
const res = await raiseAppraisal(fd({ assignmentId, period: "2026", competence: "4", conduct: "5", safety: "4", comments: "Solid" }));
|
||||
if (!("ok" in res)) throw new Error("raise failed");
|
||||
return res.id!;
|
||||
}
|
||||
|
||||
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: "ITAPP2-SS", email: SS_EMAIL, name: "SS App2", 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.appraisal.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("appraisal lifecycle", () => {
|
||||
it("raise → verify (MPO) → approve (Manager)", async () => {
|
||||
const { assignmentId } = await assignment();
|
||||
const id = await raise(assignmentId);
|
||||
expect((await db.appraisal.findUniqueOrThrow({ where: { id } })).status).toBe("SUBMITTED");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await verifyAppraisal(id, true))).toBe(true);
|
||||
const verified = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
||||
expect(verified.status).toBe("MPO_VERIFIED");
|
||||
expect(verified.verifiedById).toBe(manningId);
|
||||
|
||||
// MPO cannot approve
|
||||
expect(await approveAppraisal(id, true)).not.toHaveProperty("ok");
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await approveAppraisal(id, true))).toBe(true);
|
||||
const approved = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
||||
expect(approved.status).toBe("MANAGER_APPROVED");
|
||||
expect(approved.approvedById).toBe(managerId);
|
||||
});
|
||||
|
||||
it("MPO rejects with remarks", async () => {
|
||||
const { assignmentId } = await assignment();
|
||||
const id = await raise(assignmentId);
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyAppraisal(id, false))).toBe(true); // remarks required
|
||||
expect("ok" in (await verifyAppraisal(id, false, "Incomplete"))).toBe(true);
|
||||
const a = await db.appraisal.findUniqueOrThrow({ where: { id } });
|
||||
expect(a.status).toBe("REJECTED");
|
||||
expect(a.rejectedReason).toBe("Incomplete");
|
||||
});
|
||||
|
||||
it("raise is rejected for a role without raise_appraisal (MPO)", async () => {
|
||||
const { assignmentId } = await assignment();
|
||||
as(manningId, "MANNING"); // MPO does not hold raise_appraisal
|
||||
expect(await raiseAppraisal(fd({ assignmentId, period: "2026" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("verify is rejected for a role without verify_appraisal (site staff)", async () => {
|
||||
const { assignmentId } = await assignment();
|
||||
const id = await raise(assignmentId);
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await verifyAppraisal(id, true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
173
App/tests/integration/candidates.test.ts
Normal file
173
App/tests/integration/candidates.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* 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";
|
||||
import React from "react";
|
||||
// The list page's JSX compiles to classic React.createElement in the node runner.
|
||||
(globalThis as unknown as { React: typeof React }).React = React;
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||
// We read the page element's props directly; the client component is irrelevant.
|
||||
vi.mock("@/app/(portal)/crewing/candidates/candidates-manager", () => ({ CandidatesManager: () => null }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { addCandidate, updateCandidate } from "@/app/(portal)/crewing/candidates/actions";
|
||||
import CandidatesPage from "@/app/(portal)/crewing/candidates/page";
|
||||
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("ex-hand recognition + ordering (B3)", () => {
|
||||
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
|
||||
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
||||
|
||||
// Re-applies as a fresh careers candidate with the same email → recognized.
|
||||
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
|
||||
expect("ok" in res && res.id).toBe(exhand.id);
|
||||
expect(await db.crewMember.count()).toBe(1); // no duplicate row
|
||||
|
||||
const after = await db.crewMember.findUniqueOrThrow({ where: { id: exhand.id }, include: { actions: true } });
|
||||
expect(after.status).toBe("EX_HAND");
|
||||
expect(after.appliedRankId).toBe(rankId);
|
||||
expect(after.experienceMonths).toBe(120); // prior history preserved (max)
|
||||
expect(after.actions.some((a) => a.actionType === "CANDIDATE_UPDATED")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
|
||||
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
|
||||
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
|
||||
expect("ok" in res && res.id).toBe(exhand.id);
|
||||
expect(await db.crewMember.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("does not match a different person → creates a new candidate", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
|
||||
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
|
||||
expect(await db.crewMember.count()).toBe(2);
|
||||
});
|
||||
|
||||
it("lists ex-hands above new candidates by default (AC2)", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
|
||||
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
|
||||
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
|
||||
expect(el.props.candidates[0].status).toBe("EX_HAND");
|
||||
expect(el.props.candidates[0].name).toBe("Ex Second");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
87
App/tests/integration/crew-pii-page.test.ts
Normal file
87
App/tests/integration/crew-pii-page.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Integration test for the server-side PII masking on the crew profile page.
|
||||
* Identity-document numbers (Aadhaar/PAN) must be masked BEFORE they cross to the
|
||||
* client component — full only for Accounts/SuperUser (Crewing-Implementation-Spec
|
||||
* §6 / Roles-and-Permissions §3). We invoke the server component and inspect the
|
||||
* props it hands to <CrewProfile>, so a regression that passes raw numbers to the
|
||||
* client is caught here.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
|
||||
// The integration runner compiles the page's JSX to classic React.createElement
|
||||
// without injecting React; provide it so invoking the server component works.
|
||||
(globalThis as unknown as { React: typeof React }).React = React;
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
||||
// The client component is irrelevant to this test — we read element.props directly.
|
||||
vi.mock("@/app/(portal)/crewing/crew/[id]/crew-profile", () => ({ CrewProfile: () => null }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import CrewProfilePage from "@/app/(portal)/crewing/crew/[id]/page";
|
||||
import { makeSession } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
const AADHAAR = "123456789012";
|
||||
const PAN = "ABCDE1234F";
|
||||
|
||||
let crewId: string;
|
||||
const as = (role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(`u-${role}`, role));
|
||||
|
||||
// Pull the documents prop the page would pass to the client component.
|
||||
async function docsFor(role: Role) {
|
||||
as(role);
|
||||
const element = (await CrewProfilePage({ params: Promise.resolve({ id: crewId }) })) as {
|
||||
props: { documents: Array<{ docType: string; number: string | null }> };
|
||||
};
|
||||
return element.props.documents;
|
||||
}
|
||||
const numberFor = (docs: Array<{ docType: string; number: string | null }>, docType: string) =>
|
||||
docs.find((d) => d.docType === docType)?.number ?? null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const c = await db.crewMember.create({
|
||||
data: { name: "PII Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-PII${Date.now() % 100000}` },
|
||||
});
|
||||
crewId = c.id;
|
||||
await db.seafarerDocument.createMany({
|
||||
data: [
|
||||
{ crewMemberId: c.id, docType: "AADHAAR", number: AADHAAR },
|
||||
{ crewMemberId: c.id, docType: "PAN", number: PAN },
|
||||
{ crewMemberId: c.id, docType: "PASSPORT", number: "P1234567" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
afterAll(async () => {
|
||||
await db.seafarerDocument.deleteMany({ where: { crewMemberId: crewId } });
|
||||
await db.crewMember.deleteMany({ where: { id: crewId } });
|
||||
});
|
||||
|
||||
describe("crew profile — identity-document masking (server-side)", () => {
|
||||
it("masks Aadhaar/PAN for a MANAGER", async () => {
|
||||
const docs = await docsFor("MANAGER");
|
||||
expect(numberFor(docs, "AADHAAR")).toBe("•••• 9012");
|
||||
expect(numberFor(docs, "PAN")).toBe("•••• 234F");
|
||||
// Non-identity documents are not restricted.
|
||||
expect(numberFor(docs, "PASSPORT")).toBe("P1234567");
|
||||
});
|
||||
|
||||
it("masks Aadhaar/PAN for SITE_STAFF and the MPO too", async () => {
|
||||
expect(numberFor(await docsFor("SITE_STAFF"), "AADHAAR")).toBe("•••• 9012");
|
||||
expect(numberFor(await docsFor("MANNING"), "PAN")).toBe("•••• 234F");
|
||||
});
|
||||
|
||||
it("shows Aadhaar/PAN in full to ACCOUNTS and SUPERUSER", async () => {
|
||||
const acc = await docsFor("ACCOUNTS");
|
||||
expect(numberFor(acc, "AADHAAR")).toBe(AADHAAR);
|
||||
expect(numberFor(acc, "PAN")).toBe(PAN);
|
||||
expect(numberFor(await docsFor("SUPERUSER"), "AADHAAR")).toBe(AADHAAR);
|
||||
});
|
||||
});
|
||||
136
App/tests/integration/crew-records.test.ts
Normal file
136
App/tests/integration/crew-records.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Integration tests for the Crewing Phase 4a crew-records actions (documents,
|
||||
* bank/EPF, next of kin, PPE, experience). The records tables are new this phase,
|
||||
* so afterEach wipes them.
|
||||
*/
|
||||
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 {
|
||||
uploadDocument, deleteDocument, saveBankEpf,
|
||||
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience,
|
||||
} from "@/app/(portal)/crewing/crew/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let accountsId: string;
|
||||
let siteStaffId: string;
|
||||
let crewId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itcrew.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;
|
||||
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
||||
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITCREW-SS", email: SS_EMAIL, name: "SS Crew", role: "SITE_STAFF" } });
|
||||
siteStaffId = ss.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.seafarerDocument.deleteMany({});
|
||||
await db.nextOfKin.deleteMany({});
|
||||
await db.ppeIssue.deleteMany({});
|
||||
await db.experienceRecord.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
async function makeCrew() {
|
||||
const c = await db.crewMember.create({ data: { name: "Active Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-T${Date.now() % 100000}` } });
|
||||
crewId = c.id;
|
||||
return c.id;
|
||||
}
|
||||
|
||||
describe("documents", () => {
|
||||
it("uploads and removes a document (with audit)", async () => {
|
||||
const id = await makeCrew();
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT", number: "P123", expiryDate: "2030-01-01" })))).toBe(true);
|
||||
const doc = await db.seafarerDocument.findFirstOrThrow({ where: { crewMemberId: id } });
|
||||
expect(doc.docType).toBe("PASSPORT");
|
||||
expect(await db.crewAction.count({ where: { actionType: "DOCUMENT_UPLOADED" } })).toBe(1);
|
||||
|
||||
expect("ok" in (await deleteDocument(doc.id))).toBe(true);
|
||||
expect(await db.seafarerDocument.count({ where: { crewMemberId: id } })).toBe(0);
|
||||
// Deletions of PII-bearing records are audited (M3).
|
||||
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("is rejected for a role without upload_crew_records (accounts)", async () => {
|
||||
const id = await makeCrew();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await uploadDocument(fd({ crewMemberId: id, docType: "PASSPORT" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank & EPF", () => {
|
||||
it("upserts bank and EPF details", async () => {
|
||||
const id = await makeCrew();
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await saveBankEpf(fd({ crewMemberId: id, accountNumber: "999888777", ifsc: "HDFC0009", uan: "UAN-1" })))).toBe(true);
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("999888777");
|
||||
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).uan).toBe("UAN-1");
|
||||
// Upsert again updates rather than duplicating.
|
||||
await saveBankEpf(fd({ crewMemberId: id, accountNumber: "111", ifsc: "X", uan: "UAN-2" }));
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: id } })).accountNumber).toBe("111");
|
||||
expect(await db.bankDetail.count({ where: { crewMemberId: id } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next of kin", () => {
|
||||
it("adds an emergency contact", async () => {
|
||||
const id = await makeCrew();
|
||||
as(siteStaffId, "SITE_STAFF"); // site staff can upload crew records
|
||||
expect("ok" in (await addNextOfKin(fd({ crewMemberId: id, name: "Spouse", relationship: "Wife", isEmergency: "true" })))).toBe(true);
|
||||
const nok = await db.nextOfKin.findFirstOrThrow({ where: { crewMemberId: id } });
|
||||
expect(nok.isEmergency).toBe(true);
|
||||
// Removal is audited (M3).
|
||||
expect("ok" in (await deleteNextOfKin(nok.id))).toBe(true);
|
||||
expect(await db.nextOfKin.count({ where: { crewMemberId: id } })).toBe(0);
|
||||
expect(await db.crewAction.count({ where: { crewMemberId: id, actionType: "RECORD_DELETED" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PPE", () => {
|
||||
it("issues PPE then marks it returned", async () => {
|
||||
const id = await makeCrew();
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect("ok" in (await issuePpe(fd({ crewMemberId: id, item: "SAFETY_SHOES", size: "9", quantity: "1" })))).toBe(true);
|
||||
const ppe = await db.ppeIssue.findFirstOrThrow({ where: { crewMemberId: id } });
|
||||
expect(ppe.returnedDate).toBeNull();
|
||||
expect("ok" in (await returnPpe(ppe.id))).toBe(true);
|
||||
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).returnedDate).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is rejected for a role without issue_ppe (accounts)", async () => {
|
||||
const id = await makeCrew();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await issuePpe(fd({ crewMemberId: id, item: "HELMET" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("experience", () => {
|
||||
it("adds a declared experience record", async () => {
|
||||
const id = await makeCrew();
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await addExperience(fd({ crewMemberId: id, vesselType: "Dredger", durationMonths: "36" })))).toBe(true);
|
||||
const e = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: id } });
|
||||
expect(e.source).toBe("declared");
|
||||
expect(e.durationMonths).toBe(36);
|
||||
});
|
||||
});
|
||||
134
App/tests/integration/crewing-admin.test.ts
Normal file
134
App/tests/integration/crewing-admin.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Integration tests for the crewing-admin actions: admin crew CRUD, Manager
|
||||
* direct placement (no requisition), and per-vessel/per-rank strength config —
|
||||
* all gated by the new manage_crew permission.
|
||||
*/
|
||||
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 { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } from "@/app/(portal)/admin/crew/actions";
|
||||
import { upsertRequirement, deleteRequirement } from "@/app/(portal)/admin/crew-strength/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let adminId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itadm.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;
|
||||
adminId = (await getSeedUser("admin@pelagia.local")).id;
|
||||
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITADM-SS", email: SS_EMAIL, name: "SS Adm", 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.crewAssignment.deleteMany({});
|
||||
await db.vesselRankRequirement.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("admin crew CRUD (manage_crew)", () => {
|
||||
it("admin creates and edits a crew member", async () => {
|
||||
as(adminId, "ADMIN");
|
||||
const res = await createCrewMember(fd({ name: "Direct Hire", status: "CANDIDATE", source: "WALK_IN" }));
|
||||
expect("ok" in res && res.ok).toBe(true);
|
||||
const c = await db.crewMember.findFirstOrThrow({ where: { name: "Direct Hire" } });
|
||||
expect(c.source).toBe("WALK_IN");
|
||||
|
||||
await updateCrewMember(fd({ id: c.id, name: "Direct Hire", status: "BLACKLISTED", source: "WALK_IN" }));
|
||||
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("BLACKLISTED");
|
||||
});
|
||||
|
||||
it("is rejected for roles without manage_crew (site staff)", async () => {
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await createCrewMember(fd({ name: "Nope" }))).toEqual({ error: "Unauthorized" });
|
||||
expect(await db.crewMember.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("blocks deletion of crew with assignments", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
const c = await db.crewMember.create({ data: { name: "Has Assignment", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } });
|
||||
expect("error" in (await deleteCrewMember(c.id))).toBe(true);
|
||||
expect(await db.crewMember.findUnique({ where: { id: c.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a crew member with no assignments/applications", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
const c = await db.crewMember.create({ data: { name: "Removable", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
||||
expect("ok" in (await deleteCrewMember(c.id))).toBe(true);
|
||||
expect(await db.crewMember.findUnique({ where: { id: c.id } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("direct placement (Manager, no requisition)", () => {
|
||||
it("places a candidate → ACTIVE assignment + promoted to EMPLOYEE with a CRW- number", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
const c = await db.crewMember.create({ data: { name: "To Place", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
||||
const res = await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" }));
|
||||
expect("ok" in res && res.ok).toBe(true);
|
||||
|
||||
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: c.id } });
|
||||
expect(assignment.status).toBe("ACTIVE");
|
||||
expect(assignment.requisitionId).toBeNull(); // no requisition
|
||||
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } });
|
||||
expect(after.status).toBe("EMPLOYEE");
|
||||
expect(after.employeeId).toMatch(/^CRW-\d+$/);
|
||||
expect(after.currentRankId).toBe(rankId);
|
||||
});
|
||||
|
||||
it("refuses to place crew that already has an active assignment", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
const c = await db.crewMember.create({ data: { name: "Already Placed", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date(), crewMemberId: c.id, rankId, vesselId } });
|
||||
expect("error" in (await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" })))).toBe(true);
|
||||
});
|
||||
|
||||
it("is rejected for roles without manage_crew", async () => {
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
const c = await db.crewMember.create({ data: { name: "X", status: "CANDIDATE", type: "NEW", source: "CAREERS" } });
|
||||
expect(await placeCrew(fd({ crewMemberId: c.id, rankId, vesselId, signOnDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("crew strength config (manage_crew)", () => {
|
||||
it("upserts and removes a vessel/rank requirement", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await upsertRequirement(fd({ vesselId, rankId, minStrength: "3" })))).toBe(true);
|
||||
let req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } });
|
||||
expect(req.minStrength).toBe(3);
|
||||
// Upsert updates in place.
|
||||
await upsertRequirement(fd({ vesselId, rankId, minStrength: "5" }));
|
||||
req = await db.vesselRankRequirement.findUniqueOrThrow({ where: { vesselId_rankId: { vesselId, rankId } } });
|
||||
expect(req.minStrength).toBe(5);
|
||||
expect(await db.vesselRankRequirement.count()).toBe(1);
|
||||
|
||||
expect("ok" in (await deleteRequirement(req.id))).toBe(true);
|
||||
expect(await db.vesselRankRequirement.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is rejected for roles without manage_crew", async () => {
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await upsertRequirement(fd({ vesselId, rankId, minStrength: "2" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
115
App/tests/integration/crewing-followups.test.ts
Normal file
115
App/tests/integration/crewing-followups.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Integration tests for the self-contained crewing follow-ups:
|
||||
* - SITE_STAFF login creation on placement/onboarding (grantsLogin ranks)
|
||||
* - PPE / next-of-kin verification gates
|
||||
* (Own-site scoping is exercised via the siteId set on the created login.)
|
||||
*/
|
||||
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 { placeCrew } from "@/app/(portal)/admin/crew/actions";
|
||||
import { verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let manningId: string;
|
||||
let accountsId: string;
|
||||
let loginRankId: string;
|
||||
let plainRankId: string;
|
||||
let siteId: string;
|
||||
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
const LOGIN_EMAIL = "pmlogin.itfu@example.local";
|
||||
|
||||
beforeAll(async () => {
|
||||
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||
manningId = (await getSeedUser("manning@pelagia.local")).id;
|
||||
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
||||
loginRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: true } })).id;
|
||||
plainRankId = (await db.rank.findFirstOrThrow({ where: { grantsLogin: false } })).id;
|
||||
siteId = (await db.site.findFirstOrThrow()).id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.ppeIssue.deleteMany({});
|
||||
await db.nextOfKin.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
await db.user.deleteMany({ where: { email: LOGIN_EMAIL } });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: LOGIN_EMAIL } });
|
||||
});
|
||||
|
||||
describe("SITE_STAFF login on placement (grantsLogin ranks)", () => {
|
||||
it("creates a SITE_STAFF login (with home site) for a management-rank placement", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "New PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } });
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true);
|
||||
|
||||
const after = await db.crewMember.findUniqueOrThrow({ where: { id: c.id } });
|
||||
const login = await db.user.findUniqueOrThrow({ where: { email: LOGIN_EMAIL } });
|
||||
expect(login.role).toBe("SITE_STAFF");
|
||||
expect(login.employeeId).toBe(after.employeeId); // shares the CRW- number
|
||||
expect(login.passwordHash).toBeNull();
|
||||
expect(login.siteId).toBe(siteId); // own-site link set at creation
|
||||
});
|
||||
|
||||
it("creates no login for a non-login rank", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "Deck Hand", status: "CANDIDATE", type: "NEW", source: "WALK_IN", email: LOGIN_EMAIL } });
|
||||
as(managerId, "MANAGER");
|
||||
await placeCrew(fd({ crewMemberId: c.id, rankId: plainRankId, siteId, signOnDate: "2026-07-01" }));
|
||||
expect(await db.user.findUnique({ where: { email: LOGIN_EMAIL } })).toBeNull();
|
||||
});
|
||||
|
||||
it("skips the login when the crew member has no email (placement still succeeds)", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "No Email PM", status: "CANDIDATE", type: "NEW", source: "WALK_IN" } });
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await placeCrew(fd({ crewMemberId: c.id, rankId: loginRankId, siteId, signOnDate: "2026-07-01" })))).toBe(true);
|
||||
expect((await db.crewMember.findUniqueOrThrow({ where: { id: c.id } })).status).toBe("EMPLOYEE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PPE / next-of-kin verification (MPO)", () => {
|
||||
async function crewWithRecords() {
|
||||
const c = await db.crewMember.create({ data: { name: "Verify Me", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "HELMET" } });
|
||||
const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse" } });
|
||||
return { ppeId: ppe.id, nokId: nok.id };
|
||||
}
|
||||
|
||||
it("MPO verifies PPE and next-of-kin", async () => {
|
||||
const { ppeId, nokId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await verifyPpe(ppeId, true))).toBe(true);
|
||||
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppeId } })).verificationStatus).toBe("VERIFIED");
|
||||
expect("ok" in (await verifyNextOfKin(nokId, true))).toBe(true);
|
||||
expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nokId } })).verificationStatus).toBe("VERIFIED");
|
||||
});
|
||||
|
||||
it("rejection requires a reason; already-decided is guarded", async () => {
|
||||
const { ppeId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyPpe(ppeId, false))).toBe(true);
|
||||
expect("ok" in (await verifyPpe(ppeId, false, "Wrong size"))).toBe(true);
|
||||
expect("error" in (await verifyPpe(ppeId, true))).toBe(true); // already rejected
|
||||
});
|
||||
|
||||
it("is rejected for roles without verify_site_records (accounts)", async () => {
|
||||
const { ppeId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await verifyPpe(ppeId, true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
213
App/tests/integration/crewing-gates.test.ts
Normal file
213
App/tests/integration/crewing-gates.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Integration tests that lock in the Manager-only "return/decline" gates and the
|
||||
* remaining verification gates across the crewing pipeline — the reconciliation
|
||||
* rulings most likely to regress silently:
|
||||
* - R8: salary/selection approval (and their *returns*) are Manager-only.
|
||||
* - R2: an interview waiver can never reach a NEW candidate by any path.
|
||||
* - R11/§8.11: PPE / next-of-kin verify gates (MPO) + bank reject-with-remarks.
|
||||
* - §5.4/H3: only an MPO_VERIFIED appraisal can be Manager-approved.
|
||||
* Forward happy-paths are already covered by applications/verification/appraisal
|
||||
* suites; these focus on the negative and role-gating edges.
|
||||
*/
|
||||
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 {
|
||||
returnSalary,
|
||||
returnSelection,
|
||||
requestInterviewWaiver,
|
||||
declineInterviewWaiver,
|
||||
} from "@/app/(portal)/crewing/applications/actions";
|
||||
import { verifyBankEpf, verifyPpe, verifyNextOfKin } from "@/app/(portal)/crewing/verification/actions";
|
||||
import { raiseAppraisal, approveAppraisal } from "@/app/(portal)/crewing/appraisals/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { ApplicationStage, GateResult, Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let manningId: string;
|
||||
let accountsId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itgates.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
let seq = 0;
|
||||
async function applicationAt(
|
||||
stage: ApplicationStage,
|
||||
opts: { type?: "NEW" | "EX_HAND"; interviewResult?: "PENDING" | "ACCEPTED" } = {}
|
||||
) {
|
||||
seq += 1;
|
||||
const req = await db.requisition.create({ data: { code: `REQ-G${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } });
|
||||
const cand = await db.crewMember.create({
|
||||
data: {
|
||||
name: opts.type === "EX_HAND" ? "Ex G" : "New G",
|
||||
type: opts.type ?? "NEW",
|
||||
status: opts.type === "EX_HAND" ? "EX_HAND" : "CANDIDATE",
|
||||
source: opts.type === "EX_HAND" ? "EX_HAND" : "CAREERS",
|
||||
appliedRankId: rankId,
|
||||
},
|
||||
});
|
||||
const app = await db.application.create({
|
||||
data: { requisitionId: req.id, crewMemberId: cand.id, stage, type: opts.type ?? "NEW", interviewResult: opts.interviewResult ?? "PENDING" },
|
||||
});
|
||||
return { appId: app.id, reqId: req.id, candId: cand.id };
|
||||
}
|
||||
|
||||
const gate = (applicationId: string, gateType: "SALARY" | "SELECTION" | "WAIVER", result: GateResult = "PENDING") =>
|
||||
db.applicationGate.create({ data: { applicationId, gate: gateType, result } });
|
||||
|
||||
beforeAll(async () => {
|
||||
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||
manningId = (await getSeedUser("manning@pelagia.local")).id;
|
||||
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
||||
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITGATES-SS", email: SS_EMAIL, name: "SS Gates", 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.appraisal.deleteMany({});
|
||||
await db.salaryStructure.deleteMany({});
|
||||
await db.applicationGate.deleteMany({});
|
||||
await db.referenceCheck.deleteMany({});
|
||||
await db.application.deleteMany({});
|
||||
await db.nextOfKin.deleteMany({});
|
||||
await db.ppeIssue.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("salary return is Manager-only and audited (R8)", () => {
|
||||
it("MPO cannot return salary; Manager needs a reason; reason rejects the SALARY gate", async () => {
|
||||
const { appId } = await applicationAt("SALARY_AGREEMENT");
|
||||
await db.salaryStructure.create({ data: { applicationId: appId, rateBasis: "MONTHLY", basic: 60000 } });
|
||||
await gate(appId, "SALARY");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect(await returnSalary(appId, "Too high")).toEqual({ error: "Unauthorized" });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("error" in (await returnSalary(appId, " "))).toBe(true); // reason required
|
||||
expect("ok" in (await returnSalary(appId, "Re-negotiate basic"))).toBe(true);
|
||||
|
||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SALARY" } })).result).toBe("REJECTED");
|
||||
// Audited as a return, not as a forward "salary agreed".
|
||||
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SALARY_RETURNED" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection return is Manager-only (R8)", () => {
|
||||
it("MPO cannot return a selection; Manager return resets the interview result and rejects the gate", async () => {
|
||||
const { appId } = await applicationAt("INTERVIEW", { interviewResult: "ACCEPTED" });
|
||||
await gate(appId, "SELECTION");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect(await returnSelection(appId, "Reconsider")).toEqual({ error: "Unauthorized" });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await returnSelection(appId, "Pending references"))).toBe(true);
|
||||
const app = await db.application.findUniqueOrThrow({ where: { id: appId } });
|
||||
expect(app.interviewResult).toBe("PENDING");
|
||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "SELECTION" } })).result).toBe("REJECTED");
|
||||
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "SELECTION_RETURNED" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interview waiver can never reach a NEW candidate (R2)", () => {
|
||||
it("the Manager cannot request a waiver (no request_interview_waiver) and NEW stays un-waived", async () => {
|
||||
const { appId } = await applicationAt("INTERVIEW", { type: "NEW" });
|
||||
// Manager lacks request_interview_waiver entirely.
|
||||
as(managerId, "MANAGER");
|
||||
expect(await requestInterviewWaiver(appId)).toEqual({ error: "Unauthorized" });
|
||||
// MPO can request, but the candidate type blocks it for a NEW hand.
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await requestInterviewWaiver(appId))).toBe(true);
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).interviewWaived).toBe(false);
|
||||
});
|
||||
|
||||
it("declining a waiver is Manager-only, needs a reason, and rejects the WAIVER gate", async () => {
|
||||
const { appId } = await applicationAt("INTERVIEW", { type: "EX_HAND" });
|
||||
await gate(appId, "WAIVER");
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect(await declineInterviewWaiver(appId, "No")).toEqual({ error: "Unauthorized" });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("error" in (await declineInterviewWaiver(appId, " "))).toBe(true); // reason required
|
||||
expect("ok" in (await declineInterviewWaiver(appId, "Interview required"))).toBe(true);
|
||||
expect((await db.applicationGate.findFirstOrThrow({ where: { applicationId: appId, gate: "WAIVER" } })).result).toBe("REJECTED");
|
||||
expect(await db.crewAction.count({ where: { applicationId: appId, actionType: "WAIVER_DECLINED" } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank verification reject path (Accounts, §8.11)", () => {
|
||||
it("rejecting bank details requires remarks and sets REJECTED", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "Bank Reject", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "999", ifsc: "ICIC0001" } });
|
||||
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect("error" in (await verifyBankEpf(c.id, "bank", false))).toBe(true); // remarks required
|
||||
expect("ok" in (await verifyBankEpf(c.id, "bank", false, "Name mismatch"))).toBe(true);
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: c.id } })).verificationStatus).toBe("REJECTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PPE & next-of-kin verify gates (MPO, §8.11 follow-up)", () => {
|
||||
it("MPO verifies a next-of-kin record; site staff and Accounts cannot", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "NoK Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const nok = await db.nextOfKin.create({ data: { crewMemberId: c.id, name: "Spouse", relationship: "Wife", isEmergency: true } });
|
||||
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" });
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await verifyNextOfKin(nok.id, true)).toEqual({ error: "Unauthorized" });
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await verifyNextOfKin(nok.id, true))).toBe(true);
|
||||
expect((await db.nextOfKin.findUniqueOrThrow({ where: { id: nok.id } })).verificationStatus).toBe("VERIFIED");
|
||||
});
|
||||
|
||||
it("MPO rejects a PPE issue only with remarks", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "PPE Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const ppe = await db.ppeIssue.create({ data: { crewMemberId: c.id, item: "BOILER_SUIT", size: "L" } });
|
||||
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyPpe(ppe.id, false))).toBe(true); // remarks required
|
||||
expect("ok" in (await verifyPpe(ppe.id, false, "Wrong size logged"))).toBe(true);
|
||||
expect((await db.ppeIssue.findUniqueOrThrow({ where: { id: ppe.id } })).verificationStatus).toBe("REJECTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("appraisal approval requires MPO verification first (H3)", () => {
|
||||
it("a SUBMITTED appraisal cannot be Manager-approved without MPO verification", async () => {
|
||||
const c = await db.crewMember.create({ data: { name: "Appraisee G", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const assignment = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
const raised = await raiseAppraisal(fd({ assignmentId: assignment.id, period: "2026", competence: "4", conduct: "4", safety: "4" }));
|
||||
if (!("ok" in raised)) throw new Error("raise failed");
|
||||
|
||||
// Straight to Manager approve, skipping MPO verify → blocked by the state machine.
|
||||
as(managerId, "MANAGER");
|
||||
expect("error" in (await approveAppraisal(raised.id!, true))).toBe(true);
|
||||
expect((await db.appraisal.findUniqueOrThrow({ where: { id: raised.id! } })).status).toBe("SUBMITTED");
|
||||
});
|
||||
});
|
||||
93
App/tests/integration/epfo.test.ts
Normal file
93
App/tests/integration/epfo.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* EPFO assisted-verification coverage:
|
||||
* - the EpfoService deterministic STUB contract the app relies on (no live
|
||||
* portal): OTP 000000 → matched; UAN/OTP validation; session expiry.
|
||||
* - the Next proxy routes' verify_bank_epf permission gate (§6) — only Accounts
|
||||
* (or SuperUser) may reach the upstream service.
|
||||
* No EPFO_LIVE, no running service: the stub logic is imported directly and the
|
||||
* upstream fetch is mocked.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { POST as otpPOST } from "@/app/api/epfo/otp/route";
|
||||
import { POST as verifyPOST } from "@/app/api/epfo/route";
|
||||
import { stubOtp, stubVerify, isUan, STUB_MATCH_OTP } from "../../../EpfoService/src/stub";
|
||||
import { makeSession } from "./helpers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
const UAN = "100200300400";
|
||||
const as = (role: Role | null) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(role ? makeSession(`u-${role}`, role) : null);
|
||||
|
||||
// Minimal NextRequest stand-in: the handlers only call req.json().
|
||||
const req = (body: unknown) => ({ json: async () => body } as unknown as NextRequest);
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("EpfoService stub contract", () => {
|
||||
it("stubOtp validates the 12-digit UAN and opens a session", () => {
|
||||
const ok = stubOtp(UAN, "sess-1");
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toMatchObject({ sessionId: "sess-1", stub: true });
|
||||
expect(stubOtp("123", "sess-1").status).toBe(400); // too short
|
||||
expect(stubOtp(undefined, "sess-1").status).toBe(400);
|
||||
expect(isUan(UAN)).toBe(true);
|
||||
expect(isUan("12345678901")).toBe(false);
|
||||
});
|
||||
|
||||
it("stubVerify matches only OTP 000000 and validates session/uan/otp", () => {
|
||||
const session = { uan: UAN };
|
||||
const matched = stubVerify(session, UAN, STUB_MATCH_OTP);
|
||||
expect(matched.status).toBe(200);
|
||||
expect(matched.body).toMatchObject({ matched: true, name: "EPFO Member (stub)", status: "ACTIVE" });
|
||||
|
||||
const wrong = stubVerify(session, UAN, "123456");
|
||||
expect(wrong.body).toMatchObject({ matched: false, name: null });
|
||||
|
||||
expect(stubVerify(undefined, UAN, STUB_MATCH_OTP).status).toBe(410); // expired/unknown session
|
||||
expect(stubVerify(session, "999999999999", STUB_MATCH_OTP).status).toBe(400); // UAN mismatch
|
||||
expect(stubVerify(session, UAN, "12").status).toBe(400); // OTP too short
|
||||
expect(stubVerify(session, UAN, "abcd").status).toBe(400); // non-numeric OTP
|
||||
});
|
||||
});
|
||||
|
||||
describe("EPFO proxy routes — verify_bank_epf gate (§6)", () => {
|
||||
it("rejects an unauthenticated caller (401) on both routes", async () => {
|
||||
as(null);
|
||||
expect((await otpPOST(req({ uan: UAN }))).status).toBe(401);
|
||||
expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("forbids a role without verify_bank_epf (MPO → 403)", async () => {
|
||||
as("MANNING");
|
||||
expect((await otpPOST(req({ uan: UAN }))).status).toBe(403);
|
||||
expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("lets Accounts through to the upstream service (mocked)", async () => {
|
||||
as("ACCOUNTS");
|
||||
const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ sessionId: "epfo_1", mobileHint: "••••••••", stub: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
);
|
||||
const res = await otpPOST(req({ uan: UAN }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({ sessionId: "epfo_1" });
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it("validates the body before calling upstream (Accounts, missing fields → 400)", async () => {
|
||||
as("ACCOUNTS");
|
||||
const fetchMock = vi.spyOn(global, "fetch");
|
||||
expect((await otpPOST(req({}))).status).toBe(400);
|
||||
expect((await verifyPOST(req({ uan: UAN }))).status).toBe(400); // no sessionId/otp
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
152
App/tests/integration/leave-attendance.test.ts
Normal file
152
App/tests/integration/leave-attendance.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Integration tests for Crewing Phase 4b leave & attendance: apply/decide leave
|
||||
* (Manager), the clash auto-backfill (required strength = 1), and attendance
|
||||
* recording with MPO/Manager lockout.
|
||||
*/
|
||||
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 { applyLeave, decideLeave } from "@/app/(portal)/crewing/leave/actions";
|
||||
import { saveAttendance } from "@/app/(portal)/crewing/attendance/actions";
|
||||
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@itla.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function makeAssignment(name: string, rId = rankId) {
|
||||
const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
return db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId } });
|
||||
}
|
||||
|
||||
async function applyAndGetId(assignmentId: string, from = "2026-07-01", to = "2026-07-10") {
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
const res = await applyLeave(fd({ assignmentId, type: "ANNUAL", fromDate: from, toDate: to }));
|
||||
if (!("ok" in res)) throw new Error("applyLeave failed");
|
||||
return res.id!;
|
||||
}
|
||||
|
||||
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: "ITLA-SS", email: SS_EMAIL, name: "SS LA", 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.attendance.deleteMany({});
|
||||
await db.leaveRequest.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.vesselRankRequirement.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("apply / decide leave", () => {
|
||||
it("site staff apply, Manager approves → assignment ON_LEAVE", async () => {
|
||||
const a = await makeAssignment("Solo Crew");
|
||||
const leaveId = await applyAndGetId(a.id);
|
||||
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPLIED");
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await decideLeave(leaveId, true))).toBe(true);
|
||||
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("APPROVED");
|
||||
expect((await db.crewAssignment.findUniqueOrThrow({ where: { id: a.id } })).status).toBe("ON_LEAVE");
|
||||
});
|
||||
|
||||
it("apply is rejected for the MPO (no apply_leave)", async () => {
|
||||
const a = await makeAssignment("X");
|
||||
as(manningId, "MANNING");
|
||||
expect(await applyLeave(fd({ assignmentId: a.id, fromDate: "2026-07-01", toDate: "2026-07-02" }))).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
|
||||
it("decline requires a reason and is Manager-only", async () => {
|
||||
const a = await makeAssignment("Y");
|
||||
const leaveId = await applyAndGetId(a.id);
|
||||
as(managerId, "MANAGER");
|
||||
expect("error" in (await decideLeave(leaveId, false, " "))).toBe(true);
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await decideLeave(leaveId, false, "no")).toEqual({ error: "Unauthorized" });
|
||||
as(managerId, "MANAGER");
|
||||
expect("ok" in (await decideLeave(leaveId, false, "Operational needs"))).toBe(true);
|
||||
expect((await db.leaveRequest.findUniqueOrThrow({ where: { id: leaveId } })).status).toBe("REJECTED");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clash auto-backfill (required strength = 1)", () => {
|
||||
it("auto-raises a LEAVE requisition when the only same-rank cover goes on leave", async () => {
|
||||
const a = await makeAssignment("Only One");
|
||||
const leaveId = await applyAndGetId(a.id);
|
||||
as(managerId, "MANAGER");
|
||||
await decideLeave(leaveId, true);
|
||||
|
||||
const req = await db.requisition.findFirst({ where: { autoRaised: true } });
|
||||
expect(req).not.toBeNull();
|
||||
expect(req!.reason).toBe("LEAVE");
|
||||
expect(req!.rankId).toBe(rankId);
|
||||
expect(req!.vesselId).toBe(vesselId);
|
||||
});
|
||||
|
||||
it("does NOT auto-raise when another active same-rank crew remains (default strength 1)", async () => {
|
||||
const a = await makeAssignment("Going On Leave");
|
||||
await makeAssignment("Stays Active"); // same rank + vessel, active
|
||||
const leaveId = await applyAndGetId(a.id);
|
||||
as(managerId, "MANAGER");
|
||||
await decideLeave(leaveId, true);
|
||||
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
|
||||
});
|
||||
|
||||
it("auto-raises when a configured required strength exceeds the remaining cover (Option A)", async () => {
|
||||
// Require 2 of this rank on the vessel; with one remaining after leave → clash.
|
||||
await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } });
|
||||
const a = await makeAssignment("Going On Leave");
|
||||
await makeAssignment("Stays Active");
|
||||
const leaveId = await applyAndGetId(a.id);
|
||||
as(managerId, "MANAGER");
|
||||
await decideLeave(leaveId, true);
|
||||
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attendance", () => {
|
||||
it("site staff record attendance (upsert)", async () => {
|
||||
const a = await makeAssignment("Marked");
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect("ok" in (await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }, { date: "2026-07-02", status: "ABSENT" }]))).toBe(true);
|
||||
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2);
|
||||
// Re-saving the same day updates rather than duplicating.
|
||||
await saveAttendance(a.id, [{ date: "2026-07-01", status: "HALF_DAY" }]);
|
||||
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(2);
|
||||
expect((await db.attendance.findFirstOrThrow({ where: { assignmentId: a.id, status: "HALF_DAY" } })).status).toBe("HALF_DAY");
|
||||
});
|
||||
|
||||
it("the MPO and the Manager cannot record attendance (R5/§6)", async () => {
|
||||
const a = await makeAssignment("NoMark");
|
||||
as(manningId, "MANNING");
|
||||
expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" });
|
||||
as(managerId, "MANAGER");
|
||||
expect(await saveAttendance(a.id, [{ date: "2026-07-01", status: "PRESENT" }])).toEqual({ error: "Unauthorized" });
|
||||
expect(await db.attendance.count({ where: { assignmentId: a.id } })).toBe(0);
|
||||
});
|
||||
});
|
||||
149
App/tests/integration/leave-clash.test.ts
Normal file
149
App/tests/integration/leave-clash.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Integration tests for the Crewing R6 leave-clash detection
|
||||
* (Crewing-Implementation-Spec §5.3 / Epic A5, Option A). The existing
|
||||
* leave-attendance suite covers the all-active cases (strength 1 + a configured
|
||||
* strength 2); these lock in the parts of `leaveCausesClash` that those don't
|
||||
* exercise — the overlapping-leave cover subtraction and the date-overlap
|
||||
* predicate — so an approved leave only auto-raises a backfill requisition when
|
||||
* the *available* same-rank cover over the *window* actually drops below the
|
||||
* required strength.
|
||||
*/
|
||||
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 { applyLeave, decideLeave } from "@/app/(portal)/crewing/leave/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let otherRankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itclash.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function makeAssignment(name: string, rId = rankId, status: "ACTIVE" | "ON_LEAVE" = "ACTIVE") {
|
||||
const cm = await db.crewMember.create({ data: { name, status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
return db.crewAssignment.create({
|
||||
data: { status, signOnDate: new Date("2026-01-01"), crewMemberId: cm.id, rankId: rId, vesselId },
|
||||
});
|
||||
}
|
||||
|
||||
// Seed a pre-existing APPROVED leave directly (bypasses the apply/decide flow so
|
||||
// the window can be controlled precisely without side effects on this run).
|
||||
async function approvedLeave(assignmentId: string, from: string, to: string) {
|
||||
return db.leaveRequest.create({
|
||||
data: {
|
||||
assignmentId,
|
||||
type: "ANNUAL",
|
||||
fromDate: new Date(from),
|
||||
toDate: new Date(to),
|
||||
status: "APPROVED",
|
||||
appliedById: siteStaffId,
|
||||
decidedById: managerId,
|
||||
decidedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function applyAndApprove(assignmentId: string, from = "2026-07-01", to = "2026-07-10") {
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
const res = await applyLeave(fd({ assignmentId, type: "ANNUAL", fromDate: from, toDate: to }));
|
||||
if (!("ok" in res)) throw new Error("applyLeave failed");
|
||||
as(managerId, "MANAGER");
|
||||
await decideLeave(res.id!, true);
|
||||
}
|
||||
|
||||
const autoRaisedCount = () => db.requisition.count({ where: { autoRaised: true } });
|
||||
|
||||
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: "ITCLASH-SS", email: SS_EMAIL, name: "SS Clash", role: "SITE_STAFF" },
|
||||
});
|
||||
siteStaffId = ss.id;
|
||||
const ranks = await db.rank.findMany({ take: 2, orderBy: { name: "asc" } });
|
||||
rankId = ranks[0].id;
|
||||
otherRankId = ranks[1]?.id ?? ranks[0].id;
|
||||
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.leaveRequest.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.vesselRankRequirement.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("clash — overlapping-leave cover subtraction (strength 1)", () => {
|
||||
it("auto-raises when the only other same-rank crew is already on OVERLAPPING approved leave", async () => {
|
||||
const a = await makeAssignment("Going On Leave");
|
||||
const b = await makeAssignment("Already On Leave");
|
||||
// B is already away across A's window → B is not available cover.
|
||||
await approvedLeave(b.id, "2026-07-05", "2026-07-20");
|
||||
|
||||
await applyAndApprove(a.id, "2026-07-01", "2026-07-10");
|
||||
|
||||
expect(await autoRaisedCount()).toBe(1);
|
||||
const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } });
|
||||
expect(req.reason).toBe("LEAVE");
|
||||
expect(req.rankId).toBe(rankId);
|
||||
expect(req.vesselId).toBe(vesselId);
|
||||
});
|
||||
|
||||
it("does NOT auto-raise when the other crew's approved leave does NOT overlap the window", async () => {
|
||||
const a = await makeAssignment("Going On Leave");
|
||||
const b = await makeAssignment("Away Later");
|
||||
// B's leave is in August — it does not overlap A's July window, so B still
|
||||
// covers the rank during A's absence.
|
||||
await approvedLeave(b.id, "2026-08-01", "2026-08-31");
|
||||
|
||||
await applyAndApprove(a.id, "2026-07-01", "2026-07-10");
|
||||
|
||||
expect(await autoRaisedCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clash — rank + strength scoping", () => {
|
||||
it("ignores cover from a DIFFERENT rank on the same vessel", async () => {
|
||||
const a = await makeAssignment("Solo In Rank");
|
||||
// A different-rank crew member is not cover for A's rank.
|
||||
await makeAssignment("Other Rank", otherRankId);
|
||||
|
||||
await applyAndApprove(a.id);
|
||||
|
||||
// With no same-rank cover left, the default-strength-1 clash fires
|
||||
// (unless the two seeded ranks happen to be identical in a thin DB).
|
||||
expect(await autoRaisedCount()).toBe(rankId === otherRankId ? 0 : 1);
|
||||
});
|
||||
|
||||
it("does NOT auto-raise while configured strength is still met after the leave", async () => {
|
||||
// Require 2; keep 3 active so one going on leave still leaves 2 cover.
|
||||
await db.vesselRankRequirement.create({ data: { vesselId, rankId, minStrength: 2 } });
|
||||
const a = await makeAssignment("Going On Leave");
|
||||
await makeAssignment("Stays A");
|
||||
await makeAssignment("Stays B");
|
||||
|
||||
await applyAndApprove(a.id);
|
||||
|
||||
expect(await autoRaisedCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
152
App/tests/integration/onboarding.test.ts
Normal file
152
App/tests/integration/onboarding.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Integration tests for the Crewing Phase 3c onboarding action. Onboarding is the
|
||||
* side-effecting transaction off a SELECTED application (assignment + employeeId +
|
||||
* salary binding + requisition FILLED + crew EMPLOYEE).
|
||||
*/
|
||||
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 { onboardCandidate } from "@/app/(portal)/crewing/applications/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itonb.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
let seq = 0;
|
||||
async function selectedApplication() {
|
||||
seq += 1;
|
||||
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
|
||||
const cand = await db.crewMember.create({ data: { name: "Selected Sam", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
|
||||
const app = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
|
||||
await db.salaryStructure.create({ data: { applicationId: app.id, rateBasis: "MONTHLY", basic: 50000, approvedById: managerId } });
|
||||
return { appId: app.id, reqId: req.id, candId: cand.id };
|
||||
}
|
||||
|
||||
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: "ITONB-SS", email: SS_EMAIL, name: "SS Onb", role: "SITE_STAFF" } });
|
||||
siteStaffId = ss.id;
|
||||
rankId = (await db.rank.findFirstOrThrow()).id;
|
||||
vesselId = (await db.vessel.findFirstOrThrow()).id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.contractLetter.deleteMany({});
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.salaryStructure.deleteMany({});
|
||||
await db.applicationGate.deleteMany({});
|
||||
await db.referenceCheck.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.application.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("onboardCandidate", () => {
|
||||
it("onboards a SELECTED candidate end-to-end in one transaction", async () => {
|
||||
const { appId, reqId, candId } = await selectedApplication();
|
||||
as(managerId, "MANAGER");
|
||||
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
|
||||
expect("ok" in res && res.ok).toBe(true);
|
||||
|
||||
const assignment = await db.crewAssignment.findFirstOrThrow({ where: { crewMemberId: candId } });
|
||||
expect(assignment.status).toBe("ACTIVE");
|
||||
expect(assignment.requisitionId).toBe(reqId);
|
||||
expect(assignment.rankId).toBe(rankId);
|
||||
|
||||
const cm = await db.crewMember.findUniqueOrThrow({ where: { id: candId } });
|
||||
expect(cm.status).toBe("EMPLOYEE");
|
||||
expect(cm.employeeId).toMatch(/^CRW-\d+$/);
|
||||
expect(cm.currentRankId).toBe(rankId);
|
||||
|
||||
expect((await db.application.findUniqueOrThrow({ where: { id: appId } })).stage).toBe("ONBOARDED");
|
||||
expect((await db.requisition.findUniqueOrThrow({ where: { id: reqId } })).status).toBe("FILLED");
|
||||
|
||||
const sal = await db.salaryStructure.findFirstOrThrow({ where: { applicationId: appId } });
|
||||
expect(sal.assignmentId).toBe(assignment.id);
|
||||
expect(sal.effectiveFrom).not.toBeNull();
|
||||
|
||||
const action = await db.crewAction.findFirstOrThrow({ where: { actionType: "CREW_ONBOARDED" } });
|
||||
expect(action.actorId).toBe(managerId);
|
||||
// D3 AC2: the audit row records the created IDs in metadata.
|
||||
const meta = action.metadata as { assignmentId?: string; employeeId?: string; salaryStructureId?: string } | null;
|
||||
expect(meta?.assignmentId).toBe(assignment.id);
|
||||
expect(meta?.employeeId).toBe(cm.employeeId);
|
||||
expect(meta?.salaryStructureId).toBe(sal.id);
|
||||
});
|
||||
|
||||
it("blocks onboarding when no salary structure is Manager-approved (D1)", async () => {
|
||||
seq += 1;
|
||||
const req = await db.requisition.create({ data: { code: `REQ-O${seq}`, rankId, vesselId, reason: "NEW_VACANCY", status: "SELECTED" } });
|
||||
const cand = await db.crewMember.create({ data: { name: "Unapproved Sal", type: "NEW", status: "CANDIDATE", source: "CAREERS", appliedRankId: rankId } });
|
||||
const appRow = await db.application.create({ data: { requisitionId: req.id, crewMemberId: cand.id, stage: "SELECTED", type: "NEW" } });
|
||||
// Salary agreed but NOT Manager-approved (approvedById null).
|
||||
await db.salaryStructure.create({ data: { applicationId: appRow.id, rateBasis: "MONTHLY", basic: 40000 } });
|
||||
|
||||
as(managerId, "MANAGER");
|
||||
const res = await onboardCandidate(fd({ applicationId: appRow.id, joiningDate: "2026-07-01" }));
|
||||
expect("error" in res).toBe(true);
|
||||
expect(await db.crewAssignment.count()).toBe(0);
|
||||
// The candidate is untouched — still a CANDIDATE, no employee number.
|
||||
const after = await db.crewMember.findUniqueOrThrow({ where: { id: cand.id } });
|
||||
expect(after.status).toBe("CANDIDATE");
|
||||
expect(after.employeeId).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a joining date", async () => {
|
||||
const { appId } = await selectedApplication();
|
||||
as(managerId, "MANAGER");
|
||||
const res = await onboardCandidate(fd({ applicationId: appId }));
|
||||
expect("error" in res).toBe(true);
|
||||
expect(await db.crewAssignment.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("only onboards from SELECTED", async () => {
|
||||
const { appId } = await selectedApplication();
|
||||
await db.application.update({ where: { id: appId }, data: { stage: "INTERVIEW" } });
|
||||
as(managerId, "MANAGER");
|
||||
const res = await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }));
|
||||
expect("error" in res).toBe(true);
|
||||
expect(await db.crewAssignment.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is rejected for roles without onboard_crew (site staff, accounts)", async () => {
|
||||
const { appId } = await selectedApplication();
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
||||
as(managerId, "ACCOUNTS");
|
||||
expect(await onboardCandidate(fd({ applicationId: appId, joiningDate: "2026-07-01" }))).toEqual({ error: "Unauthorized" });
|
||||
expect(await db.crewAssignment.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("assigns sequential CRW- employee numbers", async () => {
|
||||
const a = await selectedApplication();
|
||||
const b = await selectedApplication();
|
||||
as(managerId, "MANAGER");
|
||||
await onboardCandidate(fd({ applicationId: a.appId, joiningDate: "2026-07-01" }));
|
||||
await onboardCandidate(fd({ applicationId: b.appId, joiningDate: "2026-07-02" }));
|
||||
const ids = (await db.crewMember.findMany({ where: { employeeId: { not: null } }, select: { employeeId: true } })).map((c) => c.employeeId);
|
||||
expect(new Set(ids).size).toBe(2);
|
||||
expect(ids.every((i) => /^CRW-\d+$/.test(i!))).toBe(true);
|
||||
});
|
||||
});
|
||||
273
App/tests/integration/requisitions.test.ts
Normal file
273
App/tests/integration/requisitions.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* 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";
|
||||
import React from "react";
|
||||
// The list page's JSX compiles to classic React.createElement in the node runner.
|
||||
(globalThis as unknown as { React: typeof React }).React = React;
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() }));
|
||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||
vi.mock("@/lib/notifier", () => ({ notify: vi.fn(), notifyCrew: vi.fn() }));
|
||||
// We read the page element's props directly; the client component is irrelevant.
|
||||
vi.mock("@/app/(portal)/crewing/requisitions/requisitions-manager", () => ({ RequisitionsManager: () => null }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
raiseRequisition,
|
||||
cancelRequisition,
|
||||
transitionRequisition,
|
||||
requestReliefCover,
|
||||
convertReliefToRequisition,
|
||||
} from "@/app/(portal)/crewing/requisitions/actions";
|
||||
import RequisitionsPage from "@/app/(portal)/crewing/requisitions/page";
|
||||
import { autoRaiseRequisition } from "@/lib/requisition-service";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
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.application.deleteMany({});
|
||||
await db.crewMember.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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("requisitions list (A3)", () => {
|
||||
it("exposes a candidate count per requisition row", async () => {
|
||||
as(managerId, "MANAGER");
|
||||
const req = await db.requisition.create({ data: { code: "REQ-A3", rankId, vesselId, reason: "NEW_VACANCY", status: "SHORTLISTING" } });
|
||||
const empty = await db.requisition.create({ data: { code: "REQ-A3B", rankId, vesselId, reason: "LEAVE", status: "OPEN" } });
|
||||
for (const name of ["Cand A", "Cand B"]) {
|
||||
const c = await db.crewMember.create({ data: { name, type: "NEW", status: "CANDIDATE", source: "CAREERS" } });
|
||||
await db.application.create({ data: { requisitionId: req.id, crewMemberId: c.id, stage: "SHORTLISTED", type: "NEW" } });
|
||||
}
|
||||
|
||||
const el = (await RequisitionsPage()) as unknown as {
|
||||
props: { requisitions: Array<{ id: string; candidateCount: number }> };
|
||||
};
|
||||
expect(el.props.requisitions.find((r) => r.id === req.id)?.candidateCount).toBe(2);
|
||||
expect(el.props.requisitions.find((r) => r.id === empty.id)?.candidateCount).toBe(0);
|
||||
});
|
||||
});
|
||||
99
App/tests/integration/signoff.test.ts
Normal file
99
App/tests/integration/signoff.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Integration tests for Crewing Phase 4c sign-off (Epic K): assignment SIGNED_OFF,
|
||||
* experience record appended, crew member flipped to EX_HAND, and a SIGN_OFF
|
||||
* backfill requisition auto-raised — on the same CrewMember entity.
|
||||
*/
|
||||
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 { signOffCrew } from "@/app/(portal)/crewing/crew/actions";
|
||||
import { makeSession, getSeedUser } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let managerId: string;
|
||||
let accountsId: string;
|
||||
let siteStaffId: string;
|
||||
let rankId: string;
|
||||
let vesselId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itso.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function activeCrew() {
|
||||
const c = await db.crewMember.create({ data: { name: "On Tour", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-S${Date.now() % 100000}`, currentRankId: rankId } });
|
||||
const a = await db.crewAssignment.create({ data: { status: "ACTIVE", signOnDate: new Date("2026-01-01"), crewMemberId: c.id, rankId, vesselId } });
|
||||
return { crewId: c.id, assignmentId: a.id };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
managerId = (await getSeedUser("manager@pelagia.local")).id;
|
||||
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
||||
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITSO-SS", email: SS_EMAIL, name: "SS SO", 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.experienceRecord.deleteMany({});
|
||||
await db.crewAssignment.deleteMany({});
|
||||
await db.requisition.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("signOffCrew", () => {
|
||||
it("signs off → SIGNED_OFF + experience record + EX_HAND + backfill requisition", async () => {
|
||||
const { crewId, assignmentId } = await activeCrew();
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
const res = await signOffCrew(assignmentId, "2026-07-01", "End of contract");
|
||||
expect("ok" in res && res.ok).toBe(true);
|
||||
|
||||
const a = await db.crewAssignment.findUniqueOrThrow({ where: { id: assignmentId } });
|
||||
expect(a.status).toBe("SIGNED_OFF");
|
||||
expect(a.signOffDate).not.toBeNull();
|
||||
|
||||
// Same entity flipped back to the candidate pool as an ex-hand.
|
||||
const c = await db.crewMember.findUniqueOrThrow({ where: { id: crewId } });
|
||||
expect(c.status).toBe("EX_HAND");
|
||||
expect(c.type).toBe("EX_HAND");
|
||||
expect(c.employeeId).not.toBeNull(); // history retained
|
||||
|
||||
const exp = await db.experienceRecord.findFirstOrThrow({ where: { crewMemberId: crewId } });
|
||||
expect(exp.source).toBe("internal");
|
||||
expect(exp.rankId).toBe(rankId);
|
||||
expect(exp.durationMonths).toBe(6); // Jan→Jul
|
||||
|
||||
const req = await db.requisition.findFirstOrThrow({ where: { autoRaised: true } });
|
||||
expect(req.reason).toBe("SIGN_OFF");
|
||||
expect(req.rankId).toBe(rankId);
|
||||
expect(req.vesselId).toBe(vesselId);
|
||||
});
|
||||
|
||||
it("refuses to sign off an already signed-off assignment", async () => {
|
||||
const { assignmentId } = await activeCrew();
|
||||
as(managerId, "MANAGER");
|
||||
await signOffCrew(assignmentId, "2026-07-01");
|
||||
const res = await signOffCrew(assignmentId, "2026-08-01");
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
|
||||
it("is rejected for a role without sign_off_crew (accounts)", async () => {
|
||||
const { assignmentId } = await activeCrew();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await signOffCrew(assignmentId, "2026-07-01")).toEqual({ error: "Unauthorized" });
|
||||
expect(await db.requisition.count({ where: { autoRaised: true } })).toBe(0);
|
||||
});
|
||||
});
|
||||
120
App/tests/integration/verification.test.ts
Normal file
120
App/tests/integration/verification.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Integration tests for Crewing Phase 5a verification: documents (MPO) and
|
||||
* bank/EPF (Accounts), with role gating per §6/§8.11.
|
||||
*/
|
||||
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 { verifyDocument, verifyBankEpf, recordEpfoCheck } from "@/app/(portal)/crewing/verification/actions";
|
||||
import { makeSession, getSeedUser } from "./helpers";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
let manningId: string;
|
||||
let accountsId: string;
|
||||
let siteStaffId: string;
|
||||
|
||||
const SS_EMAIL = "sitestaff@itver.local";
|
||||
const as = (userId: string, role: Role) =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
||||
|
||||
async function crewWithRecords() {
|
||||
const c = await db.crewMember.create({ data: { name: "To Verify", status: "EMPLOYEE", type: "NEW", source: "CAREERS" } });
|
||||
const doc = await db.seafarerDocument.create({ data: { crewMemberId: c.id, docType: "PASSPORT", number: "P999" } });
|
||||
await db.bankDetail.create({ data: { crewMemberId: c.id, accountNumber: "123456789", ifsc: "HDFC0001" } });
|
||||
await db.epfDetail.create({ data: { crewMemberId: c.id, uan: "UAN-1" } });
|
||||
return { crewId: c.id, docId: doc.id };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
manningId = (await getSeedUser("manning@pelagia.local")).id;
|
||||
accountsId = (await getSeedUser("accounts@pelagia.local")).id;
|
||||
const ss = await db.user.upsert({ where: { email: SS_EMAIL }, update: { role: "SITE_STAFF", isActive: true }, create: { employeeId: "ITVER-SS", email: SS_EMAIL, name: "SS Ver", role: "SITE_STAFF" } });
|
||||
siteStaffId = ss.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.crewAction.deleteMany({});
|
||||
await db.seafarerDocument.deleteMany({});
|
||||
await db.bankDetail.deleteMany({});
|
||||
await db.epfDetail.deleteMany({});
|
||||
await db.crewMember.deleteMany({});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.user.deleteMany({ where: { email: SS_EMAIL } });
|
||||
});
|
||||
|
||||
describe("document verification (MPO)", () => {
|
||||
it("verifies a document with an audit row", async () => {
|
||||
const { crewId, docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("ok" in (await verifyDocument(docId, true))).toBe(true);
|
||||
const d = await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } });
|
||||
expect(d.verificationStatus).toBe("VERIFIED");
|
||||
expect(d.verifiedById).toBe(manningId);
|
||||
expect(await db.crewAction.count({ where: { crewMemberId: crewId, actionType: "RECORD_VERIFIED" } })).toBe(1);
|
||||
});
|
||||
|
||||
it("rejection requires a reason and records it", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect("error" in (await verifyDocument(docId, false))).toBe(true);
|
||||
expect("ok" in (await verifyDocument(docId, false, "Illegible scan"))).toBe(true);
|
||||
expect((await db.seafarerDocument.findUniqueOrThrow({ where: { id: docId } })).verificationStatus).toBe("REJECTED");
|
||||
});
|
||||
|
||||
it("won't re-verify an already-decided document", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
await verifyDocument(docId, true);
|
||||
expect("error" in (await verifyDocument(docId, true))).toBe(true);
|
||||
});
|
||||
|
||||
it("is rejected for roles without verify_site_records (accounts, site staff)", async () => {
|
||||
const { docId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
|
||||
as(siteStaffId, "SITE_STAFF");
|
||||
expect(await verifyDocument(docId, true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bank/EPF verification (Accounts)", () => {
|
||||
it("Accounts verifies bank and EPF", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect("ok" in (await verifyBankEpf(crewId, "bank", true))).toBe(true);
|
||||
expect((await db.bankDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
|
||||
expect("ok" in (await verifyBankEpf(crewId, "epf", true))).toBe(true);
|
||||
expect((await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } })).verificationStatus).toBe("VERIFIED");
|
||||
});
|
||||
|
||||
it("is rejected for the MPO (no verify_bank_epf)", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect(await verifyBankEpf(crewId, "bank", true)).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("EPFO assisted check (recordEpfoCheck)", () => {
|
||||
it("records the EPFO member name + timestamp (Accounts)", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(accountsId, "ACCOUNTS");
|
||||
expect("ok" in (await recordEpfoCheck(crewId, "EPFO Member (stub)"))).toBe(true);
|
||||
const epf = await db.epfDetail.findUniqueOrThrow({ where: { crewMemberId: crewId } });
|
||||
expect(epf.epfoMemberName).toBe("EPFO Member (stub)");
|
||||
expect(epf.epfoCheckedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is rejected for the MPO (no verify_bank_epf)", async () => {
|
||||
const { crewId } = await crewWithRecords();
|
||||
as(manningId, "MANNING");
|
||||
expect(await recordEpfoCheck(crewId, "x")).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
74
App/tests/unit/application-pipeline.test.ts
Normal file
74
App/tests/unit/application-pipeline.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
BOARD_STAGES,
|
||||
canPerformAction,
|
||||
canReject,
|
||||
getAvailableActions,
|
||||
getTransition,
|
||||
} from "@/lib/application-pipeline";
|
||||
|
||||
// The gated 7-stage recruitment pipeline (Crewing-Implementation-Spec §5.1).
|
||||
describe("Application pipeline state machine", () => {
|
||||
it("has the 7 board stages in order", () => {
|
||||
expect(BOARD_STAGES).toEqual([
|
||||
"SHORTLISTED",
|
||||
"COMPETENCY_AND_REFERENCES",
|
||||
"DOC_VERIFICATION",
|
||||
"SALARY_AGREEMENT",
|
||||
"PROPOSED",
|
||||
"INTERVIEW",
|
||||
"SELECTED",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("sourcing advances (MPO/Manager)", () => {
|
||||
it("MPO walks the early stages", () => {
|
||||
expect(getTransition("SHORTLISTED", "start_competency")?.to).toBe("COMPETENCY_AND_REFERENCES");
|
||||
expect(canPerformAction("SHORTLISTED", "start_competency", "MANNING")).toBe(true);
|
||||
expect(getTransition("COMPETENCY_AND_REFERENCES", "verify_competency")?.to).toBe("DOC_VERIFICATION");
|
||||
expect(getTransition("DOC_VERIFICATION", "verify_docs")?.to).toBe("SALARY_AGREEMENT");
|
||||
expect(getTransition("PROPOSED", "propose_accepted")?.to).toBe("INTERVIEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Manager-gated advances (spec §6)", () => {
|
||||
it("salary approval is Manager-only", () => {
|
||||
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANAGER")).toBe(true);
|
||||
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "SUPERUSER")).toBe(true);
|
||||
expect(canPerformAction("SALARY_AGREEMENT", "approve_salary", "MANNING")).toBe(false);
|
||||
expect(getTransition("SALARY_AGREEMENT", "approve_salary")?.to).toBe("PROPOSED");
|
||||
});
|
||||
|
||||
it("selection is Manager-only", () => {
|
||||
expect(canPerformAction("INTERVIEW", "select", "MANAGER")).toBe(true);
|
||||
expect(canPerformAction("INTERVIEW", "select", "MANNING")).toBe(false);
|
||||
expect(getTransition("INTERVIEW", "select")?.to).toBe("SELECTED");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects actions on the wrong stage", () => {
|
||||
expect(getTransition("SHORTLISTED", "select")).toBeNull();
|
||||
expect(getTransition("SELECTED", "approve_salary")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers MPO only sourcing actions, Manager the gated ones", () => {
|
||||
expect(getAvailableActions("SALARY_AGREEMENT", "MANNING")).toHaveLength(0);
|
||||
expect(getAvailableActions("SALARY_AGREEMENT", "MANAGER")).toEqual(["approve_salary"]);
|
||||
expect(getAvailableActions("SHORTLISTED", "SITE_STAFF")).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe("rejection (orthogonal)", () => {
|
||||
it("MPO/Manager can reject from any active stage", () => {
|
||||
expect(canReject("COMPETENCY_AND_REFERENCES", "MANNING")).toBe(true);
|
||||
expect(canReject("INTERVIEW", "MANAGER")).toBe(true);
|
||||
});
|
||||
it("cannot reject once selected/onboarded/already rejected", () => {
|
||||
expect(canReject("SELECTED", "MANAGER")).toBe(false);
|
||||
expect(canReject("ONBOARDED", "MANAGER")).toBe(false);
|
||||
expect(canReject("REJECTED", "MANAGER")).toBe(false);
|
||||
});
|
||||
it("site staff cannot reject", () => {
|
||||
expect(canReject("SHORTLISTED", "SITE_STAFF")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
30
App/tests/unit/appraisal-state-machine.test.ts
Normal file
30
App/tests/unit/appraisal-state-machine.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getTransition, canPerformAction, canReject } from "@/lib/appraisal-state-machine";
|
||||
|
||||
// Appraisal lifecycle (Crewing-Implementation-Spec §5.4).
|
||||
describe("Appraisal state machine", () => {
|
||||
it("MPO verifies a SUBMITTED appraisal", () => {
|
||||
expect(getTransition("SUBMITTED", "verify")?.to).toBe("MPO_VERIFIED");
|
||||
expect(canPerformAction("SUBMITTED", "verify", "MANNING")).toBe(true);
|
||||
expect(canPerformAction("SUBMITTED", "verify", "MANAGER")).toBe(true);
|
||||
expect(canPerformAction("SUBMITTED", "verify", "SITE_STAFF")).toBe(false);
|
||||
});
|
||||
|
||||
it("Manager approves an MPO_VERIFIED appraisal (not the MPO)", () => {
|
||||
expect(getTransition("MPO_VERIFIED", "approve")?.to).toBe("MANAGER_APPROVED");
|
||||
expect(canPerformAction("MPO_VERIFIED", "approve", "MANAGER")).toBe(true);
|
||||
expect(canPerformAction("MPO_VERIFIED", "approve", "MANNING")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects out-of-order actions", () => {
|
||||
expect(getTransition("SUBMITTED", "approve")).toBeNull();
|
||||
expect(getTransition("MANAGER_APPROVED", "verify")).toBeNull();
|
||||
});
|
||||
|
||||
it("is rejectable only while in review", () => {
|
||||
expect(canReject("SUBMITTED")).toBe(true);
|
||||
expect(canReject("MPO_VERIFIED")).toBe(true);
|
||||
expect(canReject("MANAGER_APPROVED")).toBe(false);
|
||||
expect(canReject("REJECTED")).toBe(false);
|
||||
});
|
||||
});
|
||||
67
App/tests/unit/crew-pii.test.ts
Normal file
67
App/tests/unit/crew-pii.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
||||
|
||||
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
||||
describe("crew PII masking", () => {
|
||||
describe("maskTail", () => {
|
||||
it("keeps the last 4 by default", () => {
|
||||
expect(maskTail("123456789")).toBe("•••• 6789");
|
||||
});
|
||||
it("renders — for empty values", () => {
|
||||
expect(maskTail(null)).toBe("—");
|
||||
expect(maskTail("")).toBe("—");
|
||||
});
|
||||
it("fully masks values at or under the visible length", () => {
|
||||
expect(maskTail("12")).toBe("••••");
|
||||
expect(maskTail("1234")).toBe("••••");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewFullBankEpf", () => {
|
||||
it("only Accounts and SuperUser see full bank/EPF", () => {
|
||||
expect(canViewFullBankEpf("ACCOUNTS")).toBe(true);
|
||||
expect(canViewFullBankEpf("SUPERUSER")).toBe(true);
|
||||
expect(canViewFullBankEpf("MANAGER")).toBe(false);
|
||||
expect(canViewFullBankEpf("MANNING")).toBe(false);
|
||||
expect(canViewFullBankEpf("SITE_STAFF")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canViewSalary", () => {
|
||||
it("hides salary from site staff only", () => {
|
||||
expect(canViewSalary("SITE_STAFF")).toBe(false);
|
||||
expect(canViewSalary("MANAGER")).toBe(true);
|
||||
expect(canViewSalary("ACCOUNTS")).toBe(true);
|
||||
expect(canViewSalary("MANNING")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bankEpfValue", () => {
|
||||
it("shows full to Accounts, masked to others, — when empty", () => {
|
||||
expect(bankEpfValue("123456789", "ACCOUNTS")).toBe("123456789");
|
||||
expect(bankEpfValue("123456789", "MANAGER")).toBe("•••• 6789");
|
||||
expect(bankEpfValue(null, "ACCOUNTS")).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentNumberValue", () => {
|
||||
it("masks Aadhaar/PAN numbers for non-privileged roles", () => {
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "MANAGER")).toBe("•••• 9012");
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "MANNING")).toBe("•••• 9012");
|
||||
expect(documentNumberValue("ABCDE1234F", "PAN", "SITE_STAFF")).toBe("•••• 234F");
|
||||
});
|
||||
it("shows Aadhaar/PAN in full to Accounts and SuperUser", () => {
|
||||
expect(documentNumberValue("123456789012", "AADHAAR", "ACCOUNTS")).toBe("123456789012");
|
||||
expect(documentNumberValue("ABCDE1234F", "PAN", "SUPERUSER")).toBe("ABCDE1234F");
|
||||
});
|
||||
it("does not restrict non-identity documents for any role", () => {
|
||||
expect(documentNumberValue("P1234567", "PASSPORT", "SITE_STAFF")).toBe("P1234567");
|
||||
expect(documentNumberValue("CDC-99", "CDC", "MANNING")).toBe("CDC-99");
|
||||
expect(documentNumberValue("STCW-1", "STCW", "MANAGER")).toBe("STCW-1");
|
||||
});
|
||||
it("returns null for an empty number regardless of type/role", () => {
|
||||
expect(documentNumberValue(null, "AADHAAR", "ACCOUNTS")).toBeNull();
|
||||
expect(documentNumberValue("", "PASSPORT", "MANAGER")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -67,6 +67,15 @@ describe("Crewing permissions (spec §6)", () => {
|
|||
expect(hasPermission("ACCOUNTS", "record_attendance")).toBe(false);
|
||||
});
|
||||
|
||||
it("manage_crew is Manager + SuperUser + Admin (office crew management)", () => {
|
||||
expect(hasPermission("MANAGER", "manage_crew")).toBe(true);
|
||||
expect(hasPermission("SUPERUSER", "manage_crew")).toBe(true);
|
||||
expect(hasPermission("ADMIN", "manage_crew")).toBe(true);
|
||||
expect(hasPermission("SITE_STAFF", "manage_crew")).toBe(false);
|
||||
expect(hasPermission("MANNING", "manage_crew")).toBe(false);
|
||||
expect(hasPermission("ACCOUNTS", "manage_crew")).toBe(false);
|
||||
});
|
||||
|
||||
it("manage_ranks is Manager + Admin only (not SuperUser)", () => {
|
||||
expect(hasPermission("MANAGER", "manage_ranks")).toBe(true);
|
||||
expect(hasPermission("ADMIN", "manage_ranks")).toBe(true);
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
EpfoService/README.md
Normal file
51
EpfoService/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# EpfoService
|
||||
|
||||
EPFO / UAN **assisted-lookup** proxy for PPMS crewing — mirrors `GstService`.
|
||||
Drives the EPFO member portal headlessly (Playwright) to fetch a member record
|
||||
for a UAN, so Accounts can confirm a crew member's EPF details against the source.
|
||||
|
||||
## Why it differs from GstService
|
||||
|
||||
- The GST portal has an anonymous **captcha** lookup. The EPFO member portal does
|
||||
not — "Know your UAN" is gated by an **OTP to the member's registered mobile**.
|
||||
So the handshake is two steps (`/otp` then `/verify`).
|
||||
- **Aadhaar is out of scope.** UIDAI restricts Aadhaar verification to licensed
|
||||
AUA/KUA via consented e-KYC; it cannot be portal-scraped. PPMS keeps Aadhaar
|
||||
**assisted-manual** (stores only the last 4 digits, masked).
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Body | Returns |
|
||||
|---|---|---|---|
|
||||
| GET | `/health` | — | `{ status, mode, sessionCount }` |
|
||||
| POST | `/otp` | `{ uan }` | `{ sessionId, mobileHint }` |
|
||||
| POST | `/verify` | `{ sessionId, uan, otp }` | `{ matched, name, status }` |
|
||||
|
||||
## Modes
|
||||
|
||||
- **Stub (default):** `EPFO_LIVE` unset/`false`. Deterministic responses — OTP
|
||||
`000000` → matched member, anything else → not matched. Lets the app
|
||||
integration run end-to-end in dev/CI without the live portal.
|
||||
- **Live:** `EPFO_LIVE=true`. Drives the real portal. **The page selectors and the
|
||||
OTP/captcha flow are marked `TODO(live)` and must be validated against a real
|
||||
session before enabling** — the portal layout is the source of truth.
|
||||
|
||||
## Env
|
||||
|
||||
```
|
||||
PORT=3004
|
||||
SESSION_TTL_MS=300000
|
||||
EPFO_LIVE=false
|
||||
EPFO_PORTAL_URL=https://unifiedportal-mem.epfindia.gov.in/memberinterface/
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```
|
||||
pnpm install
|
||||
pnpm dev # tsx watch
|
||||
# or
|
||||
pnpm build && pnpm start
|
||||
```
|
||||
|
||||
The PPMS app reaches it via `EPFO_SERVICE_URL` (proxied through `/api/epfo`).
|
||||
21
EpfoService/package.json
Normal file
21
EpfoService/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "epfo-service",
|
||||
"version": "0.1.0",
|
||||
"description": "EPFO/UAN proxy — assisted UAN lookup from the EPFO member portal via Playwright (OTP handshake). Mirrors GstService. Aadhaar is NOT handled here (UIDAI-restricted).",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
139
EpfoService/src/index.ts
Normal file
139
EpfoService/src/index.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* EpfoService — EPFO / UAN assisted-lookup proxy (mirrors GstService).
|
||||
*
|
||||
* The EPFO member portal does not offer an anonymous lookup like the GST portal:
|
||||
* the "Know your UAN" / member flow is gated by an **OTP to the member's
|
||||
* registered mobile**. So the handshake is two steps:
|
||||
* POST /otp { uan } → opens a session, requests the OTP
|
||||
* POST /verify { sessionId, uan, otp } → submits the OTP, returns the member
|
||||
* record (name, DOB, status, …)
|
||||
*
|
||||
* The real portal navigation is gated behind EPFO_LIVE=true. Until the live
|
||||
* selectors/OTP are validated against a real session, the service runs in STUB
|
||||
* mode (deterministic responses) so the app integration is exercisable in dev.
|
||||
*
|
||||
* Aadhaar verification is intentionally OUT OF SCOPE here — UIDAI restricts it to
|
||||
* licensed AUA/KUA via consented e-KYC; it cannot be portal-scraped. Aadhaar
|
||||
* stays assisted-manual in PPMS.
|
||||
*/
|
||||
import express from "express";
|
||||
import type { Browser, BrowserContext, Page } from "playwright";
|
||||
import { isUan, mobileHint, stubOtp, stubVerify } from "./stub";
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3004);
|
||||
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min
|
||||
const LIVE = process.env.EPFO_LIVE === "true";
|
||||
const PORTAL_URL = process.env.EPFO_PORTAL_URL ?? "https://unifiedportal-mem.epfindia.gov.in/memberinterface/";
|
||||
|
||||
function log(level: string, msg: string, ctx?: Record<string, unknown>) {
|
||||
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx });
|
||||
(level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n");
|
||||
}
|
||||
|
||||
// ── Sessions ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Session {
|
||||
uan: string;
|
||||
createdAt: number;
|
||||
context?: BrowserContext;
|
||||
page?: Page;
|
||||
}
|
||||
const sessions = new Map<string, Session>();
|
||||
let seq = 0;
|
||||
const newSessionId = () => `epfo_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
let pruned = 0;
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.createdAt > SESSION_TTL_MS) {
|
||||
s.context?.close().catch(() => {});
|
||||
sessions.delete(id);
|
||||
pruned++;
|
||||
}
|
||||
}
|
||||
if (pruned) log("INFO", "Pruned expired sessions", { pruned, remaining: sessions.size });
|
||||
}, 60_000).unref();
|
||||
|
||||
// ── Browser (only launched in LIVE mode) ───────────────────────────────────────
|
||||
|
||||
let _browser: Browser | null = null;
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
if (_browser?.isConnected()) return _browser;
|
||||
const { chromium } = await import("playwright");
|
||||
_browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
||||
_browser.on("disconnected", () => { _browser = null; });
|
||||
return _browser;
|
||||
}
|
||||
|
||||
// ── App ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok", mode: LIVE ? "live" : "stub", sessionCount: sessions.size });
|
||||
});
|
||||
|
||||
/** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */
|
||||
app.post("/otp", async (req, res) => {
|
||||
const { uan } = req.body ?? {};
|
||||
const sessionId = newSessionId();
|
||||
|
||||
if (!LIVE) {
|
||||
const r = stubOtp(uan, sessionId);
|
||||
if (r.ok) {
|
||||
sessions.set(sessionId, { uan, createdAt: Date.now() });
|
||||
log("INFO", "OTP requested (stub)", { sessionId });
|
||||
}
|
||||
return res.status(r.status).json(r.body);
|
||||
}
|
||||
|
||||
if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" });
|
||||
|
||||
try {
|
||||
const browser = await getBrowser();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.goto(PORTAL_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
// TODO(live): drive the member portal's "Know your UAN" OTP request:
|
||||
// fill UAN, solve the on-page captcha, click "Get OTP", read the masked mobile.
|
||||
// Selectors must be validated against a real session before enabling EPFO_LIVE.
|
||||
sessions.set(sessionId, { uan, createdAt: Date.now(), context, page });
|
||||
return res.json({ sessionId, mobileHint: mobileHint() });
|
||||
} catch (e) {
|
||||
log("ERROR", "POST /otp failed", { err: String(e) });
|
||||
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
|
||||
app.post("/verify", async (req, res) => {
|
||||
const { sessionId, uan, otp } = req.body ?? {};
|
||||
const s = (sessionId && sessions.get(sessionId)) || undefined;
|
||||
|
||||
if (!LIVE) {
|
||||
const r = stubVerify(s, uan, otp);
|
||||
// A valid handshake consumes the session (one OTP per request).
|
||||
if (r.ok && sessionId) sessions.delete(sessionId);
|
||||
log("INFO", "Verify (stub)", { sessionId, matched: r.body.matched });
|
||||
return res.status(r.status).json(r.body);
|
||||
}
|
||||
|
||||
if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" });
|
||||
if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" });
|
||||
if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" });
|
||||
|
||||
try {
|
||||
// TODO(live): submit the OTP and scrape the member record (name/DOB/status).
|
||||
const result = { matched: false, name: null as string | null, status: null as string | null };
|
||||
s.context?.close().catch(() => {});
|
||||
sessions.delete(sessionId);
|
||||
return res.json(result);
|
||||
} catch (e) {
|
||||
log("ERROR", "POST /verify failed", { err: String(e) });
|
||||
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => log("INFO", `EpfoService listening`, { port: PORT, mode: LIVE ? "live" : "stub" }));
|
||||
42
EpfoService/src/stub.ts
Normal file
42
EpfoService/src/stub.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Pure, dependency-free EPFO stub + validation logic (no express/playwright), so
|
||||
* the deterministic contract the PPMS app relies on can be unit-tested without
|
||||
* launching the service. `index.ts` uses these in its stub branches, so the
|
||||
* tested logic IS the production stub behaviour.
|
||||
*
|
||||
* Deterministic stub contract (EPFO_LIVE unset):
|
||||
* /otp validates the UAN and opens a session.
|
||||
* /verify validates session + UAN + OTP; matched iff OTP === STUB_MATCH_OTP.
|
||||
*/
|
||||
|
||||
export const STUB_MATCH_OTP = "000000";
|
||||
|
||||
export const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s);
|
||||
export const isOtp = (s: unknown): s is string => typeof s === "string" && /^\d{4,8}$/.test(s);
|
||||
|
||||
export const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••");
|
||||
|
||||
export interface StubResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Stub of POST /otp — validate the UAN and (caller-supplied) open a session. */
|
||||
export function stubOtp(uan: unknown, sessionId: string): StubResult {
|
||||
if (!isUan(uan)) return { ok: false, status: 400, body: { error: "A 12-digit UAN is required" } };
|
||||
return { ok: true, status: 200, body: { sessionId, mobileHint: mobileHint(), stub: true } };
|
||||
}
|
||||
|
||||
/** Stub of POST /verify — validate the session/UAN/OTP and return the match. */
|
||||
export function stubVerify(session: { uan: string } | undefined, uan: unknown, otp: unknown): StubResult {
|
||||
if (!session) return { ok: false, status: 410, body: { error: "Session expired — request a new OTP" } };
|
||||
if (!isUan(uan) || session.uan !== uan) return { ok: false, status: 400, body: { error: "UAN mismatch" } };
|
||||
if (!isOtp(otp)) return { ok: false, status: 400, body: { error: "A valid OTP is required" } };
|
||||
const matched = otp === STUB_MATCH_OTP;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: { matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true },
|
||||
};
|
||||
}
|
||||
12
EpfoService/tsconfig.json
Normal file
12
EpfoService/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue