diff --git a/App/.env.example b/App/.env.example index a22649f..248cb9c 100644 --- a/App/.env.example +++ b/App/.env.example @@ -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 diff --git a/App/CLAUDE.md b/App/CLAUDE.md index 1794f35..ed2a1f5 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -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. diff --git a/App/app/(portal)/admin/crew-strength/actions.ts b/App/app/(portal)/admin/crew-strength/actions.ts new file mode 100644 index 0000000..ed7cde6 --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/actions.ts @@ -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 { + 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 { + const denied = await guard(); + if ("error" in denied) return denied; + await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {}); + revalidatePath(PATH); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx b/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx new file mode 100644 index 0000000..5d2cf27 --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/crew-strength-manager.tsx @@ -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 ( +
+
+

Crew strength

+

Required crew per rank, per vessel. Drives the leave-clash backfill — a leave that drops cover below the required strength auto-raises a requisition.

+
+ +
+
+ + +
+
+ + +
+
+ + setF({ ...f, minStrength: e.target.value })} required /> +
+ + {error &&

{error}

} +
+ +
+ + + + + + + + + + + {requirements.length === 0 ? ( + + ) : requirements.map((r) => ( + + + + + + + ))} + +
VesselRankMin strength
No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.
{r.vessel}{r.rank}{r.minStrength} + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/crew-strength/page.tsx b/App/app/(portal)/admin/crew-strength/page.tsx new file mode 100644 index 0000000..2db3d8a --- /dev/null +++ b/App/app/(portal)/admin/crew-strength/page.tsx @@ -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 ( + ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))} + vessels={vessels} + ranks={ranks} + /> + ); +} diff --git a/App/app/(portal)/admin/crew/actions.ts b/App/app/(portal)/admin/crew/actions.ts new file mode 100644 index 0000000..6bdf40b --- /dev/null +++ b/App/app/(portal)/admin/crew/actions.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/App/app/(portal)/admin/crew/admin-crew-manager.tsx b/App/app/(portal)/admin/crew/admin-crew-manager.tsx new file mode 100644 index 0000000..2fe7d7d --- /dev/null +++ b/App/app/(portal)/admin/crew/admin-crew-manager.tsx @@ -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 = { + 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 ( +
+
+
+

Crew management

+

{crew.length} crew records · create, edit, place onto a vessel/site, or remove

+
+ +
+ + setSearch(e.target.value)} /> + +
+ + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : filtered.map((c) => )} + +
NameEmployeeStatusRank
No crew records.
+
+
+ ); +} + +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 ( + + {c.name} + {c.employeeId ?? "—"} + {label(c.status)} + {c.currentRank ?? "—"} + + + setEditOpen(true)}>Edit + {!c.hasActiveAssignment && setPlaceOpen(true)}>Place onto vessel/site} + + setDeleteOpen(true)}>Delete + + + + deleteCrewMember(c.id)} /> + + + ); +} + +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 && } + setOpen(false)}> +
+
+ setF({ ...f, name: e.target.value })} required /> + + + + + + setF({ ...f, email: e.target.value })} /> + setF({ ...f, phone: e.target.value })} /> + setF({ ...f, experienceMonths: e.target.value })} /> +
+ {error &&

{error}

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

Assign this crew member directly to a vessel/site — no requisition needed. A candidate is promoted to active crew with an employee number.

+
+ + +
+
+ + +
+
+ + setF({ ...f, signOnDate: e.target.value })} required /> +
+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/App/app/(portal)/admin/crew/page.tsx b/App/app/(portal)/admin/crew/page.tsx new file mode 100644 index 0000000..e5f8d3e --- /dev/null +++ b/App/app/(portal)/admin/crew/page.tsx @@ -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 ( + ({ + 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} + /> + ); +} diff --git a/App/app/(portal)/approvals/crewing-approvals.tsx b/App/app/(portal)/approvals/crewing-approvals.tsx new file mode 100644 index 0000000..a8ec5f3 --- /dev/null +++ b/App/app/(portal)/approvals/crewing-approvals.tsx @@ -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 = { 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 Promise<{ ok: true } | { error: string }>> = { + SALARY: approveSalary, + SELECTION: selectCandidate, + WAIVER: approveInterviewWaiver, + LEAVE: (id) => decideLeave(id, true), + APPRAISAL: (id) => approveAppraisal(id, true), +}; +const returnFn: Record 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 ( + + {KIND_LABEL[item.kind]} + + {item.candidateName} + {item.rank} · {item.requisitionCode} + + {item.detail} + +
+ + +
+ {error &&

{error}

} + setReturnOpen(false)}> +
+