diff --git a/.forgejo/workflows/pr-checks.yml b/.forgejo/workflows/pr-checks.yml index 51bab25..6d69b54 100644 --- a/.forgejo/workflows/pr-checks.yml +++ b/.forgejo/workflows/pr-checks.yml @@ -1,15 +1,22 @@ name: PR checks -# Enforces the contribution policy on every PR into master (all gates hard): +# Enforces the contribution policy on every PR into master — plus the crewing +# stack branches (feat/crewing-*), which collect the stacked, feature-flagged +# crewing phases (foundations → requisitions → candidates → …) before they merge +# to master. Same hard gates: # - code changes must ship with tests (docs/config/automation are exempt) # - type-check is clean across the whole project (tests included) # - unit tests pass # - integration tests pass against an ephemeral Postgres (migrate + seed) # Runs on the pms1 host runner. See automation/README.md > "Contribution policy". +# +# Note: the workflow is evaluated from the branch under test, so the trigger list +# must match it. The feat/crewing-* glob covers every branch in the stack so each +# stacked phase PR is checked without further edits to this file. on: pull_request: - branches: [master] + branches: [master, "feat/crewing-*"] jobs: checks: diff --git a/App/.env.example b/App/.env.example index a80b2c7..3f98d6d 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 dfc1951..0109aa9 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -118,6 +118,95 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at `/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices. +### 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). **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. + +**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 `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. @@ -141,8 +230,10 @@ 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_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only) +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)/admin/ranks/actions.ts b/App/app/(portal)/admin/ranks/actions.ts new file mode 100644 index 0000000..2f2bbf0 --- /dev/null +++ b/App/app/(portal)/admin/ranks/actions.ts @@ -0,0 +1,187 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { CREWING_ENABLED } from "@/lib/feature-flags"; +import { RankCategory, SeafarerDocType } from "@prisma/client"; +import { z } from "zod"; +import { revalidatePath } from "next/cache"; + +type ActionResult = { ok: true } | { error: string }; + +async function guard(): Promise<{ error: string } | null> { + if (!CREWING_ENABLED) return { error: "Crewing is not enabled" }; + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_ranks")) { + return { error: "Unauthorized" }; + } + return null; +} + +const rankSchema = z.object({ + code: z.string().trim().min(1, "Code is required").max(16, "Code is too long"), + name: z.string().trim().min(1, "Name is required"), + description: z.string().optional(), + parentId: z.string().optional(), + category: z.nativeEnum(RankCategory), + isSeafarer: z.boolean(), + grantsLogin: z.boolean(), +}); + +function parseRank(formData: FormData) { + return rankSchema.safeParse({ + code: formData.get("code"), + name: formData.get("name"), + description: (formData.get("description") as string) || undefined, + parentId: (formData.get("parentId") as string) || undefined, + category: formData.get("category"), + isSeafarer: formData.get("isSeafarer") === "on" || formData.get("isSeafarer") === "true", + grantsLogin: formData.get("grantsLogin") === "on" || formData.get("grantsLogin") === "true", + }); +} + +// True if `candidateParentId` is `rankId` itself or one of its descendants — +// setting it as the parent would create a cycle. +async function wouldCycle(rankId: string, candidateParentId: string): Promise { + if (rankId === candidateParentId) return true; + const all = await db.rank.findMany({ select: { id: true, parentId: true } }); + const childrenOf = new Map(); + for (const r of all) { + if (r.parentId) { + const list = childrenOf.get(r.parentId) ?? []; + list.push(r.id); + childrenOf.set(r.parentId, list); + } + } + const stack = [rankId]; + while (stack.length) { + const cur = stack.pop()!; + if (cur === candidateParentId) return true; + stack.push(...(childrenOf.get(cur) ?? [])); + } + return false; +} + +export async function createRank(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const parsed = parseRank(formData); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + const exists = await db.rank.findUnique({ where: { code: data.code } }); + if (exists) return { error: "A rank with that code already exists" }; + + await db.rank.create({ + data: { + code: data.code, + name: data.name, + description: data.description ?? null, + parentId: data.parentId ?? null, + category: data.category, + isSeafarer: data.isSeafarer, + grantsLogin: data.grantsLogin, + }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function updateRank(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const id = formData.get("id") as string; + if (!id) return { error: "Rank ID is required" }; + + const parsed = parseRank(formData); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + const conflict = await db.rank.findFirst({ where: { code: data.code, id: { not: id } } }); + if (conflict) return { error: "Another rank already uses that code" }; + + if (data.parentId && (await wouldCycle(id, data.parentId))) { + return { error: "A rank cannot report to itself or one of its sub-ranks" }; + } + + await db.rank.update({ + where: { id }, + data: { + code: data.code, + name: data.name, + description: data.description ?? null, + parentId: data.parentId ?? null, + category: data.category, + isSeafarer: data.isSeafarer, + grantsLogin: data.grantsLogin, + }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function deleteRank(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + const hasChildren = await db.rank.findFirst({ where: { parentId: id } }); + if (hasChildren) return { error: "Cannot delete: this rank has sub-ranks. Reassign or remove them first." }; + + // Document requirements cascade on delete. + await db.rank.delete({ where: { id } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function toggleRankActive(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + const rank = await db.rank.findUnique({ where: { id }, select: { isActive: true } }); + if (!rank) return { error: "Rank not found" }; + + await db.rank.update({ where: { id }, data: { isActive: !rank.isActive } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +const docReqSchema = z.object({ + rankId: z.string().min(1), + docType: z.nativeEnum(SeafarerDocType), + isMandatory: z.boolean(), + note: z.string().optional(), +}); + +export async function addRankDocRequirement(formData: FormData): Promise { + const denied = await guard(); + if (denied) return denied; + + const parsed = docReqSchema.safeParse({ + rankId: formData.get("rankId"), + docType: formData.get("docType"), + isMandatory: formData.get("isMandatory") === "on" || formData.get("isMandatory") === "true", + note: (formData.get("note") as string) || undefined, + }); + if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" }; + const data = parsed.data; + + await db.rankDocRequirement.upsert({ + where: { rankId_docType: { rankId: data.rankId, docType: data.docType } }, + update: { isMandatory: data.isMandatory, note: data.note ?? null }, + create: { rankId: data.rankId, docType: data.docType, isMandatory: data.isMandatory, note: data.note ?? null }, + }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} + +export async function removeRankDocRequirement(id: string): Promise { + const denied = await guard(); + if (denied) return denied; + + await db.rankDocRequirement.delete({ where: { id } }); + revalidatePath("/admin/ranks"); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/ranks/page.tsx b/App/app/(portal)/admin/ranks/page.tsx new file mode 100644 index 0000000..9d1db72 --- /dev/null +++ b/App/app/(portal)/admin/ranks/page.tsx @@ -0,0 +1,44 @@ +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 { RanksManager } from "./ranks-manager"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Ranks & Documents" }; + +export default async function AdminRanksPage() { + // 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, "manage_ranks")) redirect("/dashboard"); + + const ranks = await db.rank.findMany({ + orderBy: [{ name: "asc" }], + include: { docRequirements: { orderBy: { docType: "asc" } } }, + }); + + // Flatten to plain props (no Date/Decimal crosses the server→client boundary). + const rows = ranks.map((r) => ({ + id: r.id, + code: r.code, + name: r.name, + description: r.description, + category: r.category, + isSeafarer: r.isSeafarer, + grantsLogin: r.grantsLogin, + isActive: r.isActive, + parentId: r.parentId, + docRequirements: r.docRequirements.map((d) => ({ + id: d.id, + docType: d.docType, + isMandatory: d.isMandatory, + note: d.note, + })), + })); + + return ; +} diff --git a/App/app/(portal)/admin/ranks/rank-doc-panel.tsx b/App/app/(portal)/admin/ranks/rank-doc-panel.tsx new file mode 100644 index 0000000..593fa34 --- /dev/null +++ b/App/app/(portal)/admin/ranks/rank-doc-panel.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { SeafarerDocType } from "@prisma/client"; +import type { RankRow } from "./ranks-manager"; +import { addRankDocRequirement, removeRankDocRequirement } from "./actions"; + +// Listed (not imported as a runtime enum) to keep @prisma/client out of the client bundle. +const DOC_TYPES: { value: SeafarerDocType; label: string }[] = [ + { value: "STCW", label: "STCW" }, + { value: "AADHAAR", label: "Aadhaar" }, + { value: "PAN", label: "PAN" }, + { value: "PASSPORT", label: "Passport" }, + { value: "CDC", label: "CDC" }, + { value: "COC", label: "COC" }, + { value: "PHOTOGRAPH", label: "Photograph" }, + { value: "DRIVING_LICENSE", label: "Driving licence" }, + { value: "MEDICAL_FITNESS", label: "Medical fitness" }, + { value: "CONTRACT_LETTER", label: "Contract letter" }, +]; + +const DOC_LABEL = Object.fromEntries(DOC_TYPES.map((d) => [d.value, d.label])) as Record; + +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 RankDocPanel({ rank }: { rank: RankRow | null }) { + const router = useRouter(); + const [adding, setAdding] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + if (!rank) { + return ( +
+ Select a rank to manage its required documents. +
+ ); + } + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + setPending(true); + setError(""); + const fd = new FormData(e.currentTarget); + fd.set("rankId", rank!.id); + const result = await addRankDocRequirement(fd); + if ("error" in result) { + setError(result.error); + setPending(false); + } else { + setPending(false); + setAdding(false); + router.refresh(); + } + } + + async function handleRemove(id: string) { + await removeRankDocRequirement(id); + router.refresh(); + } + + return ( +
+
+
+

Required documents

+

{rank.code} — {rank.name}

+
+ +
+ + {adding && ( +
+ + + + {error &&

{error}

} + +
+ )} + + {rank.docRequirements.length === 0 ? ( +

No required documents for this rank.

+ ) : ( +
+ {rank.docRequirements.map((d) => ( +
+ {DOC_LABEL[d.docType] ?? d.docType} + {d.note && {d.note}} + + {d.isMandatory ? "Mandatory" : "Conditional"} + + +
+ ))} +
+ )} +
+ ); +} diff --git a/App/app/(portal)/admin/ranks/rank-form.tsx b/App/app/(portal)/admin/ranks/rank-form.tsx new file mode 100644 index 0000000..6af403f --- /dev/null +++ b/App/app/(portal)/admin/ranks/rank-form.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { createRank, updateRank } from "./actions"; +import type { RankRow } from "./ranks-manager"; + +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"; + +function RankFormFields({ rank, allRanks }: { rank?: RankRow; allRanks: RankRow[] }) { + const parentOptions = allRanks.filter((r) => !rank || r.id !== rank.id); + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ ); +} + +export function AddRankButton({ allRanks }: { allRanks: RankRow[] }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); + setError(""); + const result = await createRank(new FormData(e.currentTarget)); + if ("error" in result) { + setError(result.error); + setPending(false); + } else { + setPending(false); + setOpen(false); + router.refresh(); + } + } + + return ( + <> + + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ + ); +} + +export function EditRankButton({ + rank, + allRanks, + open: controlledOpen, + onOpenChange, +}: { + rank: RankRow; + allRanks: RankRow[]; + open?: boolean; + onOpenChange?: (v: boolean) => void; +}) { + const router = useRouter(); + const [internalOpen, setInternalOpen] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); + setError(""); + const fd = new FormData(e.currentTarget); + fd.set("id", rank.id); + const result = await updateRank(fd); + if ("error" in result) { + setError(result.error); + setPending(false); + } else { + setPending(false); + setOpen(false); + router.refresh(); + } + } + + return ( + setOpen(false)}> +
+ + {error &&

{error}

} +
+ + +
+ +
+ ); +} diff --git a/App/app/(portal)/admin/ranks/ranks-manager.tsx b/App/app/(portal)/admin/ranks/ranks-manager.tsx new file mode 100644 index 0000000..e4c537b --- /dev/null +++ b/App/app/(portal)/admin/ranks/ranks-manager.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState } from "react"; +import type { RankCategory, SeafarerDocType } from "@prisma/client"; +import { AddRankButton, EditRankButton } from "./rank-form"; +import { RankDocPanel } from "./rank-doc-panel"; +import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu"; +import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { deleteRank, toggleRankActive } from "./actions"; +import { cn } from "@/lib/utils"; + +export type DocReqRow = { + id: string; + docType: SeafarerDocType; + isMandatory: boolean; + note: string | null; +}; + +export type RankRow = { + id: string; + code: string; + name: string; + description: string | null; + category: RankCategory; + isSeafarer: boolean; + grantsLogin: boolean; + isActive: boolean; + parentId: string | null; + docRequirements: DocReqRow[]; +}; + +type TreeNode = RankRow & { children: TreeNode[] }; + +function buildTree(ranks: RankRow[]): TreeNode[] { + const byId = new Map(); + ranks.forEach((r) => byId.set(r.id, { ...r, children: [] })); + const roots: TreeNode[] = []; + byId.forEach((node) => { + if (node.parentId && byId.has(node.parentId)) { + byId.get(node.parentId)!.children.push(node); + } else { + roots.push(node); + } + }); + const sortRec = (nodes: TreeNode[]) => { + nodes.sort((a, b) => a.name.localeCompare(b.name)); + nodes.forEach((n) => sortRec(n.children)); + }; + sortRec(roots); + return roots; +} + +function RankActionsMenu({ rank, allRanks }: { rank: RankRow; allRanks: RankRow[] }) { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [toggleOpen, setToggleOpen] = useState(false); + + return ( + <> + + setEditOpen(true)}>Edit + setToggleOpen(true)}> + {rank.isActive ? "Deactivate" : "Activate"} + + + setDeleteOpen(true)}>Delete + + + deleteRank(rank.id)} + /> +