Compare commits

..

No commits in common. "master" and "feat/company-header-logo-branding" have entirely different histories.

171 changed files with 145 additions and 15233 deletions

View file

@ -1,22 +1,14 @@
name: PR checks
# 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:
# Enforces the contribution policy on every PR into master (all gates hard):
# - 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, "feat/crewing-*"]
branches: [master]
jobs:
checks:
@ -64,45 +56,3 @@ jobs:
set -e
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
cd App && pnpm test # jsdom unit tests, no DB — must pass
integration:
runs-on: host
steps:
- name: Checkout PR
uses: actions/checkout@v4
- name: Integration tests (ephemeral Postgres)
run: |
set -euo pipefail
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
# Throwaway Postgres per run — isolated from prod / pelagia_test / staging.
# A random host port avoids collisions with the host DB and concurrent runs.
PG="ci-pg-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker rm -f "$PG" >/dev/null 2>&1 || true
docker run -d --name "$PG" \
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=pelagia_ci \
-p 127.0.0.1::5432 postgres:16 >/dev/null
for i in $(seq 1 30); do
docker exec "$PG" pg_isready -U ci -d pelagia_ci >/dev/null 2>&1 && break
sleep 1
done
PORT=$(docker inspect --format '{{ (index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort }}' "$PG")
export DATABASE_URL="postgresql://ci:ci@127.0.0.1:${PORT}/pelagia_ci"
# Non-secret placeholders so auth.ts (reads these at module load) boots in dev mode.
export NEXTAUTH_SECRET="ci-secret"
export NEXTAUTH_URL="http://localhost:3000"
export AZURE_AD_CLIENT_ID="placeholder"
export AZURE_AD_CLIENT_SECRET="placeholder"
export AZURE_AD_TENANT_ID="placeholder"
cd App
pnpm install --frozen-lockfile
pnpm db:generate
pnpm db:migrate:deploy # apply migrations to the fresh DB
pnpm db:seed # dev seed — integration tests rely on it
pnpm test:integration # node + real DB — must pass

View file

@ -49,13 +49,6 @@ 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

View file

@ -118,95 +118,6 @@ 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.28.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.48.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.78.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.98.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`.
@ -230,9 +141,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.
```

View file

@ -1,55 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const PATH = "/admin/crew-strength";
async function guard(): Promise<{ error: string } | { ok: true }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
return { ok: true };
}
const schema = z.object({
vesselId: z.string().min(1, "Vessel is required"),
rankId: z.string().min(1, "Rank is required"),
minStrength: z.coerce.number().int().min(0, "Strength must be 0 or more").max(999),
});
// Per-vessel, per-rank required strength (drives leave-clash detection, R6).
export async function upsertRequirement(formData: FormData): Promise<ActionResult> {
const denied = await guard();
if ("error" in denied) return denied;
const parsed = schema.safeParse({
vesselId: formData.get("vesselId"),
rankId: formData.get("rankId"),
minStrength: formData.get("minStrength"),
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
await db.vesselRankRequirement.upsert({
where: { vesselId_rankId: { vesselId: d.vesselId, rankId: d.rankId } },
update: { minStrength: d.minStrength },
create: { vesselId: d.vesselId, rankId: d.rankId, minStrength: d.minStrength },
});
revalidatePath(PATH);
return { ok: true };
}
export async function deleteRequirement(id: string): Promise<ActionResult> {
const denied = await guard();
if ("error" in denied) return denied;
await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {});
revalidatePath(PATH);
return { ok: true };
}

View file

@ -1,82 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { upsertRequirement, deleteRequirement } from "./actions";
const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
type Req = { id: string; vessel: string; rank: string; minStrength: number };
export function CrewStrengthManager({ requirements, vessels, ranks }: { requirements: Req[]; vessels: Opt[]; ranks: RankOpt[] }) {
const router = useRouter();
const [f, setF] = useState({ vesselId: "", rankId: "", minStrength: "1" });
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("vesselId", f.vesselId); fd.set("rankId", f.rankId); fd.set("minStrength", f.minStrength);
const res = await upsertRequirement(fd);
setPending(false);
if ("error" in res) setError(res.error); else { setF({ vesselId: "", rankId: "", minStrength: "1" }); router.refresh(); }
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Crew strength</h1>
<p className="text-sm text-neutral-500 mt-0.5">Required crew per rank, per vessel. Drives the leave-clash backfill a leave that drops cover below the required strength auto-raises a requisition.</p>
</div>
<form onSubmit={submit} className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-neutral-200 bg-white p-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel</label>
<select className={INPUT} value={f.vesselId} onChange={(e) => setF({ ...f, vesselId: e.target.value })} required><option value=""> Vessel </option>{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank</label>
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value=""> Rank </option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Min strength</label>
<input className={`${INPUT} w-28`} type="number" min={0} value={f.minStrength} onChange={(e) => setF({ ...f, minStrength: e.target.value })} required />
</div>
<button className={BTN} disabled={pending || !f.vesselId || !f.rankId}>{pending ? "Saving…" : "Set requirement"}</button>
{error && <p className="w-full text-sm text-danger-700">{error}</p>}
</form>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Vessel</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Min strength</th>
<th className="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
{requirements.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-12 text-center text-neutral-400">No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.</td></tr>
) : requirements.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 text-neutral-800">{r.vessel}</td>
<td className="px-4 py-3 text-neutral-700">{r.rank}</td>
<td className="px-4 py-3 font-semibold text-neutral-900">{r.minStrength}</td>
<td className="px-4 py-3 text-right">
<button className="text-xs font-medium text-danger-600 hover:underline" onClick={async () => { await deleteRequirement(r.id); router.refresh(); }}>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,34 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { CrewStrengthManager } from "./crew-strength-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew strength" };
export default async function CrewStrengthPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
const [requirements, vessels, ranks] = await Promise.all([
db.vesselRankRequirement.findMany({
orderBy: [{ vessel: { name: "asc" } }, { rank: { name: "asc" } }],
include: { vessel: { select: { name: true } }, rank: { select: { name: true } } },
}),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
]);
return (
<CrewStrengthManager
requirements={requirements.map((r) => ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))}
vessels={vessels}
ranks={ranks}
/>
);
}

View file

@ -1,167 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const PATH = "/admin/crew";
async function guard(): Promise<{ error: string } | { userId: string }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
return { userId: session.user.id };
}
const crewSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
status: z.nativeEnum(CrewStatus).default("CANDIDATE"),
type: z.nativeEnum(CandidateType).default("NEW"),
source: z.nativeEnum(CandidateSource).default("CAREERS"),
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
phone: z.string().optional(),
appliedRankId: z.string().optional(),
currentRankId: z.string().optional(),
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
});
function parse(formData: FormData) {
return crewSchema.safeParse({
name: formData.get("name"),
status: (formData.get("status") as string) || undefined,
type: (formData.get("type") as string) || undefined,
source: (formData.get("source") as string) || undefined,
email: (formData.get("email") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
currentRankId: (formData.get("currentRankId") as string) || undefined,
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
});
}
export async function createCrewMember(formData: FormData): Promise<ActionResult> {
const g = await guard();
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const crew = await db.crewMember.create({
data: {
name: d.name, status: d.status, type: d.type, source: d.source,
email: d.email || null, phone: d.phone || null,
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
},
});
revalidatePath(PATH);
return { ok: true, id: crew.id };
}
export async function updateCrewMember(formData: FormData): Promise<ActionResult> {
const g = await guard();
if ("error" in g) return g;
const id = formData.get("id") as string;
if (!id) return { error: "Crew ID is required" };
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await db.crewMember.findUnique({ where: { id }, select: { id: true } }))) return { error: "Crew member not found" };
await db.crewMember.update({
where: { id },
data: {
name: d.name, status: d.status, type: d.type, source: d.source,
email: d.email || null, phone: d.phone || null,
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
},
});
revalidatePath(PATH);
return { ok: true };
}
export async function deleteCrewMember(id: string): Promise<ActionResult> {
const g = await guard();
if ("error" in g) return g;
const crew = await db.crewMember.findUnique({
where: { id },
select: { _count: { select: { assignments: true, applications: true } } },
});
if (!crew) return { error: "Crew member not found" };
if (crew._count.assignments > 0 || crew._count.applications > 0) {
return { error: "Cannot delete: this crew member has assignments or applications. Remove those first." };
}
await db.crewAction.deleteMany({ where: { crewMemberId: id } });
await db.crewMember.delete({ where: { id } });
revalidatePath(PATH);
return { ok: true };
}
// ── Direct placement (Manager) — assign crew to a vessel/site, no requisition ──
const placeSchema = z
.object({
crewMemberId: z.string().min(1, "Crew member is required"),
rankId: z.string().min(1, "Rank is required"),
vesselId: z.string().optional(),
siteId: z.string().optional(),
signOnDate: z.string().min(1, "Joining date is required"),
})
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required" });
export async function placeCrew(formData: FormData): Promise<ActionResult> {
const g = await guard();
if ("error" in g) return g;
const parsed = placeSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
rankId: formData.get("rankId"),
vesselId: (formData.get("vesselId") as string) || undefined,
siteId: (formData.get("siteId") as string) || undefined,
signOnDate: formData.get("signOnDate"),
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const crew = await db.crewMember.findUnique({
where: { id: d.crewMemberId },
include: { assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true } } },
});
if (!crew) return { error: "Crew member not found" };
if (crew.assignments.length > 0) return { error: "This crew member already has an active assignment" };
await db.$transaction(async (tx) => {
await tx.crewAssignment.create({
data: {
status: "ACTIVE",
signOnDate: new Date(d.signOnDate),
crewMemberId: crew.id,
rankId: d.rankId,
vesselId: d.vesselId || null,
siteId: d.siteId || null,
},
});
// Promote a candidate/ex-hand to active crew (employee no.) on first placement.
const data: { status: "EMPLOYEE"; currentRankId: string; employeeId?: string } = { status: "EMPLOYEE", currentRankId: d.rankId };
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
await tx.crewMember.update({ where: { id: crew.id }, data });
await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } });
// Management ranks (grantsLogin) become a SITE_STAFF login on placement.
await maybeCreateSiteStaffLogin(tx, { name: crew.name, email: crew.email, employeeId: data.employeeId ?? crew.employeeId }, d.rankId, d.siteId || null);
});
revalidatePath(PATH);
revalidatePath("/crewing/crew");
return { ok: true };
}

View file

@ -1,201 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } from "./actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50";
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
const TYPES: CandidateType[] = ["NEW", "EX_HAND"];
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
type Crew = {
id: string; name: string; status: CrewStatus; type: CandidateType; source: CandidateSource;
email: string | null; phone: string | null; employeeId: string | null;
appliedRankId: string | null; currentRankId: string | null; currentRank: string | null;
experienceMonths: number; hasActiveAssignment: boolean; removable: boolean;
};
const STATUS_VARIANT: Record<CrewStatus, "outline" | "default" | "success" | "secondary" | "danger"> = {
PROSPECT: "outline", CANDIDATE: "default", EMPLOYEE: "success", EX_HAND: "secondary", BLACKLISTED: "danger",
};
export function AdminCrewManager({ crew, ranks, vessels, sites }: { crew: Crew[]; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return crew.filter((c) => !q || `${c.name} ${c.employeeId ?? ""}`.toLowerCase().includes(q));
}, [crew, search]);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Crew management</h1>
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} crew records · create, edit, place onto a vessel/site, or remove</p>
</div>
<CrewFormButton ranks={ranks} />
</div>
<input className={`${INPUT} mb-4 max-w-sm`} placeholder="Search name or employee no…" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Employee</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3 w-12"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">No crew records.</td></tr>
) : filtered.map((c) => <Row key={c.id} c={c} ranks={ranks} vessels={vessels} sites={sites} />)}
</tbody>
</table>
</div>
</div>
);
}
function Row({ c, ranks, vessels, sites }: { c: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
const [editOpen, setEditOpen] = useState(false);
const [placeOpen, setPlaceOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{c.name}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId ?? "—"}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{label(c.status)}</Badge></td>
<td className="px-4 py-3 text-neutral-700">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-right">
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
{!c.hasActiveAssignment && <RowActionsItem onClick={() => setPlaceOpen(true)}>Place onto vessel/site</RowActionsItem>}
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<CrewFormButton ranks={ranks} editing={c} open={editOpen} onOpenChange={setEditOpen} />
<PlaceDialog crew={c} ranks={ranks} vessels={vessels} sites={sites} open={placeOpen} onOpenChange={setPlaceOpen} />
<DeleteConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} label={c.name} onConfirm={() => deleteCrewMember(c.id)} />
</td>
</tr>
);
}
function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt[]; editing?: Crew; open?: boolean; onOpenChange?: (v: boolean) => void }) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [f, setF] = useState({
name: editing?.name ?? "", status: editing?.status ?? "CANDIDATE", type: editing?.type ?? "NEW", source: editing?.source ?? "CAREERS",
email: editing?.email ?? "", phone: editing?.phone ?? "", appliedRankId: editing?.appliedRankId ?? "", currentRankId: editing?.currentRankId ?? "",
experienceMonths: String(editing?.experienceMonths ?? 0),
});
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
if (editing) fd.set("id", editing.id);
Object.entries(f).forEach(([k, v]) => v !== "" && fd.set(k, String(v)));
const res = await (editing ? updateCrewMember(fd) : createCrewMember(fd));
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
{!isControlled && <button className={BTN} onClick={() => setOpen(true)}>+ Add crew</button>}
<AdminDialog title={editing ? "Edit crew member" : "Add crew member"} open={isOpen} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
<input className={INPUT} type="number" min={0} placeholder="Experience (months)" value={f.experienceMonths} onChange={(e) => setF({ ...f, experienceMonths: e.target.value })} />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.name} className={BTN}>{pending ? "Saving…" : editing ? "Save changes" : "Add crew"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function PlaceDialog({ crew, ranks, vessels, sites, open, onOpenChange }: { crew: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[]; open: boolean; onOpenChange: (v: boolean) => void }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [f, setF] = useState({ rankId: crew.currentRankId ?? crew.appliedRankId ?? "", location: "", signOnDate: "" });
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("crewMemberId", crew.id);
fd.set("rankId", f.rankId);
if (f.location.startsWith("v:")) fd.set("vesselId", f.location.slice(2));
else if (f.location.startsWith("s:")) fd.set("siteId", f.location.slice(2));
fd.set("signOnDate", f.signOnDate);
const res = await placeCrew(fd);
setPending(false);
if ("error" in res) setError(res.error); else { onOpenChange(false); router.refresh(); }
}
return (
<AdminDialog title={`Place ${crew.name}`} open={open} onClose={() => onOpenChange(false)}>
<form onSubmit={submit} className="space-y-3">
<p className="text-sm text-neutral-600">Assign this crew member directly to a vessel/site no requisition needed. A candidate is promoted to active crew with an employee number.</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value=""> Rank </option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
<select className={INPUT} value={f.location} onChange={(e) => setF({ ...f, location: e.target.value })} required>
<option value=""> Select </option>
{vessels.length > 0 && <optgroup label="Vessels">{vessels.map((v) => <option key={v.id} value={`v:${v.id}`}>{v.name}</option>)}</optgroup>}
{sites.length > 0 && <optgroup label="Sites">{sites.map((s) => <option key={s.id} value={`s:${s.id}`}>{s.name}</option>)}</optgroup>}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
<input type="date" className={INPUT} value={f.signOnDate} onChange={(e) => setF({ ...f, signOnDate: e.target.value })} required />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" className={SECONDARY} onClick={() => onOpenChange(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.rankId || !f.location || !f.signOnDate} className={BTN}>{pending ? "Placing…" : "Place crew"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -1,56 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { AdminCrewManager } from "./admin-crew-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew management" };
export default async function AdminCrewPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
const [crew, ranks, vessels, sites] = await Promise.all([
db.crewMember.findMany({
orderBy: { name: "asc" },
include: {
currentRank: { select: { name: true } },
appliedRank: { select: { name: true } },
assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true }, take: 1 },
_count: { select: { assignments: true, applications: true } },
},
}),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
return (
<AdminCrewManager
crew={crew.map((c) => ({
id: c.id,
name: c.name,
status: c.status,
type: c.type,
source: c.source,
email: c.email,
phone: c.phone,
employeeId: c.employeeId,
appliedRankId: c.appliedRankId,
currentRankId: c.currentRankId,
currentRank: c.currentRank?.name ?? null,
experienceMonths: c.experienceMonths,
hasActiveAssignment: c.assignments.length > 0,
removable: c._count.assignments === 0 && c._count.applications === 0,
}))}
ranks={ranks}
vessels={vessels}
sites={sites}
/>
);
}

View file

@ -1,187 +0,0 @@
"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<boolean> {
if (rankId === candidateParentId) return true;
const all = await db.rank.findMany({ select: { id: true, parentId: true } });
const childrenOf = new Map<string, string[]>();
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
const denied = await guard();
if (denied) return denied;
await db.rankDocRequirement.delete({ where: { id } });
revalidatePath("/admin/ranks");
return { ok: true };
}

View file

@ -1,44 +0,0 @@
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 <RanksManager ranks={rows} />;
}

View file

@ -1,132 +0,0 @@
"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<SeafarerDocType, string>;
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 (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-400">
Select a rank to manage its required documents.
</div>
);
}
async function handleAdd(e: React.FormEvent<HTMLFormElement>) {
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 (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-neutral-900">Required documents</h2>
<p className="text-xs text-neutral-500 mt-0.5">{rank.code} {rank.name}</p>
</div>
<button
onClick={() => setAdding((v) => !v)}
className="rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100"
>
{adding ? "Close" : "+ Add"}
</button>
</div>
{adding && (
<form onSubmit={handleAdd} className="px-4 py-3 border-b border-neutral-100 bg-neutral-50/50 space-y-2">
<select name="docType" className={INPUT} defaultValue={DOC_TYPES[0].value}>
{DOC_TYPES.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isMandatory" defaultChecked className="h-4 w-4" />
Mandatory (uncheck for conditional)
</label>
<input name="note" className={INPUT} placeholder="Note (optional)" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Saving…" : "Add requirement"}
</button>
</form>
)}
{rank.docRequirements.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-neutral-400">No required documents for this rank.</p>
) : (
<div>
{rank.docRequirements.map((d) => (
<div key={d.id} className="flex items-center gap-2 px-4 py-2.5 border-b border-neutral-100 last:border-0">
<span className="text-sm text-neutral-900 flex-1">{DOC_LABEL[d.docType] ?? d.docType}</span>
{d.note && <span className="text-xs text-neutral-400 max-w-[10rem] truncate">{d.note}</span>}
<span
className={
d.isMandatory
? "rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-xs font-medium"
: "rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs font-medium"
}
>
{d.isMandatory ? "Mandatory" : "Conditional"}
</span>
<button
onClick={() => handleRemove(d.id)}
className="text-xs text-danger-700 hover:underline"
title="Remove"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,184 +0,0 @@
"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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
<input name="code" defaultValue={rank?.code} required maxLength={16} placeholder="e.g. SDO" className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input name="name" defaultValue={rank?.name} required placeholder="e.g. Sr. Dredge Operator" className={INPUT} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reports to</label>
<select name="parentId" defaultValue={rank?.parentId ?? ""} className={INPUT}>
<option value=""> Top of the org </option>
{parentOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.code} {r.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category</label>
<select name="category" defaultValue={rank?.category ?? "OPERATIONAL"} className={INPUT}>
<option value="OPERATIONAL">Operational</option>
<option value="SUPPORT">Support</option>
</select>
</div>
</div>
<div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isSeafarer" defaultChecked={rank?.isSeafarer ?? false} className="h-4 w-4" />
Seafarer (holds STCW / CDC etc.)
</label>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="grantsLogin" defaultChecked={rank?.grantsLogin ?? false} className="h-4 w-4" />
Grants portal login
</label>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
<input name="description" defaultValue={rank?.description ?? ""} className={INPUT} placeholder="Optional" />
</div>
</div>
);
}
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<HTMLFormElement>) {
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 (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Add Rank
</button>
<AdminDialog title="Add Rank" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<RankFormFields allRanks={allRanks} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Creating…" : "Create Rank"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
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<HTMLFormElement>) {
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 (
<AdminDialog title="Edit Rank" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<RankFormFields rank={rank} allRanks={allRanks} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
Cancel
</button>
<button
type="submit"
disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
>
{pending ? "Saving…" : "Save Changes"}
</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -1,200 +0,0 @@
"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<string, TreeNode>();
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 (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}>
{rank.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditRankButton rank={rank} allRanks={allRanks} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
label={`${rank.code}${rank.name}`}
onConfirm={() => deleteRank(rank.id)}
/>
<ConfirmDialog
open={toggleOpen}
onOpenChange={setToggleOpen}
title={rank.isActive ? `Deactivate ${rank.name}?` : `Activate ${rank.name}?`}
description={
rank.isActive
? `${rank.name} will be hidden from new requisitions and crew records.`
: `${rank.name} will become available again.`
}
confirmLabel={rank.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleRankActive(rank.id)}
/>
</>
);
}
function RankRowView({
node,
depth,
allRanks,
selectedId,
onSelect,
}: {
node: TreeNode;
depth: number;
allRanks: RankRow[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const isSelected = node.id === selectedId;
return (
<>
<div
className={cn(
"flex items-center gap-2 px-3 py-2 border-b border-neutral-100 last:border-0 cursor-pointer",
isSelected ? "bg-primary-50" : "hover:bg-neutral-50"
)}
style={{ paddingLeft: 12 + depth * 20 }}
onClick={() => onSelect(node.id)}
>
<span className="font-mono text-xs text-neutral-400 w-12 shrink-0">{node.code}</span>
<span className={cn("text-sm flex-1", node.isActive ? "text-neutral-900" : "text-neutral-400 line-through")}>
{node.name}
</span>
{node.grantsLogin && (
<span className="rounded-full bg-primary-100 text-primary-700 px-2 py-0.5 text-xs font-medium">Login</span>
)}
{node.isSeafarer && (
<span className="rounded-full bg-neutral-100 text-neutral-600 px-2 py-0.5 text-xs font-medium">Seafarer</span>
)}
<span className="rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs">{node.category}</span>
<span className="text-xs text-neutral-400 w-16 text-right shrink-0">
{node.docRequirements.length} doc{node.docRequirements.length === 1 ? "" : "s"}
</span>
<div onClick={(e) => e.stopPropagation()}>
<RankActionsMenu rank={node} allRanks={allRanks} />
</div>
</div>
{node.children.map((child) => (
<RankRowView
key={child.id}
node={child}
depth={depth + 1}
allRanks={allRanks}
selectedId={selectedId}
onSelect={onSelect}
/>
))}
</>
);
}
export function RanksManager({ ranks }: { ranks: RankRow[] }) {
const tree = buildTree(ranks);
const [selectedId, setSelectedId] = useState<string | null>(ranks[0]?.id ?? null);
const selected = ranks.find((r) => r.id === selectedId) ?? null;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Ranks &amp; Documents</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{ranks.length} ranks · the crew org chart and the documents each rank must hold
</p>
</div>
<AddRankButton allRanks={ranks} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* Rank hierarchy card */}
<div className="lg:col-span-3 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Rank hierarchy</h2>
<p className="text-xs text-neutral-500 mt-0.5">
The org chart. <span className="text-primary-700 font-medium">Login</span> ranks (PM, Assistant PM, Site
In-charge) map to a portal account; all others are crew records.
</p>
</div>
{tree.length === 0 ? (
<p className="px-4 py-12 text-center text-neutral-400">No ranks yet. Add a top-level rank to begin.</p>
) : (
<div>
{tree.map((node) => (
<RankRowView
key={node.id}
node={node}
depth={0}
allRanks={ranks}
selectedId={selectedId}
onSelect={setSelectedId}
/>
))}
</div>
)}
</div>
{/* Required documents card */}
<div className="lg:col-span-2">
<RankDocPanel rank={selected} />
</div>
</div>
</div>
);
}

View file

@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
SUBMITTED: "Submitted", REJECTED: "Rejected",
};
return (

View file

@ -22,7 +22,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser",
AUDITOR: "Auditor",
ADMIN: "Admin",
SITE_STAFF: "Site Staff",
};
export default async function SuperUserRequestsPage() {

View file

@ -30,7 +30,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser",
AUDITOR: "Auditor",
ADMIN: "Admin",
SITE_STAFF: "Site Staff",
};
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];

View file

@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
};

View file

@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
};
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")

View file

@ -1,120 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import {
approveSalary,
returnSalary,
selectCandidate,
returnSelection,
approveInterviewWaiver,
declineInterviewWaiver,
} from "../crewing/applications/actions";
import { decideLeave } from "../crewing/leave/actions";
import { approveAppraisal } from "../crewing/appraisals/actions";
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE" | "APPRAISAL";
export type CrewApprovalItem = {
id: string; // applicationId, or leaveRequestId for LEAVE
kind: CrewApprovalKind;
candidateName: string;
rank: string;
requisitionCode: string;
detail: string;
link: string;
};
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave", APPRAISAL: "Appraisal" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning", APPRAISAL: "default" } as const;
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary,
SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver,
LEAVE: (id) => decideLeave(id, true),
APPRAISAL: (id) => approveAppraisal(id, true),
};
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: returnSalary,
SELECTION: returnSelection,
WAIVER: declineInterviewWaiver,
LEAVE: (id, reason) => decideLeave(id, false, reason),
APPRAISAL: (id, reason) => approveAppraisal(id, false, reason),
};
function Row({ item }: { item: CrewApprovalItem }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [returnOpen, setReturnOpen] = useState(false);
const [reason, setReason] = useState("");
async function approve() {
setPending(true); setError("");
const res = await approveFn[item.kind](item.id);
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function doReturn(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await returnFn[item.kind](item.id, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
}
return (
<tr className="hover:bg-neutral-50">
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3">
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
<form onSubmit={doReturn} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Return</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
return (
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -5,8 +5,6 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { ApprovalsSearch } from "./approvals-search";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
import { Suspense } from "react";
import type { Metadata } from "next";
@ -51,88 +49,6 @@ export default async function ApprovalsPage({ searchParams }: Props) {
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
const role = session.user.role;
const showCrewing =
CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver") ||
hasPermission(role, "decide_leave") ||
hasPermission(role, "approve_appraisal"));
const crewGates = showCrewing
? await db.applicationGate.findMany({
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
orderBy: { createdAt: "asc" },
include: {
application: {
include: {
crewMember: { select: { name: true } },
requisition: { select: { code: true, rank: { select: { name: true } } } },
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
},
},
},
})
: [];
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
const sal = g.application.salaryStructures[0];
const detail =
g.gate === "SALARY" && sal
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
: g.gate === "WAIVER"
? "Returning crew — interview waiver"
: "Interview cleared";
return {
id: g.applicationId,
kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code,
detail,
link: `/crewing/applications/${g.applicationId}`,
};
});
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
? (await db.leaveRequest.findMany({
where: { status: "APPLIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((l) => ({
id: l.id,
kind: "LEAVE" as CrewApprovalKind,
candidateName: l.assignment.crewMember.name,
rank: l.assignment.rank.name,
requisitionCode: `${l.fromDate.toLocaleDateString()}${l.toDate.toLocaleDateString()}`,
detail: l.type.toLowerCase(),
link: "/crewing/leave",
}))
: [];
// MPO-verified appraisals awaiting Manager approval (§8.13/§8.14).
const appraisalItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "approve_appraisal"))
? (await db.appraisal.findMany({
where: { status: "MPO_VERIFIED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})).map((a) => ({
id: a.id,
kind: "APPRAISAL" as CrewApprovalKind,
candidateName: a.assignment.crewMember.name,
rank: a.assignment.rank.name,
requisitionCode: a.period,
detail: "MPO-verified appraisal",
link: "/approvals",
}))
: [];
const allCrewItems = [...crewItems, ...leaveItems, ...appraisalItems];
return (
<div>
<div className="mb-4">
@ -221,8 +137,6 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</div>
</>
)}
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
</div>
);
}

View file

@ -1,144 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ApplicationActionCard } from "../application-action-card";
import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
import { experienceLabel } from "../../candidates/candidate-ui";
import { cn } from "@/lib/utils";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Application" };
export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
const { id } = await params;
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
gates: true,
salaryStructures: { orderBy: { createdAt: "desc" } },
},
});
if (!app) notFound();
const gate = (t: string) => app.gates.find((g) => g.gate === t);
const salaryPending = gate("SALARY")?.result === "PENDING";
const waiverPending = gate("WAIVER")?.result === "PENDING";
const selectionPending = gate("SELECTION")?.result === "PENDING";
const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
const curIdx = stageIndex(app.stage);
return (
<div className="max-w-4xl">
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Pipeline · {app.requisition.code}
</Link>
<div className="mb-6 flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
{app.crewMember.type === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
<p className="text-sm text-neutral-500 -mt-4 mb-6">
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
</p>
{/* 7-step stepper */}
<div className="mb-6 flex flex-wrap gap-2">
{STAGE_ORDER.map((s, i) => {
const done = curIdx > i || app.stage === "ONBOARDED";
const current = curIdx === i;
return (
<div key={s} className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
)}>
{done && <Check className="h-3 w-3" />}
{STAGE_LABEL[s]}
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Adaptive action card */}
<ApplicationActionCard
id={app.id}
stage={app.stage}
isExHand={app.crewMember.type === "EX_HAND"}
interviewResult={app.interviewResult}
interviewWaived={app.interviewWaived}
rejectedReason={app.rejectedReason}
salaryPending={salaryPending}
waiverPending={waiverPending}
selectionPending={selectionPending}
employeeNo={app.crewMember.employeeId}
salary={proposed ? {
rateBasis: proposed.rateBasis,
basic: Number(proposed.basic),
victualingPerDay: Number(proposed.victualingPerDay),
currency: proposed.currency,
approved: Boolean(proposed.approvedById),
} : null}
perms={{
manage: hasPermission(role, "manage_candidates"),
recordReference: hasPermission(role, "record_reference_check"),
recordInterview: hasPermission(role, "record_interview_result"),
requestWaiver: hasPermission(role, "request_interview_waiver"),
approveSalary: hasPermission(role, "approve_salary_structure"),
approveWaiver: hasPermission(role, "approve_interview_waiver"),
select: hasPermission(role, "select_candidate"),
onboard: hasPermission(role, "onboard_crew"),
}}
/>
{/* Profile */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{([
["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
["Last rank held", app.crewMember.currentRank?.name ?? "—"],
["Experience", experienceLabel(app.crewMember.experienceMonths)],
["Source", app.crewMember.source],
] as [string, string][]).map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{app.crewMember.type === "EX_HAND" && (
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
Returning crew prior docs/bank/tour on file; interview may be waived with Manager approval.
</div>
)}
<div className="px-4 py-3 border-t border-neutral-100">
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
View full candidate profile
</Link>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,680 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import {
canPerformAction,
canReject,
getTransition,
type ApplicationAction,
} from "@/lib/application-pipeline";
import { getManagerRecipients } from "@/lib/requisition-service";
import { generateEmployeeId } from "@/lib/employee-number";
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const appPath = (id: string) => `/crewing/applications/${id}`;
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// Load an application with the bits the actions need; null if missing.
async function loadApp(id: string) {
return db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
crewMember: { select: { id: true, name: true, type: true } },
},
});
}
function revalidateApp(applicationId: string, requisitionId: string) {
revalidatePath(appPath(applicationId));
revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
revalidatePath("/approvals");
}
// ── Add a candidate to a requisition's pipeline ────────────────────────────────
export async function addApplication(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const requisitionId = formData.get("requisitionId") as string;
const crewMemberId = formData.get("crewMemberId") as string;
if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
const [requisition, candidate, existing] = await Promise.all([
db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
]);
if (!requisition) return { error: "Requisition not found" };
if (!candidate) return { error: "Candidate not found" };
if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
return { error: `Cannot add candidates to a ${requisition.status} requisition` };
}
if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
const application = await db.application.create({
data: {
requisitionId,
crewMemberId,
type: candidate.type,
stage: "SHORTLISTED",
actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
},
});
// First candidate moves the requisition from OPEN into sourcing.
if (requisition.status === "OPEN") {
await db.requisition.update({
where: { id: requisitionId },
data: {
status: "SHORTLISTING",
actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
},
});
}
revalidateApp(application.id, requisitionId);
return { ok: true, id: application.id };
}
// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
// select have dedicated actions below.
export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
return { error: "Use the dedicated action for this step" };
}
const g = await guard("manage_candidates");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, action);
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
// gate is completed by `verify_competency`.
if (action === "verify_competency") {
const references = await db.referenceCheck.count({ where: { applicationId: id } });
if (references === 0) {
return { error: "Record at least one reference check before completing competency & references" };
}
}
await db.application.update({
where: { id },
data: {
stage: transition.to,
// Completing the competency & references stage records its gate.
...(action === "verify_competency"
? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
: {}),
actions: {
create: {
actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
actorId: g.userId,
crewMemberId: app.crewMemberId,
metadata: { from: app.stage, to: transition.to },
},
},
},
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
const referenceSchema = z.object({
refereeName: z.string().trim().min(1, "Referee name is required"),
refereeContact: z.string().optional(),
outcome: z.string().optional(),
note: z.string().optional(),
});
export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
const g = await guard("record_reference_check");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const parsed = referenceSchema.safeParse({
refereeName: formData.get("refereeName"),
refereeContact: (formData.get("refereeContact") as string) || undefined,
outcome: (formData.get("outcome") as string) || undefined,
note: (formData.get("note") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
await db.referenceCheck.create({
data: {
applicationId: id,
refereeName: parsed.data.refereeName,
refereeContact: parsed.data.refereeContact ?? null,
outcome: parsed.data.outcome ?? null,
note: parsed.data.note ?? null,
recordedById: g.userId,
},
});
await db.crewAction.create({
data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
const docsSchema = z.object({
accountName: z.string().optional(),
accountNumber: z.string().optional(),
ifsc: z.string().optional(),
bankName: z.string().optional(),
uan: z.string().optional(),
aadhaarLast4: z.string().optional(),
pfNumber: z.string().optional(),
note: z.string().optional(),
});
export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
const transition = getTransition(app.stage, "verify_docs");
if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
const parsed = docsSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const crewMemberId = app.crewMember.id;
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
// the seat's rank is EXPIRED.
// Scope note (documented limitation): seafarer documents are collected on the
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
// usually has none on file, so a hard "missing document" block would stall the
// whole funnel. We therefore gate on what is available (expiry of documents the
// candidate already holds); the "all required documents present" check is
// enforced post-onboarding in the verification queue (§8.11). Once careers
// intake (A2) uploads documents pre-onboarding, tighten this to also require
// presence of every mandatory docType.
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
if (reqRank) {
const [required, candidateDocs] = await Promise.all([
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
]);
const requiredTypes = new Set(required.map((r) => r.docType));
const now = new Date();
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
if (expired.length > 0) {
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
}
}
// C4 (experience check) is deferred: the Requisition has no min-experience
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
// the candidate's ExperienceRecord total against it here and flag a shortfall.
await db.$transaction(async (tx) => {
// Capture bank / EPF (PII — encryption deferred to Phase 4).
await tx.bankDetail.upsert({
where: { crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.application.update({
where: { id },
data: {
stage: transition.to,
gates: {
create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
},
actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
const salarySchema = z.object({
rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
basic: z.coerce.number().positive("Basic must be greater than 0"),
victualingPerDay: z.coerce.number().min(0).default(0),
currency: z.string().default("INR"),
});
export async function agreeSalary(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
const parsed = salarySchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
await db.$transaction(async (tx) => {
// One live proposed structure per application — replace any prior draft.
await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
await tx.salaryStructure.create({
data: {
applicationId: id,
rateBasis: d.rateBasis,
basic: d.basic,
victualingPerDay: d.victualingPerDay,
currency: d.currency,
},
});
// Salary gate goes PENDING for the Manager's queue.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
update: { result: "PENDING", decidedById: null, note: null },
create: { applicationId: id, gate: "SALARY", result: "PENDING" },
});
await tx.crewAction.create({
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
});
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SALARY_FOR_APPROVAL",
recipients: managers,
subject: `Salary for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveSalary(id: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
await db.$transaction(async (tx) => {
await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: {
stage: "PROPOSED",
actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
},
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_salary_structure");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return for revision" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "SALARY" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
const g = await guard("record_interview_result");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
if (!accepted) {
// A failed interview rejects the application.
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
}
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
});
// Selection now pending for the Manager.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
});
const managers = await getManagerRecipients();
await notifyCrew({
event: "SELECTION_FOR_APPROVAL",
recipients: managers,
subject: `Selection for approval — ${app.crewMember.name}`,
body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
const g = await guard("request_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
await db.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
});
await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "WAIVER_REQUESTED",
recipients: managers,
subject: `Interview waiver requested — ${app.crewMember.name}`,
body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
link: appPath(id),
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.$transaction(async (tx) => {
await tx.application.update({ where: { id }, data: { interviewWaived: true } });
await tx.applicationGate.update({
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
data: { result: "VERIFIED", decidedById: g.userId },
});
// Waived → selection is now pending.
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "PENDING", decidedById: null },
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
});
await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
const g = await guard("approve_interview_waiver");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to decline" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.applicationGate.updateMany({
where: { applicationId: id, gate: "WAIVER" },
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
});
await db.crewAction.create({
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function selectCandidate(id: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
}
await db.$transaction(async (tx) => {
await tx.applicationGate.upsert({
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
update: { result: "VERIFIED", decidedById: g.userId },
create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
});
await tx.application.update({
where: { id },
data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
});
// The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
});
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
const g = await guard("select_candidate");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to return" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
await db.$transaction(async (tx) => {
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
});
revalidateApp(id, app.requisition.id);
return { ok: true };
}
// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
async function rejectApplicationInternal(
id: string,
crewMemberId: string,
requisitionId: string,
userId: string,
reason: string
): Promise<ActionResult> {
await db.application.update({
where: { id },
data: {
stage: "REJECTED",
rejectedReason: reason,
rejectedAt: new Date(),
actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
},
});
revalidateApp(id, requisitionId);
return { ok: true };
}
export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
if (!reason?.trim()) return { error: "A reason is required to reject" };
const app = await loadApp(id);
if (!app) return { error: "Application not found" };
if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
}
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
// One transaction off a SELECTED application: assign the employee number, create
// the ACTIVE assignment, bind the approved salary, flip the application to
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
// Login-account creation for management ranks is a deferred follow-up.
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("onboard_crew");
if ("error" in g) return g;
const id = formData.get("applicationId") as string;
const joiningStr = formData.get("joiningDate") as string;
if (!joiningStr) return { error: "A joining date is required" };
const app = await db.application.findUnique({
where: { id },
include: {
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
crewMember: { select: { id: true, name: true, email: true } },
},
});
if (!app) return { error: "Application not found" };
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
// D1 (spec §8.5): onboarding is blocked until the salary structure is
// Manager-approved. Without this guard a SELECTED application that somehow has
// no approved structure would still "succeed" but bind zero salary rows
// (the updateMany below would match nothing) — a silent payroll gap.
const approvedSalary = await db.salaryStructure.findFirst({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
select: { id: true },
orderBy: { createdAt: "desc" },
});
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
const joiningDate = new Date(joiningStr);
// Upload the optional contract letter BEFORE the transaction (storage I/O),
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
// event (spec §11). The blob key is keyed on the crew member (stable before the
// assignment exists); if the tx fails we leave only a harmless orphan blob,
// never a fully-onboarded crew member with no contract row.
const file = formData.get("contract");
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
if (file instanceof File && file.size > 0) {
const key = buildStorageKey("contract", app.crewMember.id, file.name);
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
}
const result = await db.$transaction(async (tx) => {
const employeeId = await generateEmployeeId(tx);
const assignment = await tx.crewAssignment.create({
data: {
status: "ACTIVE",
signOnDate: joiningDate,
crewMemberId: app.crewMember.id,
rankId: app.requisition.rankId,
vesselId: app.requisition.vesselId,
siteId: app.requisition.siteId,
requisitionId: app.requisition.id,
},
});
// Bind the Manager-approved salary structure to the new assignment.
await tx.salaryStructure.updateMany({
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
});
if (contract) {
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
}
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
await tx.application.update({
where: { id },
data: {
stage: "ONBOARDED",
actions: {
create: {
actionType: "CREW_ONBOARDED",
actorId: g.userId,
crewMemberId: app.crewMember.id,
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
},
},
},
});
await tx.requisition.update({
where: { id: app.requisition.id },
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
});
await tx.crewMember.update({
where: { id: app.crewMember.id },
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
});
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
return { assignmentId: assignment.id, employeeId };
});
revalidateApp(id, app.requisition.id);
return { ok: true, id: result.employeeId };
}

View file

@ -1,400 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import {
advanceStage,
agreeSalary,
approveSalary,
returnSalary,
verifyDocuments,
recordReferenceCheck,
recordInterviewResult,
requestInterviewWaiver,
approveInterviewWaiver,
selectCandidate,
returnSelection,
rejectApplication,
onboardCandidate,
} from "./actions";
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
export type ActionCardProps = {
id: string;
stage: ApplicationStage;
isExHand: boolean;
interviewResult: InterviewOutcome;
interviewWaived: boolean;
rejectedReason: string | null;
salaryPending: boolean;
waiverPending: boolean;
selectionPending: boolean;
employeeNo: string | null;
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
perms: {
manage: boolean;
recordReference: boolean;
recordInterview: boolean;
requestWaiver: boolean;
approveSalary: boolean;
approveWaiver: boolean;
select: boolean;
onboard: boolean;
};
};
function useAction() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
setPending(true);
setError("");
const res = await fn();
setPending(false);
if ("error" in res) setError(res.error);
else router.refresh();
return res;
}
return { pending, error, run };
}
function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
</div>
<div className="p-4 space-y-3">{children}</div>
</div>
);
}
function RejectButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await rejectApplication(id, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Rejecting…" : "Reject"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function Err({ msg }: { msg: string }) {
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null;
}
export function ApplicationActionCard(p: ActionCardProps) {
const { run, pending, error } = useAction();
const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
// Reference-check form state (COMPETENCY_AND_REFERENCES).
const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
// Bank/EPF form state (DOC_VERIFICATION).
const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
// Salary form state (SALARY_AGREEMENT).
const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
function fdFrom(obj: Record<string, string>, extra?: Record<string, string>) {
const fd = new FormData();
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
return fd;
}
const footer = (
<>
<Err msg={error} />
{canReject && (
<div className="flex justify-end pt-1">
<RejectButton id={p.id} />
</div>
)}
</>
);
switch (p.stage) {
case "SHORTLISTED":
return (
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
Start competency &amp; references
</button>
)}
{footer}
</Card>
);
case "COMPETENCY_AND_REFERENCES":
return (
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
{p.perms.recordReference && (
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
Save reference
</button>
</div>
)}
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
Verify &amp; continue to documents
</button>
)}
{footer}
</Card>
);
case "DOC_VERIFICATION":
return (
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
Verify &amp; continue to salary
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
)}
{footer}
</Card>
);
case "SALARY_AGREEMENT":
if (p.salaryPending) {
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
<p className="text-sm text-neutral-600">
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
</p>
{p.perms.approveSalary ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
)}
{footer}
</Card>
);
}
return (
<Card title="Salary" sub="Office-only; the Manager approves.">
{p.perms.manage ? (
<>
<div className="grid grid-cols-2 gap-2">
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
<option value="MONTHLY">Per month</option>
<option value="DAILY">Per day</option>
</select>
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
</div>
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
Agree salary &amp; send for approval
</button>
</>
) : (
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
)}
{footer}
</Card>
);
case "PROPOSED":
return (
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
{p.perms.manage && (
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
Candidate accepted schedule interview
</button>
)}
{footer}
</Card>
);
case "INTERVIEW":
return (
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
{/* Interview result row */}
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
</div>
)}
{/* Waiver (ex-hand) */}
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver Manager</button>
)}
{p.waiverPending && (
p.perms.approveWaiver ? (
<div className="flex items-center gap-2">
<span className="text-sm text-warning-700">Waiver requested.</span>
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
</div>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
)
)}
{/* Selection row */}
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
p.perms.select ? (
<div className="flex gap-2">
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve select</button>
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
</div>
) : (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} awaiting Manager selection.</p>
)
)}
{footer}
</Card>
);
case "SELECTED":
return (
<Card title="Selected" sub="Ready to onboard.">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
{p.perms.onboard && <OnboardButton id={p.id} />}
</Card>
);
case "REJECTED":
return (
<Card title="Rejected">
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
</Card>
);
default:
return (
<Card title="Onboarded">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
</p>
</Card>
);
}
}
function OnboardButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [joiningDate, setJoiningDate] = useState("");
const [contract, setContract] = useState<File | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("applicationId", id);
fd.set("joiningDate", joiningDate);
if (contract) fd.set("contract", contract);
const res = await onboardCandidate(fd);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button className={PRIMARY} onClick={() => setOpen(true)}>Onboard to crew</button>
<AdminDialog title="Onboard to crew" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
<input type="date" className={INPUT} value={joiningDate} onChange={(e) => setJoiningDate(e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contract letter (optional)</label>
<input type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" onChange={(e) => setContract(e.target.files?.[0] ?? null)} />
</div>
<div className="rounded-md bg-neutral-50 border border-neutral-200 p-3">
<p className="text-xs font-medium text-neutral-600 mb-1">Starts automatically on confirm</p>
<p className="text-xs text-neutral-500">Employee number · salary &amp; victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)</p>
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !joiningDate} className={PRIMARY}>{pending ? "Onboarding…" : "Confirm onboarding"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await onReturn(reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<>
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -1,47 +0,0 @@
import type { ApplicationStage } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
// The 7 board columns in order (mirrors lib/application-pipeline BOARD_STAGES;
// kept here as a client-safe constant for the stepper/board UI).
export const STAGE_ORDER: ApplicationStage[] = [
"SHORTLISTED",
"COMPETENCY_AND_REFERENCES",
"DOC_VERIFICATION",
"SALARY_AGREEMENT",
"PROPOSED",
"INTERVIEW",
"SELECTED",
];
export const STAGE_LABEL: Record<ApplicationStage, string> = {
SHORTLISTED: "Shortlisted",
COMPETENCY_AND_REFERENCES: "Competency & references",
DOC_VERIFICATION: "Documents",
SALARY_AGREEMENT: "Salary",
PROPOSED: "Proposed",
INTERVIEW: "Interview",
SELECTED: "Selected",
REJECTED: "Rejected",
ONBOARDED: "Onboarded",
};
export const STAGE_VARIANT: Record<ApplicationStage, Variant> = {
SHORTLISTED: "outline",
COMPETENCY_AND_REFERENCES: "default",
DOC_VERIFICATION: "default",
SALARY_AGREEMENT: "warning",
PROPOSED: "default",
INTERVIEW: "warning",
SELECTED: "success",
REJECTED: "danger",
ONBOARDED: "success",
};
// Index of a stage within the 7-step flow (1 for REJECTED; 7 for ONBOARDED).
export function stageIndex(stage: ApplicationStage): number {
if (stage === "REJECTED") return -1;
if (stage === "ONBOARDED") return STAGE_ORDER.length;
return STAGE_ORDER.indexOf(stage);
}

View file

@ -1,146 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canPerformAction, canReject } from "@/lib/appraisal-state-machine";
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
function loadAppraisal(id: string) {
return db.appraisal.findUnique({
where: { id },
include: { assignment: { include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } } } },
});
}
function revalidate(crewMemberId: string) {
revalidatePath(`/crewing/crew/${crewMemberId}`);
revalidatePath("/crewing/verification");
revalidatePath("/approvals");
}
// ── Raise an appraisal (PM / site staff) ───────────────────────────────────────
const raiseSchema = z.object({
assignmentId: z.string().min(1, "Crew assignment is required"),
period: z.string().trim().min(1, "Period is required"),
comments: z.string().optional(),
competence: z.coerce.number().int().min(1).max(5).optional(),
conduct: z.coerce.number().int().min(1).max(5).optional(),
safety: z.coerce.number().int().min(1).max(5).optional(),
});
export async function raiseAppraisal(formData: FormData): Promise<ActionResult> {
const g = await guard("raise_appraisal");
if ("error" in g) return g;
const parsed = raiseSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const assignment = await db.crewAssignment.findUnique({
where: { id: d.assignmentId },
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
});
if (!assignment) return { error: "Crew assignment not found" };
const appraisal = await db.appraisal.create({
data: {
assignmentId: d.assignmentId,
period: d.period,
comments: d.comments ?? null,
ratings: { competence: d.competence ?? null, conduct: d.conduct ?? null, safety: d.safety ?? null },
status: "SUBMITTED",
addedById: g.userId,
},
});
await db.crewAction.create({ data: { actionType: "APPRAISAL_SUBMITTED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
const mpos = await getMpoRecipients();
await notifyCrew({
event: "APPRAISAL_FOR_VERIFICATION",
recipients: mpos,
subject: `Appraisal to verify — ${assignment.crewMember.name}`,
body: `An appraisal for ${assignment.crewMember.name} (${assignment.rank.name}, ${d.period}) awaits MPO verification.`,
link: "/crewing/verification",
});
revalidate(assignment.crewMember.id);
return { ok: true, id: appraisal.id };
}
// ── Verify (MPO) ───────────────────────────────────────────────────────────────
export async function verifyAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_appraisal");
if ("error" in g) return g;
const a = await loadAppraisal(id);
if (!a) return { error: "Appraisal not found" };
if (!approve) {
if (!canReject(a.status)) return { error: `Cannot reject from ${a.status}` };
if (!remarks?.trim()) return { error: "A reason is required to reject" };
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
revalidate(a.assignment.crewMember.id);
return { ok: true };
}
if (!canPerformAction(a.status, "verify", g.role)) return { error: `Cannot verify from ${a.status}` };
await db.appraisal.update({ where: { id }, data: { status: "MPO_VERIFIED", verifiedById: g.userId } });
await db.crewAction.create({ data: { actionType: "APPRAISAL_VERIFIED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "APPRAISAL_FOR_APPROVAL",
recipients: managers,
subject: `Appraisal for approval — ${a.assignment.crewMember.name}`,
body: `${a.assignment.crewMember.name}'s appraisal (${a.assignment.rank.name}, ${a.period}) has been MPO-verified and awaits your approval.`,
link: "/approvals",
});
revalidate(a.assignment.crewMember.id);
return { ok: true };
}
// ── Approve (Manager) ──────────────────────────────────────────────────────────
export async function approveAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("approve_appraisal");
if ("error" in g) return g;
const a = await loadAppraisal(id);
if (!a) return { error: "Appraisal not found" };
if (!approve) {
if (!canReject(a.status)) return { error: `Cannot return from ${a.status}` };
if (!remarks?.trim()) return { error: "A reason is required to return" };
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
revalidate(a.assignment.crewMember.id);
return { ok: true };
}
if (!canPerformAction(a.status, "approve", g.role)) return { error: `Cannot approve from ${a.status}` };
await db.appraisal.update({ where: { id }, data: { status: "MANAGER_APPROVED", approvedById: g.userId } });
await db.crewAction.create({ data: { actionType: "APPRAISAL_APPROVED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
revalidate(a.assignment.crewMember.id);
return { ok: true };
}

View file

@ -1,46 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { AttendanceStatus } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const markSchema = z.object({ date: z.string().min(1), status: z.nativeEnum(AttendanceStatus) });
// Bulk-save the dirty cells from the month calendar (Site staff). One upsert per
// (assignment, date); a single ATTENDANCE_RECORDED audit row per save.
export async function saveAttendance(assignmentId: string, marks: { date: string; status: AttendanceStatus }[]): Promise<ActionResult> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "record_attendance")) return { error: "Unauthorized" };
if (!assignmentId) return { error: "Crew member is required" };
const parsed = z.array(markSchema).max(40).safeParse(marks);
if (!parsed.success) return { error: "Invalid attendance data" };
if (parsed.data.length === 0) return { ok: true };
const assignment = await db.crewAssignment.findUnique({ where: { id: assignmentId }, select: { crewMemberId: true } });
if (!assignment) return { error: "Crew assignment not found" };
await db.$transaction(
parsed.data.map((m) =>
db.attendance.upsert({
where: { assignmentId_date: { assignmentId, date: new Date(m.date) } },
update: { status: m.status, recordedById: session.user.id },
create: { assignmentId, date: new Date(m.date), status: m.status, recordedById: session.user.id },
})
)
);
await db.crewAction.create({
data: { actionType: "ATTENDANCE_RECORDED", actorId: session.user.id, crewMemberId: assignment.crewMemberId, metadata: { count: parsed.data.length } },
});
revalidatePath("/crewing/attendance");
return { ok: true };
}

View file

@ -1,169 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type { AttendanceStatus } from "@prisma/client";
import { cn } from "@/lib/utils";
import { saveAttendance } from "./actions";
type Assignment = { id: string; crewName: string; rank: string; location: string; marks: Record<string, AttendanceStatus> };
const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
// Tap cycle (§8.10): Unmarked → Present → Absent → Leave → Half day → Unmarked.
const CYCLE: (AttendanceStatus | null)[] = [null, "PRESENT", "ABSENT", "ON_LEAVE", "HALF_DAY"];
const next = (s: AttendanceStatus | null) => CYCLE[(CYCLE.indexOf(s ?? null) + 1) % CYCLE.length];
const CELL: Record<AttendanceStatus, string> = {
PRESENT: "bg-success-100 text-success-700 border-success-200",
ABSENT: "bg-danger-100 text-danger-700 border-danger-200",
ON_LEAVE: "bg-warning-100 text-warning-700 border-warning-200",
HALF_DAY: "bg-primary-100 text-primary-700 border-primary-200",
SIGN_OFF: "bg-neutral-200 text-neutral-600 border-neutral-300",
};
const ABBR: Record<AttendanceStatus, string> = { PRESENT: "P", ABSENT: "A", ON_LEAVE: "L", HALF_DAY: "½", SIGN_OFF: "S" };
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const iso = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
export function AttendanceCalendar({ assignments, canEdit }: { assignments: Assignment[]; canEdit: boolean }) {
const router = useRouter();
const today = new Date();
const [selectedId, setSelectedId] = useState(assignments[0]?.id ?? "");
const [y, setY] = useState(today.getFullYear());
const [m, setM] = useState(today.getMonth());
const [edits, setEdits] = useState<Record<string, Record<string, AttendanceStatus | null>>>({});
const [pending, setPending] = useState(false);
const selected = assignments.find((a) => a.id === selectedId) ?? null;
const myEdits = edits[selectedId] ?? {};
const statusOf = (date: string): AttendanceStatus | null => {
if (date in myEdits) return myEdits[date];
return selected?.marks[date] ?? null;
};
const daysInMonth = new Date(y, m + 1, 0).getDate();
const firstWeekday = new Date(y, m, 1).getDay();
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
const summary = useMemo(() => {
let present = 0, absent = 0, leave = 0;
for (const d of days) {
const s = (date => (date in myEdits ? myEdits[date] : selected?.marks[date] ?? null))(iso(y, m, d));
if (s === "PRESENT") present++; else if (s === "ABSENT") absent++; else if (s === "ON_LEAVE") leave++;
}
return { present, absent, leave };
}, [days, myEdits, selected, y, m]);
const unmarkedToDate = useMemo(() => {
const isCurrentOrPast = y < today.getFullYear() || (y === today.getFullYear() && m <= today.getMonth());
if (!isCurrentOrPast) return 0;
const lastDay = (y === today.getFullYear() && m === today.getMonth()) ? today.getDate() : daysInMonth;
let n = 0;
for (let d = 1; d <= lastDay; d++) if (statusOf(iso(y, m, d)) === null) n++;
return n;
}, [y, m, daysInMonth, myEdits, selected]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = Object.keys(myEdits).length > 0;
function cycleDay(date: string) {
if (!canEdit) return;
setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] ?? {}), [date]: next(statusOf(date)) } }));
}
function shiftMonth(delta: number) {
const nm = m + delta;
if (nm < 0) { setM(11); setY(y - 1); } else if (nm > 11) { setM(0); setY(y + 1); } else setM(nm);
}
async function save() {
setPending(true);
// Null edits (cleared cells) are skipped — clearing a saved mark isn't supported here.
const marks = Object.entries(myEdits).filter(([, s]) => s !== null).map(([date, status]) => ({ date, status: status as AttendanceStatus }));
const res = await saveAttendance(selectedId, marks);
setPending(false);
if ("ok" in res) { setEdits((e) => ({ ...e, [selectedId]: {} })); router.refresh(); }
}
if (assignments.length === 0) {
return (
<div>
<h1 className="text-2xl font-semibold text-neutral-900 mb-2">Attendance</h1>
<p className="text-neutral-400">No active crew to mark attendance for.</p>
</div>
);
}
return (
<div className="max-w-3xl">
<div className="mb-5 flex items-center justify-between">
<h1 className="text-2xl font-semibold text-neutral-900">Attendance</h1>
{canEdit && (
<button onClick={save} disabled={!dirty || pending} className={cn("rounded-lg px-4 py-2 text-sm font-semibold text-white", dirty ? "bg-primary-600 hover:bg-primary-700" : "bg-neutral-300", "disabled:opacity-60")}>
{pending ? "Saving…" : "Save"}
</button>
)}
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<select className={INPUT} value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
</select>
<div className="flex items-center gap-2">
<button onClick={() => shiftMonth(-1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronLeft className="h-4 w-4" /></button>
<span className="text-sm font-medium text-neutral-800 w-36 text-center">{MONTHS[m]} {y}</span>
<button onClick={() => shiftMonth(1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronRight className="h-4 w-4" /></button>
</div>
</div>
{unmarkedToDate > 0 && (
<div className="mb-4 rounded-lg border border-warning-200 bg-warning-50 px-4 py-2 text-sm text-warning-800">{unmarkedToDate} day{unmarkedToDate === 1 ? "" : "s"} still need marking.</div>
)}
<div className="mb-4 grid grid-cols-3 gap-3">
{([["Present", summary.present], ["Absent", summary.absent], ["On leave", summary.leave]] as const).map(([k, v]) => (
<div key={k} className="rounded-lg border border-neutral-200 bg-white p-3 text-center">
<p className="text-2xl font-semibold text-neutral-900">{v}</p>
<p className="text-xs text-neutral-500">{k}</p>
</div>
))}
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<div className="grid grid-cols-7 gap-1 mb-1 text-center text-xs font-medium text-neutral-400">
{["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map((d) => <div key={d}>{d}</div>)}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: firstWeekday }).map((_, i) => <div key={`pad${i}`} />)}
{days.map((d) => {
const date = iso(y, m, d);
const s = statusOf(date);
return (
<button
key={d}
onClick={() => cycleDay(date)}
disabled={!canEdit}
className={cn(
"aspect-square rounded-md border text-sm flex flex-col items-center justify-center",
s ? CELL[s] : "border-dashed border-neutral-200 text-neutral-400",
canEdit ? "hover:ring-2 hover:ring-primary-200 cursor-pointer" : "cursor-default"
)}
>
<span className="text-[11px] leading-none">{d}</span>
{s && <span className="text-xs font-semibold leading-none mt-0.5">{ABBR[s]}</span>}
</button>
);
})}
</div>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-neutral-500">
<span><span className="inline-block w-3 h-3 rounded bg-success-100 border border-success-200 align-middle" /> Present</span>
<span><span className="inline-block w-3 h-3 rounded bg-danger-100 border border-danger-200 align-middle" /> Absent</span>
<span><span className="inline-block w-3 h-3 rounded bg-warning-100 border border-warning-200 align-middle" /> Leave</span>
<span><span className="inline-block w-3 h-3 rounded bg-primary-100 border border-primary-200 align-middle" /> Half day</span>
</div>
</div>
{!canEdit && <p className="mt-3 text-xs text-neutral-400">View only attendance is marked by site staff.</p>}
</div>
);
}

View file

@ -1,46 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { AttendanceCalendar } from "./attendance-calendar";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Attendance" };
export default async function AttendancePage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_attendance")) redirect("/dashboard"); // MPO has no attendance (R5)
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 4);
const assignments = await db.crewAssignment.findMany({
where: { status: { not: "SIGNED_OFF" } },
orderBy: { crewMember: { name: "asc" } },
include: {
crewMember: { select: { name: true } },
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
attendance: { where: { date: { gte: cutoff } }, select: { date: true, status: true } },
},
});
return (
<AttendanceCalendar
assignments={assignments.map((a) => ({
id: a.id,
crewName: a.crewMember.name,
rank: a.rank.name,
location: a.vessel?.name ?? a.site?.name ?? "—",
marks: Object.fromEntries(a.attendance.map((m) => [m.date.toISOString().slice(0, 10), m.status])),
}))}
canEdit={hasPermission(role, "record_attendance")}
/>
);
}

View file

@ -1,137 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Candidate" };
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: {
appliedRank: { select: { name: true } },
currentRank: { select: { name: true } },
// B3 AC3 — pull the returning hand's history so the callout shows real records.
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: true } },
},
});
if (!c) notFound();
const profile: [string, string][] = [
["Rank applied", c.appliedRank?.name ?? "—"],
["Last rank held", c.currentRank?.name ?? "—"],
["Experience", experienceLabel(c.experienceMonths)],
["Vessel type", c.vesselTypeExperience ?? "—"],
["Source", SOURCE_LABEL[c.source]],
["Email", c.email ?? "—"],
["Phone", c.phone ?? "—"],
];
return (
<div className="max-w-4xl">
<Link href="/crewing/candidates" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Candidates
</Link>
<div className="mb-6 flex items-start justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.source === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
</div>
{c.source === "EX_HAND" && (
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
<span>No prior records are on file yet.</span>
) : (
<span>Prior records on file from earlier assignments:</span>
)}
{c.experienceRecords.length > 0 && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
<ul className="space-y-1">
{c.experienceRecords.map((e) => (
<li key={e.id} className="text-sm text-purple-900">
{e.rank?.name ?? "—"}
{e.vesselType ? ` · ${e.vesselType}` : ""}
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `${e.toDate.getFullYear()}` : ""})` : ""}
</li>
))}
</ul>
</div>
)}
{c.documents.length > 0 && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
<div className="flex flex-wrap gap-1.5">
{c.documents.map((doc) => (
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
{doc.docType}
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
</span>
))}
</div>
</div>
)}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Profile */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
</div>
<dl className="divide-y divide-neutral-100">
{profile.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{c.notes && (
<div className="px-4 py-3 border-t border-neutral-100">
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
<p className="text-sm text-neutral-700">{c.notes}</p>
</div>
)}
</div>
{/* Recruitment pipeline — Phase 3b */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Recruitment</h2>
</div>
<p className="px-4 py-12 text-center text-sm text-neutral-400">
The 7-stage recruitment pipeline (shortlist competency &amp; references docs
salary proposed interview selected) arrives in the next phase. Applications
against requisitions will appear here.
</p>
</div>
</div>
</div>
);
}

View file

@ -1,182 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { CandidateSource } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const LIST_PATH = "/crewing/candidates";
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
const candidateSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
source: z.nativeEnum(CandidateSource).default("CAREERS"),
appliedRankId: z.string().optional(),
currentRankId: z.string().optional(),
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
vesselTypeExperience: z.string().optional(),
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
phone: z.string().optional(),
notes: z.string().optional(),
});
function parse(formData: FormData) {
return candidateSchema.safeParse({
name: formData.get("name"),
source: (formData.get("source") as string) || undefined,
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
currentRankId: (formData.get("currentRankId") as string) || undefined,
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined,
email: (formData.get("email") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
}
// An EX_HAND source means a returning crew member; everyone else is NEW. The
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
function derive(source: CandidateSource) {
const isExHand = source === "EX_HAND";
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
}
// Store an optional CV upload and return its storage key (null if none).
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
const file = formData.get("cv");
if (!(file instanceof File) || file.size === 0) return null;
const key = buildStorageKey("cv", crewMemberId, file.name);
const buffer = Buffer.from(await file.arrayBuffer());
await uploadBuffer(key, buffer, file.type || "application/octet-stream");
return key;
}
export async function addCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
// candidate (not already tagged EX_HAND) is matched to their existing EX_HAND
// pool record by a stable key — email when given, else an exact name match —
// and the SAME row is reused (so their tour history, documents and bank stay on
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
// name-only match can in theory collide; email is preferred when available.)
if (d.source !== "EX_HAND") {
const match = await db.crewMember.findFirst({
where: {
status: "EX_HAND",
...(d.email
? { email: { equals: d.email, mode: "insensitive" } }
: { name: { equals: d.name, mode: "insensitive" } }),
},
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
});
if (match) {
const updated = await db.crewMember.update({
where: { id: match.id },
data: {
// Keep EX_HAND type/status; refresh the application's details, never
// discarding prior history (take the larger recorded experience).
appliedRankId: d.appliedRankId || match.appliedRankId,
currentRankId: d.currentRankId || match.currentRankId,
email: d.email || match.email,
phone: d.phone || match.phone,
notes: d.notes || match.notes,
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
},
});
const cvKey = await storeCv(formData, updated.id);
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: updated.id };
}
}
const candidate = await db.crewMember.create({
data: {
name: d.name,
source: d.source,
type,
status,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
},
});
const cvKey = await storeCv(formData, candidate.id);
if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: candidate.id };
}
export async function updateCandidate(formData: FormData): Promise<ActionResult> {
const g = await guard("manage_candidates");
if ("error" in g) return g;
const id = formData.get("id") as string;
if (!id) return { error: "Candidate ID is required" };
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
if (!existing) return { error: "Candidate not found" };
const cvKey = await storeCv(formData, id);
await db.crewMember.update({
where: { id },
data: {
name: d.name,
source: d.source,
// Don't downgrade an onboarded employee back to a candidate via an edit.
type,
status: existing.status === "EMPLOYEE" ? existing.status : status,
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
vesselTypeExperience: d.vesselTypeExperience || null,
email: d.email || null,
phone: d.phone || null,
notes: d.notes || null,
...(cvKey ? { cvKey } : {}),
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true, id };
}

View file

@ -1,256 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { CandidateSource } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { addCandidate, updateCandidate } from "./actions";
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
type RankOpt = { id: string; code: string; name: string };
export type EditableCandidate = {
id: string;
name: string;
source: CandidateSource;
appliedRankId: string | null;
currentRankId: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
};
function CandidateFields({
ranks,
state,
set,
fileRef,
}: {
ranks: RankOpt[];
state: FieldState;
set: <K extends keyof FieldState>(k: K, v: FieldState[K]) => void;
fileRef: React.RefObject<HTMLInputElement | null>;
}) {
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
<input className={INPUT} value={state.name} onChange={(e) => set("name", e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
{SOURCE_OPTIONS.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank applied for</label>
<select className={INPUT} value={state.appliedRankId} onChange={(e) => set("appliedRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Experience (months)</label>
<input type="number" min={0} className={INPUT} value={state.experienceMonths} onChange={(e) => set("experienceMonths", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel type</label>
<input className={INPUT} value={state.vesselTypeExperience} onChange={(e) => set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
<input type="email" className={INPUT} value={state.email} onChange={(e) => set("email", e.target.value)} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Phone</label>
<input className={INPUT} value={state.phone} onChange={(e) => set("phone", e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">CV (PDF/DOC, optional)</label>
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={state.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Optional" />
</div>
</div>
);
}
type FieldState = {
name: string;
source: CandidateSource;
appliedRankId: string;
currentRankId: string;
experienceMonths: string;
vesselTypeExperience: string;
email: string;
phone: string;
notes: string;
};
function emptyState(): FieldState {
return {
name: "", source: "CAREERS", appliedRankId: "", currentRankId: "",
experienceMonths: "0", vesselTypeExperience: "", email: "", phone: "", notes: "",
};
}
function stateFrom(c: EditableCandidate): FieldState {
return {
name: c.name,
source: c.source,
appliedRankId: c.appliedRankId ?? "",
currentRankId: c.currentRankId ?? "",
experienceMonths: String(c.experienceMonths),
vesselTypeExperience: c.vesselTypeExperience ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
notes: c.notes ?? "",
};
}
function buildFormData(state: FieldState, file: File | undefined, id?: string): FormData {
const fd = new FormData();
if (id) fd.set("id", id);
fd.set("name", state.name);
fd.set("source", state.source);
if (state.appliedRankId) fd.set("appliedRankId", state.appliedRankId);
if (state.currentRankId) fd.set("currentRankId", state.currentRankId);
fd.set("experienceMonths", state.experienceMonths || "0");
if (state.vesselTypeExperience) fd.set("vesselTypeExperience", state.vesselTypeExperience);
if (state.email) fd.set("email", state.email);
if (state.phone) fd.set("phone", state.phone);
if (state.notes) fd.set("notes", state.notes);
if (file && file.size > 0) fd.set("cv", file);
return fd;
}
export function AddCandidateButton({ ranks }: { ranks: RankOpt[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(emptyState);
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await addCandidate(buildFormData(state, fileRef.current?.files?.[0]));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
setState(emptyState());
if (fileRef.current) fileRef.current.value = "";
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Add candidate
</button>
<AdminDialog title="Add candidate" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Adding…" : "Add candidate"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditCandidateButton({
candidate,
ranks,
open,
onOpenChange,
}: {
candidate: EditableCandidate;
ranks: RankOpt[];
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [state, setState] = useState<FieldState>(() => stateFrom(candidate));
const fileRef = useRef<HTMLInputElement | null>(null);
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await updateCandidate(buildFormData(state, fileRef.current?.files?.[0], candidate.id));
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
onOpenChange(false);
router.refresh();
}
}
return (
<AdminDialog title="Edit candidate" open={open} onClose={() => onOpenChange(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => onOpenChange(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save changes"}
</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -1,38 +0,0 @@
import type { CandidateSource, CrewStatus } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
export const SOURCE_LABEL: Record<CandidateSource, string> = {
CAREERS: "Careers",
EX_HAND: "Ex-hand",
WALK_IN: "Walk-in",
REFERRAL: "Referral",
OTHER: "Other",
};
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
export const STATUS_LABEL: Record<CrewStatus, string> = {
PROSPECT: "Prospect",
CANDIDATE: "Candidate",
EMPLOYEE: "Employee",
EX_HAND: "Ex-hand",
BLACKLISTED: "Blacklisted",
};
export const STATUS_VARIANT: Record<CrewStatus, Variant> = {
PROSPECT: "outline",
CANDIDATE: "default",
EMPLOYEE: "success",
EX_HAND: "secondary",
BLACKLISTED: "danger",
};
// Compact experience label, e.g. "3y 6m", "8m", "—".
export function experienceLabel(months: number): string {
if (!months) return "—";
const y = Math.floor(months / 12);
const m = months % 12;
return [y ? `${y}y` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m";
}

View file

@ -1,169 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { CandidateSource, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
import { SOURCE_LABEL, SOURCE_OPTIONS, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "./candidate-ui";
type CandidateRow = {
id: string;
name: string;
source: CandidateSource;
status: CrewStatus;
appliedRankId: string | null;
appliedRank: string | null;
currentRankId: string | null;
currentRank: string | null;
experienceMonths: number;
vesselTypeExperience: string | null;
email: string | null;
phone: string | null;
notes: string | null;
hasCv: boolean;
};
type RankOpt = { id: string; code: string; name: string };
const INPUT =
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 text-primary-700 px-2.5 py-1 text-xs font-medium">
{label}
<button onClick={onClear} className="text-primary-400 hover:text-primary-700" aria-label="Remove filter"></button>
</span>
);
}
function toEditable(c: CandidateRow): EditableCandidate {
return {
id: c.id, name: c.name, source: c.source,
appliedRankId: c.appliedRankId, currentRankId: c.currentRankId,
experienceMonths: c.experienceMonths, vesselTypeExperience: c.vesselTypeExperience,
email: c.email, phone: c.phone, notes: c.notes,
};
}
function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
const [editOpen, setEditOpen] = useState(false);
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
</td>
<td className="px-4 py-3">
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
{SOURCE_LABEL[c.source]}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge></td>
<td className="px-4 py-3 text-right">
<div onClick={(e) => e.stopPropagation()}>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
</RowActionsMenu>
</div>
<EditCandidateButton candidate={toEditable(c)} ranks={ranks} open={editOpen} onOpenChange={setEditOpen} />
</td>
</tr>
);
}
export function CandidatesManager({ candidates, ranks }: { candidates: CandidateRow[]; ranks: RankOpt[] }) {
const [search, setSearch] = useState("");
const [source, setSource] = useState<"ALL" | CandidateSource>("ALL");
const [appliedRankId, setAppliedRankId] = useState("ALL");
const [minExp, setMinExp] = useState("");
const minExpMonths = minExp ? Math.max(0, parseInt(minExp, 10) || 0) : 0;
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return candidates.filter((c) => {
if (source !== "ALL" && c.source !== source) return false;
if (appliedRankId !== "ALL" && c.appliedRankId !== appliedRankId) return false;
if (minExpMonths && c.experienceMonths < minExpMonths) return false;
if (q && !`${c.name} ${c.appliedRank ?? ""} ${c.currentRank ?? ""}`.toLowerCase().includes(q)) return false;
return true;
});
}, [candidates, search, source, appliedRankId, minExpMonths]);
const rankName = (id: string) => ranks.find((r) => r.id === id)?.name ?? id;
const hasFilters = Boolean(search) || source !== "ALL" || appliedRankId !== "ALL" || Boolean(minExp);
const clearAll = () => { setSearch(""); setSource("ALL"); setAppliedRankId("ALL"); setMinExp(""); };
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Candidates</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals
</p>
</div>
<AddCandidateButton ranks={ranks} />
</div>
{/* Filters */}
<div className="mb-3 flex flex-wrap items-center gap-3">
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className={INPUT} value={source} onChange={(e) => setSource(e.target.value as typeof source)}>
<option value="ALL">All sources</option>
{SOURCE_OPTIONS.map((s) => <option key={s} value={s}>{SOURCE_LABEL[s]}</option>)}
</select>
<select className={INPUT} value={appliedRankId} onChange={(e) => setAppliedRankId(e.target.value)}>
<option value="ALL">Any rank applied</option>
{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}
</select>
<input type="number" min={0} className={`${INPUT} w-40`} placeholder="Min exp (months)" value={minExp} onChange={(e) => setMinExp(e.target.value)} />
</div>
{/* Active filter chips + match count */}
{hasFilters && (
<div className="mb-4 flex flex-wrap items-center gap-2">
{search && <Chip label={`${search}`} onClear={() => setSearch("")} />}
{source !== "ALL" && <Chip label={`Source: ${SOURCE_LABEL[source]}`} onClear={() => setSource("ALL")} />}
{appliedRankId !== "ALL" && <Chip label={`Rank: ${rankName(appliedRankId)}`} onClear={() => setAppliedRankId("ALL")} />}
{minExp && <Chip label={`${minExp} mo`} onClear={() => setMinExp("")} />}
<span className="text-xs text-neutral-500">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
<button onClick={clearAll} className="text-xs font-medium text-primary-600 hover:underline">Clear all</button>
</div>
)}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Rank held</th>
<th className="px-4 py-3">Rank applied</th>
<th className="px-4 py-3">Experience</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3 w-12"></th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
{candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."}
</td>
</tr>
) : (
filtered.map((c) => <CandidateRowView key={c.id} c={c} ranks={ranks} />)
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,54 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { CandidatesManager } from "./candidates-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Candidates" };
export default async function CandidatesPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
const [candidates, ranks] = await Promise.all([
db.crewMember.findMany({
// Active employees live in the Crew directory (Phase 4); the pool is
// everyone still a candidate / ex-hand (spec §8.6 R9).
where: { status: { not: "EMPLOYEE" } },
orderBy: { createdAt: "desc" },
include: {
appliedRank: { select: { name: true } },
currentRank: { select: { name: true } },
},
}),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
]);
const rows = candidates.map((c) => ({
id: c.id,
name: c.name,
source: c.source,
status: c.status,
appliedRankId: c.appliedRankId,
appliedRank: c.appliedRank?.name ?? null,
currentRankId: c.currentRankId,
currentRank: c.currentRank?.name ?? null,
experienceMonths: c.experienceMonths,
vesselTypeExperience: c.vesselTypeExperience,
email: c.email,
phone: c.phone,
notes: c.notes,
hasCv: Boolean(c.cvKey),
}));
// B3 AC2 — ex-hands (proven crew) surface above new candidates by default.
// Stable sort preserves the createdAt-desc order within each group.
rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND"));
return <CandidatesManager candidates={rows} ranks={ranks} />;
}

View file

@ -1,423 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis, AppraisalStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { cn } from "@/lib/utils";
import {
uploadDocument, deleteDocument, saveBankEpf,
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew,
} from "../actions";
import { raiseAppraisal } from "../../appraisals/actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
const LINKBTN = "text-xs font-medium text-danger-600 hover:underline";
const DOC_TYPES: SeafarerDocType[] = ["STCW","AADHAAR","PAN","PASSPORT","CDC","COC","PHOTOGRAPH","DRIVING_LICENSE","MEDICAL_FITNESS","CONTRACT_LETTER"];
const PPE_ITEMS: PpeItem[] = ["BOILER_SUIT","SAFETY_SHOES","HELMET","VEST","GLOVES","MASK","GOGGLES","TIFFIN","TORCH","WALKIE_TALKIE"];
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
type Doc = { id: string; docType: SeafarerDocType; number: string | null; issueDate: string | null; expiryDate: string | null; verificationStatus: GateResult; hasFile: boolean };
type Nok = { id: string; name: string; relationship: string | null; phone: string | null; address: string | null; isEmergency: boolean };
type Ppe = { id: string; item: PpeItem; size: string | null; quantity: number; issuedDate: string; returnedDate: string | null };
type Exp = { id: string; vesselType: string | null; rank: string | null; fromDate: string | null; toDate: string | null; durationMonths: number | null; source: string };
type Props = {
crew: { id: string; name: string; employeeId: string; rank: string; location: string; status: AssignmentStatus | null };
documents: Doc[];
bank: { accountName: string | null; accountNumber: string; ifsc: string | null; bankName: string | null };
epf: { uan: string | null; aadhaar: string; pfNumber: string | null };
nextOfKin: Nok[];
ppe: Ppe[];
experience: Exp[];
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
ranks: { id: string; name: string }[];
perms: { editRecords: boolean; issuePpe: boolean };
signOff: { assignmentId: string | null; canSignOff: boolean };
appraisals: Appr[];
appraisalCtx: { assignmentId: string | null; canRaise: boolean };
};
type Appr = { id: string; period: string; status: AppraisalStatus; comments: string | null; ratings: { competence: number | null; conduct: number | null; safety: number | null } | null };
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status", "Appraisals"] as const;
type Tab = (typeof TABS)[number];
const APPRAISAL_VARIANT: Record<AppraisalStatus, "outline" | "warning" | "default" | "success" | "danger"> = {
DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger",
};
export function CrewProfile(p: Props) {
const [tab, setTab] = useState<Tab>("Documents");
const router = useRouter();
const refresh = () => router.refresh();
return (
<div className="max-w-4xl">
<Link href="/crewing/crew" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Crew
</Link>
<div className="mb-1 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
</div>
{p.signOff.canSignOff && p.signOff.assignmentId && <SignOffButton assignmentId={p.signOff.assignmentId} crewName={p.crew.name} />}
</div>
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
<div className="mb-5 flex flex-wrap gap-1 border-b border-neutral-200">
{TABS.map((t) => (
<button key={t} onClick={() => setTab(t)} className={cn("px-3 py-2 text-sm font-medium border-b-2 -mb-px", tab === t ? "border-primary-600 text-primary-700" : "border-transparent text-neutral-500 hover:text-neutral-800")}>
{t}
</button>
))}
</div>
{tab === "Documents" && <Documents crewId={p.crew.id} docs={p.documents} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Bank & EPF" && <BankEpf crewId={p.crew.id} bank={p.bank} epf={p.epf} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Next of kin" && <NextOfKinTab crewId={p.crew.id} rows={p.nextOfKin} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
{tab === "Appraisals" && <Appraisals rows={p.appraisals} ctx={p.appraisalCtx} onDone={refresh} />}
</div>
);
}
function Appraisals({ rows, ctx, onDone }: { rows: Appr[]; ctx: { assignmentId: string | null; canRaise: boolean }; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" });
function submit(e: React.FormEvent) {
e.preventDefault();
if (!ctx.assignmentId) return;
const fd = new FormData();
fd.set("assignmentId", ctx.assignmentId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => raiseAppraisal(fd), () => setF({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No appraisals.</p> : rows.map((a) => (
<div key={a.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
<div>
<p className="text-sm text-neutral-900">{a.period} <Badge variant={APPRAISAL_VARIANT[a.status]}>{a.status.replace(/_/g, " ").toLowerCase()}</Badge></p>
<p className="text-xs text-neutral-500">
{a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"}
{a.comments ? ` · ${a.comments}` : ""}
</p>
</div>
</div>
))}
{ctx.canRaise && ctx.assignmentId && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Period (e.g. 2026 or 2026-Q2)" value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} required />
<input className={INPUT} placeholder="Comments" value={f.comments} onChange={(e) => setF({ ...f, comments: e.target.value })} />
{(["competence", "conduct", "safety"] as const).map((k) => (
<label key={k} className="text-xs text-neutral-500 capitalize">{k}
<select className={INPUT} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })}>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}</select>
</label>
))}
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.period}>{pending ? "Submitting…" : "Submit appraisal"}</button></div>
</form>
)}
{!ctx.canRaise && <p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.</p>}
</Section>
);
}
function Section({ children }: { children: React.ReactNode }) {
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
}
function Err({ msg }: { msg: string }) { return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null; }
function useRun(onDone: () => void) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function run(fn: () => Promise<{ ok: true } | { error: string }>, after?: () => void) {
setPending(true); setError("");
const res = await fn();
setPending(false);
if ("error" in res) setError(res.error); else { after?.(); onDone(); }
}
return { pending, error, run };
}
function docStatus(d: Doc): { label: string; variant: "success" | "warning" | "danger" | "secondary" } {
if (d.expiryDate && new Date(d.expiryDate) < new Date()) return { label: "Expired", variant: "danger" };
if (d.verificationStatus === "VERIFIED") return { label: "Verified", variant: "success" };
if (d.verificationStatus === "REJECTED") return { label: "Rejected", variant: "danger" };
return { label: "Pending", variant: "warning" };
}
function Documents({ crewId, docs, canEdit, onDone }: { crewId: string; docs: Doc[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" });
const [file, setFile] = useState<File | null>(null);
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
if (file) fd.set("file", file);
run(() => uploadDocument(fd), () => { setF({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" }); setFile(null); });
}
return (
<Section>
{docs.length === 0 ? <p className="text-sm text-neutral-400">No documents.</p> : (
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Document</th><th>Number</th><th>Issued</th><th>Expires</th><th>Status</th><th></th></tr></thead>
<tbody>
{docs.map((d) => { const s = docStatus(d); return (
<tr key={d.id} className="border-b border-neutral-50 last:border-0">
<td className="py-2 text-neutral-800">{label(d.docType)}{d.hasFile && <span className="ml-1 text-xs text-neutral-400">file</span>}</td>
<td className="text-neutral-600">{d.number ?? "—"}</td>
<td className="text-neutral-600">{fmtDate(d.issueDate)}</td>
<td className="text-neutral-600">{fmtDate(d.expiryDate)}</td>
<td><Badge variant={s.variant}>{s.label}</Badge></td>
<td className="text-right">{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteDocument(d.id))}>Remove</button>}</td>
</tr>
); })}
</tbody>
</table>
)}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.docType} onChange={(e) => setF({ ...f, docType: e.target.value })}>
{DOC_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
</select>
<input className={INPUT} placeholder="Number" value={f.number} onChange={(e) => setF({ ...f, number: e.target.value })} />
<label className="text-xs text-neutral-500">Issue date<input type="date" className={INPUT} value={f.issueDate} onChange={(e) => setF({ ...f, issueDate: e.target.value })} /></label>
<label className="text-xs text-neutral-500">Expiry date<input type="date" className={INPUT} value={f.expiryDate} onChange={(e) => setF({ ...f, expiryDate: e.target.value })} /></label>
<input type="file" className="col-span-2 text-sm" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add document"}</button></div>
</form>
)}
</Section>
);
}
function Row({ k, v }: { k: string; v: string | null }) {
return <div className="flex justify-between gap-4 py-1.5 border-b border-neutral-50 last:border-0"><span className="text-sm text-neutral-500">{k}</span><span className="text-sm text-neutral-900 font-mono">{v ?? "—"}</span></div>;
}
function BankEpf({ crewId, bank, epf, canEdit, onDone }: { crewId: string; bank: Props["bank"]; epf: Props["epf"]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [edit, setEdit] = useState(false);
const [f, setF] = useState({ accountName: bank.accountName ?? "", accountNumber: "", ifsc: bank.ifsc ?? "", bankName: bank.bankName ?? "", uan: epf.uan ?? "", aadhaarLast4: "", pfNumber: epf.pfNumber ?? "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => saveBankEpf(fd), () => setEdit(false));
}
return (
<Section>
<div className="rounded-md bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">Sensitive account and Aadhaar numbers are masked unless you are Accounts.</div>
<Row k="Account name" v={bank.accountName} />
<Row k="Account number" v={bank.accountNumber} />
<Row k="IFSC" v={bank.ifsc} />
<Row k="Bank" v={bank.bankName} />
<Row k="UAN" v={epf.uan} />
<Row k="Aadhaar" v={epf.aadhaar} />
<Row k="PF number" v={epf.pfNumber} />
{canEdit && !edit && <button className="text-sm text-primary-600 hover:underline" onClick={() => setEdit(true)}>Edit bank & EPF</button>}
{canEdit && edit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Account name" value={f.accountName} onChange={(e) => setF({ ...f, accountName: e.target.value })} />
<input className={INPUT} placeholder="Account number" value={f.accountNumber} onChange={(e) => setF({ ...f, accountNumber: e.target.value })} />
<input className={INPUT} placeholder="IFSC" value={f.ifsc} onChange={(e) => setF({ ...f, ifsc: e.target.value })} />
<input className={INPUT} placeholder="Bank name" value={f.bankName} onChange={(e) => setF({ ...f, bankName: e.target.value })} />
<input className={INPUT} placeholder="UAN" value={f.uan} onChange={(e) => setF({ ...f, uan: e.target.value })} />
<input className={INPUT} placeholder="Aadhaar (last 4)" value={f.aadhaarLast4} onChange={(e) => setF({ ...f, aadhaarLast4: e.target.value })} />
<input className={INPUT} placeholder="PF number" value={f.pfNumber} onChange={(e) => setF({ ...f, pfNumber: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><div className="flex gap-2"><button className={BTN} disabled={pending}>{pending ? "Saving…" : "Save"}</button><button type="button" className="text-sm text-neutral-500" onClick={() => setEdit(false)}>Cancel</button></div></div>
</form>
)}
</Section>
);
}
function NextOfKinTab({ crewId, rows, canEdit, onDone }: { crewId: string; rows: Nok[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ name: "", relationship: "", phone: "", address: "", isEmergency: false });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
fd.set("name", f.name); if (f.relationship) fd.set("relationship", f.relationship); if (f.phone) fd.set("phone", f.phone); if (f.address) fd.set("address", f.address); if (f.isEmergency) fd.set("isEmergency", "true");
run(() => addNextOfKin(fd), () => setF({ name: "", relationship: "", phone: "", address: "", isEmergency: false }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No next of kin recorded.</p> : rows.map((n) => (
<div key={n.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
<div>
<p className="text-sm text-neutral-900">{n.name} {n.isEmergency && <Badge variant="danger">Emergency</Badge>}</p>
<p className="text-xs text-neutral-500">{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}</p>
</div>
{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteNextOfKin(n.id))}>Remove</button>}
</div>
))}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<input className={INPUT} placeholder="Relationship" value={f.relationship} onChange={(e) => setF({ ...f, relationship: e.target.value })} />
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
<input className={INPUT} placeholder="Address" value={f.address} onChange={(e) => setF({ ...f, address: e.target.value })} />
<label className="col-span-2 flex items-center gap-2 text-sm text-neutral-600"><input type="checkbox" checked={f.isEmergency} onChange={(e) => setF({ ...f, isEmergency: e.target.checked })} /> Emergency contact</label>
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.name}>{pending ? "Adding…" : "Add"}</button></div>
</form>
)}
</Section>
);
}
function PpeTab({ crewId, rows, canIssue, onDone }: { crewId: string; rows: Ppe[]; canIssue: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => issuePpe(fd), () => setF({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No PPE issued.</p> : (
<table className="w-full text-sm">
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Item</th><th>Size</th><th>Qty</th><th>Issued</th><th>Status</th><th></th></tr></thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-neutral-50 last:border-0">
<td className="py-2 text-neutral-800">{label(r.item)}</td>
<td className="text-neutral-600">{r.size ?? "—"}</td>
<td className="text-neutral-600">{r.quantity}</td>
<td className="text-neutral-600">{fmtDate(r.issuedDate)}</td>
<td>{r.returnedDate ? <Badge variant="secondary">Returned</Badge> : <Badge variant="success">Issued</Badge>}</td>
<td className="text-right">{canIssue && !r.returnedDate && <button className="text-xs text-primary-600 hover:underline" onClick={() => run(() => returnPpe(r.id))}>Mark returned</button>}</td>
</tr>
))}
</tbody>
</table>
)}
{canIssue && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })}>{PPE_ITEMS.map((i) => <option key={i} value={i}>{label(i)}</option>)}</select>
<input className={INPUT} placeholder="Size" value={f.size} onChange={(e) => setF({ ...f, size: e.target.value })} />
<input className={INPUT} type="number" min={1} placeholder="Qty" value={f.quantity} onChange={(e) => setF({ ...f, quantity: e.target.value })} />
<input className={INPUT} placeholder="Comment" value={f.comment} onChange={(e) => setF({ ...f, comment: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Issuing…" : "Issue PPE"}</button></div>
</form>
)}
</Section>
);
}
function ExperienceTab({ crewId, rows, ranks, canEdit, onDone }: { crewId: string; rows: Exp[]; ranks: { id: string; name: string }[]; canEdit: boolean; onDone: () => void }) {
const { pending, error, run } = useRun(onDone);
const [f, setF] = useState({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" });
function submit(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("crewMemberId", crewId);
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
run(() => addExperience(fd), () => setF({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" }));
}
return (
<Section>
{rows.length === 0 ? <p className="text-sm text-neutral-400">No experience records.</p> : rows.map((r) => (
<div key={r.id} className="border-b border-neutral-50 last:border-0 py-2">
<p className="text-sm text-neutral-900">{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}</p>
<p className="text-xs text-neutral-500">{fmtDate(r.fromDate)} {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}</p>
</div>
))}
{canEdit && (
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })}><option value="">Rank</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select>
<input className={INPUT} placeholder="Vessel type" value={f.vesselType} onChange={(e) => setF({ ...f, vesselType: e.target.value })} />
<label className="text-xs text-neutral-500">From<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} /></label>
<label className="text-xs text-neutral-500">To<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} /></label>
<input className={INPUT} type="number" min={0} placeholder="Duration (months)" value={f.durationMonths} onChange={(e) => setF({ ...f, durationMonths: e.target.value })} />
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add experience"}</button></div>
</form>
)}
</Section>
);
}
function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
return (
<Section>
{!paystatus.showSalary ? (
<p className="text-sm text-neutral-500">Net pay is visible to office roles only. Site staff see pay <em>status</em> once monthly wage reports are generated.</p>
) : paystatus.salary ? (
<>
<Row k="Basic" v={`${paystatus.salary.currency} ${paystatus.salary.basic.toLocaleString("en-IN")} / ${paystatus.salary.rateBasis.toLowerCase()}`} />
<Row k="Victualing / day" v={`${paystatus.salary.currency} ${paystatus.salary.victualingPerDay.toLocaleString("en-IN")}`} />
</>
) : (
<p className="text-sm text-neutral-400">No salary structure on file.</p>
)}
<p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.</p>
</Section>
);
}
function SignOffButton({ assignmentId, crewName }: { assignmentId: string; crewName: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [date, setDate] = useState("");
const [remarks, setRemarks] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await signOffCrew(assignmentId, date, remarks);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); router.push("/crewing/crew"); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50">Sign off</button>
<AdminDialog title={`Sign off ${crewName}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={submit} className="space-y-4">
<p className="text-sm text-neutral-600">Ends this tour: the assignment closes, a tour record is added to Experience, and the crew member returns to the Candidates pool as an ex-hand. A backfill requisition is auto-raised.</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Sign-off date *</label>
<input type="date" className={INPUT} value={date} onChange={(e) => setDate(e.target.value)} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Remarks</label>
<input className={INPUT} value={remarks} onChange={(e) => setRemarks(e.target.value)} placeholder="Optional" />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !date} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Signing off…" : "Sign off"}</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -1,113 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
import { redirect, notFound } from "next/navigation";
import { CrewProfile } from "./crew-profile";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew profile" };
export default async function CrewProfilePage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_crew_records")) redirect("/dashboard");
const { id } = await params;
const c = await db.crewMember.findUnique({
where: { id },
include: {
currentRank: { select: { name: true } },
documents: { orderBy: { createdAt: "desc" } },
bankDetail: true,
epfDetail: true,
nextOfKin: { orderBy: { createdAt: "asc" } },
ppeIssues: { orderBy: { issuedDate: "desc" } },
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
assignments: {
where: { status: { not: "SIGNED_OFF" } },
orderBy: { signOnDate: "desc" },
take: 1,
include: {
vessel: { select: { name: true } },
site: { select: { name: true } },
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
},
},
},
});
if (!c) notFound();
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
const assignment = c.assignments[0] ?? null;
const showSalary = canViewSalary(role);
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
const appraisals = await db.appraisal.findMany({
where: { assignment: { crewMemberId: c.id } },
orderBy: { createdAt: "desc" },
select: { id: true, period: true, status: true, comments: true, ratings: true },
});
return (
<CrewProfile
crew={{
id: c.id,
name: c.name,
employeeId: c.employeeId ?? "—",
rank: c.currentRank?.name ?? "—",
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
status: assignment?.status ?? null,
}}
documents={c.documents.map((d) => ({
id: d.id,
docType: d.docType,
number: documentNumberValue(d.number, d.docType, role),
issueDate: d.issueDate?.toISOString() ?? null,
expiryDate: d.expiryDate?.toISOString() ?? null,
verificationStatus: d.verificationStatus,
hasFile: Boolean(d.fileKey),
}))}
bank={{
accountName: c.bankDetail?.accountName ?? null,
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
ifsc: c.bankDetail?.ifsc ?? null,
bankName: c.bankDetail?.bankName ?? null,
}}
epf={{
uan: c.epfDetail?.uan ?? null,
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
pfNumber: c.epfDetail?.pfNumber ?? null,
}}
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
paystatus={{
showSalary,
salary: showSalary && currentSalary
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
: null,
}}
ranks={ranks}
perms={{
editRecords: hasPermission(role, "upload_crew_records"),
issuePpe: hasPermission(role, "issue_ppe"),
}}
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
appraisals={appraisals.map((a) => ({
id: a.id,
period: a.period,
status: a.status,
comments: a.comments,
ratings: (a.ratings ?? null) as { competence: number | null; conduct: number | null; safety: number | null } | null,
}))}
appraisalCtx={{ assignmentId: assignment?.id ?? null, canRaise: hasPermission(role, "raise_appraisal") && Boolean(assignment) }}
/>
);
}

View file

@ -1,326 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
import { SeafarerDocType, PpeItem } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
// Whole months between two dates (floored), min 0 — for the experience record.
function monthsBetween(from: Date, to: Date): number {
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0);
return Math.max(0, months);
}
type ActionResult = { ok: true; id?: string } | { error: string };
const crewPath = (id: string) => `/crewing/crew/${id}`;
async function guard(permission: Permission): Promise<{ error: string } | { userId: string }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id };
}
async function requireCrew(id: string) {
return db.crewMember.findUnique({ where: { id }, select: { id: true } });
}
// ── Documents ──────────────────────────────────────────────────────────────
const docSchema = z.object({
crewMemberId: z.string().min(1),
docType: z.nativeEnum(SeafarerDocType),
number: z.string().optional(),
issueDate: z.string().optional(),
expiryDate: z.string().optional(),
});
export async function uploadDocument(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = docSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
docType: formData.get("docType"),
number: (formData.get("number") as string) || undefined,
issueDate: (formData.get("issueDate") as string) || undefined,
expiryDate: (formData.get("expiryDate") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
let fileKey: string | null = null;
const file = formData.get("file");
if (file instanceof File && file.size > 0) {
fileKey = buildStorageKey("crew-document", d.crewMemberId, file.name);
await uploadBuffer(fileKey, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
}
await db.seafarerDocument.create({
data: {
crewMemberId: d.crewMemberId,
docType: d.docType,
number: d.number ?? null,
fileKey,
issueDate: d.issueDate ? new Date(d.issueDate) : null,
expiryDate: d.expiryDate ? new Date(d.expiryDate) : null,
},
});
await db.crewAction.create({ data: { actionType: "DOCUMENT_UPLOADED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { docType: d.docType } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteDocument(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
if (!doc) return { error: "Document not found" };
await db.$transaction(async (tx) => {
await tx.seafarerDocument.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
});
});
revalidatePath(crewPath(doc.crewMemberId));
return { ok: true };
}
// ── Bank & EPF ───────────────────────────────────────────────────────────────
const bankEpfSchema = z.object({
crewMemberId: z.string().min(1),
accountName: z.string().optional(),
accountNumber: z.string().optional(),
ifsc: z.string().optional(),
bankName: z.string().optional(),
uan: z.string().optional(),
aadhaarLast4: z.string().optional(),
pfNumber: z.string().optional(),
});
export async function saveBankEpf(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = bankEpfSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.$transaction(async (tx) => {
await tx.bankDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
create: { crewMemberId: d.crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
});
await tx.epfDetail.upsert({
where: { crewMemberId: d.crewMemberId },
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
create: { crewMemberId: d.crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
});
await tx.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "bank_epf" } } });
});
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Next of kin / emergency ────────────────────────────────────────────────
const nokSchema = z.object({
crewMemberId: z.string().min(1),
name: z.string().trim().min(1, "Name is required"),
relationship: z.string().optional(),
phone: z.string().optional(),
address: z.string().optional(),
isEmergency: z.boolean().optional(),
});
export async function addNextOfKin(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = nokSchema.safeParse({
crewMemberId: formData.get("crewMemberId"),
name: formData.get("name"),
relationship: (formData.get("relationship") as string) || undefined,
phone: (formData.get("phone") as string) || undefined,
address: (formData.get("address") as string) || undefined,
isEmergency: formData.get("isEmergency") === "on" || formData.get("isEmergency") === "true",
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.nextOfKin.create({
data: {
crewMemberId: d.crewMemberId,
name: d.name,
relationship: d.relationship ?? null,
phone: d.phone ?? null,
address: d.address ?? null,
isEmergency: d.isEmergency ?? false,
},
});
await db.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "next_of_kin" } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function deleteNextOfKin(id: string): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
if (!nok) return { error: "Record not found" };
await db.$transaction(async (tx) => {
await tx.nextOfKin.delete({ where: { id } });
await tx.crewAction.create({
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
});
});
revalidatePath(crewPath(nok.crewMemberId));
return { ok: true };
}
// ── PPE ──────────────────────────────────────────────────────────────────────
const ppeSchema = z.object({
crewMemberId: z.string().min(1),
item: z.nativeEnum(PpeItem),
size: z.string().optional(),
quantity: z.coerce.number().int().min(1).default(1),
comment: z.string().optional(),
});
export async function issuePpe(formData: FormData): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const parsed = ppeSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.ppeIssue.create({
data: { crewMemberId: d.crewMemberId, item: d.item, size: d.size ?? null, quantity: d.quantity, comment: d.comment ?? null, issuedById: g.userId },
});
await db.crewAction.create({ data: { actionType: "PPE_ISSUED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { item: d.item } } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
export async function returnPpe(id: string): Promise<ActionResult> {
const g = await guard("issue_ppe");
if ("error" in g) return g;
const ppe = await db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, returnedDate: true } });
if (!ppe) return { error: "PPE record not found" };
if (ppe.returnedDate) return { error: "Already returned" };
await db.ppeIssue.update({ where: { id }, data: { returnedDate: new Date() } });
await db.crewAction.create({ data: { actionType: "PPE_RETURNED", actorId: g.userId, crewMemberId: ppe.crewMemberId } });
revalidatePath(crewPath(ppe.crewMemberId));
return { ok: true };
}
// ── Experience ─────────────────────────────────────────────────────────────
const expSchema = z.object({
crewMemberId: z.string().min(1),
vesselType: z.string().optional(),
rankId: z.string().optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
durationMonths: z.coerce.number().int().min(0).optional(),
});
export async function addExperience(formData: FormData): Promise<ActionResult> {
const g = await guard("upload_crew_records");
if ("error" in g) return g;
const parsed = expSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
await db.experienceRecord.create({
data: {
crewMemberId: d.crewMemberId,
vesselType: d.vesselType ?? null,
rankId: d.rankId || null,
fromDate: d.fromDate ? new Date(d.fromDate) : null,
toDate: d.toDate ? new Date(d.toDate) : null,
durationMonths: d.durationMonths ?? null,
source: "declared",
},
});
await db.crewAction.create({ data: { actionType: "EXPERIENCE_ADDED", actorId: g.userId, crewMemberId: d.crewMemberId } });
revalidatePath(crewPath(d.crewMemberId));
return { ok: true };
}
// ── Sign off (Phase 4c, Epic K) ────────────────────────────────────────────────
// Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD,
// flip the crew member back to EX_HAND (so they return to the Candidates pool), and
// auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper).
export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise<ActionResult> {
const g = await guard("sign_off_crew");
if ("error" in g) return g;
if (!signOffDate) return { error: "A sign-off date is required" };
const assignment = await db.crewAssignment.findUnique({
where: { id: assignmentId },
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
});
if (!assignment) return { error: "Assignment not found" };
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" };
const off = new Date(signOffDate);
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
// seat can never become vacant without its backfill being raised.
const backfill = await db.$transaction(async (tx) => {
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
await tx.experienceRecord.create({
data: {
crewMemberId: assignment.crewMemberId,
rankId: assignment.rankId,
vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null,
fromDate: assignment.signOnDate,
toDate: off,
durationMonths: monthsBetween(assignment.signOnDate, off),
source: "internal",
},
});
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
await tx.crewMember.update({
where: { id: assignment.crewMemberId },
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
});
await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
});
return autoRaiseRequisition(
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
tx
);
});
// Notify the office after the transaction commits.
await notifyAutoRaised(backfill);
revalidatePath(crewPath(assignment.crewMemberId));
revalidatePath("/crewing/crew");
return { ok: true };
}

View file

@ -1,93 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { AssignmentStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
type CrewRow = {
id: string;
name: string;
employeeId: string;
rank: string;
location: string;
status: AssignmentStatus | null;
};
const INPUT =
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function StatusBadge({ status }: { status: AssignmentStatus | null }) {
if (status === "ACTIVE") return <Badge variant="success">Active</Badge>;
if (status === "ON_LEAVE") return <Badge variant="warning">On leave</Badge>;
return <Badge variant="secondary"></Badge>;
}
export function CrewDirectory({ crew }: { crew: CrewRow[] }) {
const [search, setSearch] = useState("");
const [location, setLocation] = useState("ALL");
const locations = useMemo(
() => Array.from(new Set(crew.map((c) => c.location).filter((l) => l !== "—"))).sort(),
[crew]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return crew.filter((c) => {
if (location !== "ALL" && c.location !== location) return false;
if (q && !`${c.name} ${c.employeeId} ${c.rank}`.toLowerCase().includes(q)) return false;
return true;
});
}, [crew, search, location]);
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Crew</h1>
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} active crew member{crew.length === 1 ? "" : "s"}</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name, employee no or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
<option value="ALL">All vessels / sites</option>
{locations.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Employee</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">
{crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."}
</td></tr>
) : (
filtered.map((c) => (
<tr key={c.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/crew/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId}</td>
<td className="px-4 py-3 text-neutral-700">{c.rank}</td>
<td className="px-4 py-3 text-neutral-700">{c.location}</td>
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -1,55 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { CrewDirectory } from "./crew-directory";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Crew" };
export default async function CrewPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
// Own-site scoping (§8.7): a site-staff user with a home site sees only crew whose
// active assignment is at that site. Without a home site they remain unscoped.
let siteScopeId: string | null = null;
if (session.user.role === "SITE_STAFF") {
siteScopeId = (await db.user.findUnique({ where: { id: session.user.id }, select: { siteId: true } }))?.siteId ?? null;
}
const crew = await db.crewMember.findMany({
where: {
status: "EMPLOYEE",
...(siteScopeId ? { assignments: { some: { status: { not: "SIGNED_OFF" }, siteId: siteScopeId } } } : {}),
},
orderBy: { name: "asc" },
include: {
currentRank: { select: { name: true } },
assignments: {
where: { status: { not: "SIGNED_OFF" } },
orderBy: { signOnDate: "desc" },
take: 1,
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
},
},
});
const rows = crew.map((c) => {
const a = c.assignments[0];
return {
id: c.id,
name: c.name,
employeeId: c.employeeId ?? "—",
rank: c.currentRank?.name ?? "—",
location: a?.vessel?.name ?? a?.site?.name ?? "—",
status: a?.status ?? null,
};
});
return <CrewDirectory crew={rows} />;
}

View file

@ -1,138 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { leaveCausesClash } from "@/lib/leave-clash";
import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { LeaveType } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const LEAVE_PATH = "/crewing/leave";
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
function revalidate() {
revalidatePath(LEAVE_PATH);
revalidatePath("/approvals");
}
// ── Apply for leave (Site staff, on behalf of a crew member) ───────────────────
const applySchema = z
.object({
assignmentId: z.string().min(1, "Crew member is required"),
type: z.nativeEnum(LeaveType).default("ANNUAL"),
fromDate: z.string().min(1, "From date is required"),
toDate: z.string().min(1, "To date is required"),
reason: z.string().optional(),
})
.refine((d) => new Date(d.toDate) >= new Date(d.fromDate), { message: "To date must be on or after the from date" });
export async function applyLeave(formData: FormData): Promise<ActionResult> {
const g = await guard("apply_leave");
if ("error" in g) return g;
const parsed = applySchema.safeParse({
assignmentId: formData.get("assignmentId"),
type: (formData.get("type") as string) || undefined,
fromDate: formData.get("fromDate"),
toDate: formData.get("toDate"),
reason: (formData.get("reason") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const assignment = await db.crewAssignment.findUnique({
where: { id: d.assignmentId },
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
});
if (!assignment) return { error: "Crew assignment not found" };
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has signed off" };
const leave = await db.leaveRequest.create({
data: {
assignmentId: d.assignmentId,
type: d.type,
fromDate: new Date(d.fromDate),
toDate: new Date(d.toDate),
reason: d.reason ?? null,
appliedById: g.userId,
},
});
await db.crewAction.create({ data: { actionType: "LEAVE_APPLIED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
const managers = await getManagerRecipients();
await notifyCrew({
event: "LEAVE_FOR_APPROVAL",
recipients: managers,
subject: `Leave for approval — ${assignment.crewMember.name}`,
body: `${assignment.crewMember.name} (${assignment.rank.name}) has a leave request from ${d.fromDate} to ${d.toDate} awaiting your decision.`,
link: LEAVE_PATH,
});
revalidate();
return { ok: true, id: leave.id };
}
// ── Decide leave (Manager) ─────────────────────────────────────────────────────
// On approval the assignment goes ON_LEAVE and a clash check runs; if it would
// leave the vessel with no same-rank cover, a LEAVE requisition is auto-raised.
export async function decideLeave(id: string, approve: boolean, note?: string): Promise<ActionResult> {
const g = await guard("decide_leave");
if ("error" in g) return g;
const leave = await db.leaveRequest.findUnique({
where: { id },
include: { assignment: { select: { id: true, crewMemberId: true, rankId: true, vesselId: true, siteId: true } } },
});
if (!leave) return { error: "Leave request not found" };
if (leave.status !== "APPLIED") return { error: `This leave request is already ${leave.status}` };
if (!approve && !note?.trim()) return { error: "A reason is required to decline" };
if (!approve) {
await db.leaveRequest.update({ where: { id }, data: { status: "REJECTED", decidedById: g.userId, decidedAt: new Date(), reason: note?.trim() || leave.reason } });
await db.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, note: note?.trim() || null, metadata: { decision: "REJECTED" } } });
revalidate();
return { ok: true };
}
// Leave approval + the clash check + any backfill requisition commit atomically
// (spec §5.3/§11): an approved leave can never leave a cover gap un-raised.
const backfill = await db.$transaction(async (tx) => {
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
const clash = await leaveCausesClash(tx, {
assignmentId: leave.assignment.id,
rankId: leave.assignment.rankId,
vesselId: leave.assignment.vesselId,
fromDate: leave.fromDate,
toDate: leave.toDate,
});
if (!clash) return null;
return autoRaiseRequisition(
{ rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" },
tx
);
});
// Notify the office after the transaction commits.
if (backfill) await notifyAutoRaised(backfill);
revalidate();
return { ok: true };
}

View file

@ -1,163 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { LeaveStatus, LeaveType } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { applyLeave, decideLeave } from "./actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const LEAVE_TYPES: LeaveType[] = ["ANNUAL", "MEDICAL", "EMERGENCY", "UNPAID", "OTHER"];
const fmt = (iso: string) => new Date(iso).toLocaleDateString();
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
type Assignment = { id: string; crewName: string; rank: string; location: string };
type Request = { id: string; crewName: string; rank: string; location: string; type: LeaveType; status: LeaveStatus; fromDate: string; toDate: string; reason: string | null };
const STATUS_VARIANT: Record<LeaveStatus, "warning" | "success" | "danger" | "secondary"> = {
APPLIED: "warning", APPROVED: "success", REJECTED: "danger", CANCELLED: "secondary",
};
export function LeaveManager({ assignments, requests, canApply, canDecide }: { assignments: Assignment[]; requests: Request[]; canApply: boolean; canDecide: boolean }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [f, setF] = useState({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" });
const duration = f.fromDate && f.toDate ? Math.max(0, Math.round((new Date(f.toDate).getTime() - new Date(f.fromDate).getTime()) / 86400000) + 1) : 0;
async function submitApply(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
const res = await applyLeave(fd);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); setF({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" }); router.refresh(); }
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Leave</h1>
<p className="text-sm text-neutral-500 mt-0.5">Site staff apply on behalf of crew · the Manager approves.</p>
</div>
{canApply && <button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">Apply for leave</button>}
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th>
<th className="px-4 py-3">Rank / location</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Dates</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{requests.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-neutral-400">No leave requests.</td></tr>
) : requests.map((r) => (
<DecisionRow key={r.id} r={r} canDecide={canDecide} onDone={() => router.refresh()} />
))}
</tbody>
</table>
</div>
<AdminDialog title="Apply for leave" open={open} onClose={() => setOpen(false)}>
<form onSubmit={submitApply} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Crew member *</label>
<select className={INPUT} value={f.assignmentId} onChange={(e) => setF({ ...f, assignmentId: e.target.value })} required>
<option value=""> Select crew </option>
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Type</label>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
{LEAVE_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
</select>
</div>
<div></div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">From *</label>
<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">To *</label>
<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} required />
</div>
</div>
{duration > 0 && <p className="text-xs text-neutral-500 bg-neutral-50 rounded-md px-3 py-2">{duration} day{duration === 1 ? "" : "s"} of leave.</p>}
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<input className={INPUT} value={f.reason} onChange={(e) => setF({ ...f, reason: e.target.value })} placeholder="Optional" />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !f.assignmentId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Applying…" : "Apply"}</button>
</div>
</form>
</AdminDialog>
</div>
);
}
function DecisionRow({ r, canDecide, onDone }: { r: Request; canDecide: boolean; onDone: () => void }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [declineOpen, setDeclineOpen] = useState(false);
const [reason, setReason] = useState("");
async function approve() {
setPending(true); setError("");
const res = await decideLeave(r.id, true);
setPending(false);
if ("error" in res) setError(res.error); else onDone();
}
async function decline(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await decideLeave(r.id, false, reason);
setPending(false);
if ("error" in res) setError(res.error); else { setDeclineOpen(false); onDone(); }
}
return (
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{r.rank} · {r.location}</td>
<td className="px-4 py-3 text-neutral-600">{label(r.type)}</td>
<td className="px-4 py-3 text-neutral-600">{fmt(r.fromDate)} {fmt(r.toDate)}</td>
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[r.status]}>{label(r.status)}</Badge></td>
<td className="px-4 py-3 text-right">
{r.status === "APPLIED" && (canDecide ? (
<div className="flex justify-end gap-2">
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
<button onClick={() => setDeclineOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Decline</button>
</div>
) : <span className="text-xs text-neutral-400">Awaiting manager</span>)}
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title="Decline leave" open={declineOpen} onClose={() => setDeclineOpen(false)}>
<form onSubmit={decline} className="space-y-4 text-left">
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setDeclineOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Decline</button>
</div>
</form>
</AdminDialog>
</td>
</tr>
);
}

View file

@ -1,52 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { LeaveManager } from "./leave-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Leave" };
export default async function LeavePage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
const canApply = hasPermission(role, "apply_leave");
const canDecide = hasPermission(role, "decide_leave");
if (!canApply && !canDecide) redirect("/dashboard"); // MPO has no leave screen (R1)
const [assignments, requests] = await Promise.all([
db.crewAssignment.findMany({
where: { status: { not: "SIGNED_OFF" } },
orderBy: { crewMember: { name: "asc" } },
include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
}),
db.leaveRequest.findMany({
orderBy: { createdAt: "desc" },
take: 100,
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } } },
}),
]);
return (
<LeaveManager
assignments={assignments.map((a) => ({ id: a.id, crewName: a.crewMember.name, rank: a.rank.name, location: a.vessel?.name ?? a.site?.name ?? "—" }))}
requests={requests.map((r) => ({
id: r.id,
crewName: r.assignment.crewMember.name,
rank: r.assignment.rank.name,
location: r.assignment.vessel?.name ?? r.assignment.site?.name ?? "—",
type: r.type,
status: r.status,
fromDate: r.fromDate.toISOString(),
toDate: r.toDate.toISOString(),
reason: r.reason,
}))}
canApply={canApply}
canDecide={canDecide}
/>
);
}

View file

@ -1,138 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { canCancel } from "@/lib/requisition-state-machine";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { WithdrawRequisitionButton } from "./withdraw-button";
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "../requisition-ui";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Requisition" };
export default async function RequisitionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
const { id } = await params;
const req = await db.requisition.findUnique({
where: { id },
include: {
rank: { select: { name: true, code: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
raisedBy: { select: { name: true } },
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
_count: { select: { applications: true } },
},
});
if (!req) notFound();
const location = req.vessel?.name ?? req.site?.name ?? "—";
const canWithdraw = hasPermission(session.user.role, "cancel_requisition") && canCancel(req.status, session.user.role);
const details: [string, string][] = [
["Requisition", req.code],
["Rank", `${req.rank.name} (${req.rank.code})`],
["Vessel / site", location],
["Reason", REASON_LABEL[req.reason]],
["Raised by", req.autoRaised ? "System (auto-raised)" : req.raisedBy?.name ?? "—"],
["Raised", `${ageLabel(req.createdAt.toISOString())} ago`],
["Needed by", req.neededBy ? req.neededBy.toLocaleDateString() : "—"],
];
if (req.status === "CANCELLED" && req.cancellationReason) {
details.push(["Withdrawn", req.cancellationReason]);
}
return (
<div className="max-w-4xl">
<Link href="/crewing/requisitions" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Requisitions
</Link>
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{req.rank.name} {location}</h1>
<Badge variant={STATUS_VARIANT[req.status]}>{STATUS_LABEL[req.status]}</Badge>
</div>
<p className="text-sm text-neutral-500 mt-1">
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
</p>
</div>
<div className="flex items-center gap-2">
<Link
href={`/crewing/requisitions/${req.id}/pipeline`}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
>
Open pipeline
</Link>
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
</div>
</div>
{req.autoRaised && (
<div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800">
This requisition was <strong>auto-raised by the system</strong> ({REASON_LABEL[req.reason]}). No manual action
was needed to open it.
</div>
)}
{req.sourceReliefRequest && (
<div className="mb-6 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-800">
Converted from a relief request raised by{" "}
<strong>{req.sourceReliefRequest.requestedBy.name}</strong>.
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Vacancy details */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Vacancy details</h2>
</div>
<dl className="divide-y divide-neutral-100">
{details.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
<dt className="text-sm text-neutral-500">{k}</dt>
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
</div>
))}
</dl>
{req.notes && (
<div className="px-4 py-3 border-t border-neutral-100">
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
<p className="text-sm text-neutral-700">{req.notes}</p>
</div>
)}
</div>
{/* Candidates — the recruitment pipeline (Phase 3b) */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
</div>
<div className="px-4 py-8 text-center">
<p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
<p className="text-sm text-neutral-500 mt-0.5 mb-4">
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
</p>
<Link href={`/crewing/requisitions/${req.id}/pipeline`} className="text-sm font-medium text-primary-600 hover:underline">
Open recruitment pipeline
</Link>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,63 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { PipelineBoard } from "./pipeline-board";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Recruitment pipeline" };
export default async function PipelinePage({ params }: { params: Promise<{ id: string }> }) {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
if (!hasPermission(role, "view_requisitions")) redirect("/dashboard");
const { id } = await params;
const requisition = await db.requisition.findUnique({
where: { id },
include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
});
if (!requisition) notFound();
const applications = await db.application.findMany({
where: { requisitionId: id },
include: { crewMember: { select: { id: true, name: true, type: true, experienceMonths: true } } },
orderBy: { createdAt: "asc" },
});
const canManage = hasPermission(role, "manage_candidates");
// Candidates available to add: in the pool (not employees) and not already applied here.
const appliedIds = new Set(applications.map((a) => a.crewMemberId));
const pool = canManage
? (await db.crewMember.findMany({
where: { status: { not: "EMPLOYEE" } },
orderBy: { name: "asc" },
select: { id: true, name: true, type: true },
})).filter((c) => !appliedIds.has(c.id))
: [];
return (
<PipelineBoard
requisition={{
id: requisition.id,
code: requisition.code,
rank: requisition.rank.name,
location: requisition.vessel?.name ?? requisition.site?.name ?? "—",
status: requisition.status,
}}
applications={applications.map((a) => ({
id: a.id,
stage: a.stage,
crewName: a.crewMember.name,
isExHand: a.crewMember.type === "EX_HAND",
experienceMonths: a.crewMember.experienceMonths,
}))}
pool={pool}
canManage={canManage}
/>
);
}

View file

@ -1,125 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import type { ApplicationStage, RequisitionStatus } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { STAGE_ORDER, STAGE_LABEL } from "../../../applications/application-ui";
import { addApplication } from "../../../applications/actions";
type AppCard = { id: string; stage: ApplicationStage; crewName: string; isExHand: boolean; experienceMonths: number };
type PoolItem = { id: string; name: string; type: string };
export function PipelineBoard({
requisition,
applications,
pool,
canManage,
}: {
requisition: { id: string; code: string; rank: string; location: string; status: RequisitionStatus };
applications: AppCard[];
pool: PoolItem[];
canManage: boolean;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [crewMemberId, setCrewMemberId] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function add(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const fd = new FormData();
fd.set("requisitionId", requisition.id);
fd.set("crewMemberId", crewMemberId);
const res = await addApplication(fd);
setPending(false);
if ("error" in res) setError(res.error);
else { setOpen(false); setCrewMemberId(""); router.refresh(); }
}
const byStage = (s: ApplicationStage) => applications.filter((a) => a.stage === s);
const rejected = applications.filter((a) => a.stage === "REJECTED");
return (
<div>
<Link href={`/crewing/requisitions/${requisition.id}`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
<ArrowLeft className="h-4 w-4" /> Requisition
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">{requisition.rank} {requisition.location}</h1>
<p className="text-sm text-neutral-500 mt-0.5">Recruitment pipeline · <span className="font-mono">{requisition.code}</span> · {applications.length} candidate{applications.length === 1 ? "" : "s"}</p>
</div>
{canManage && (
<button onClick={() => setOpen(true)} className="rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-50">
+ Add candidate
</button>
)}
</div>
<div className="flex gap-3 overflow-x-auto pb-4">
{STAGE_ORDER.map((s) => {
const cards = byStage(s);
return (
<div key={s} className="w-56 shrink-0">
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">{STAGE_LABEL[s]}</span>
<span className="text-xs text-neutral-400">{cards.length}</span>
</div>
<div className="space-y-2 min-h-[60px] rounded-lg bg-neutral-50 p-2">
{cards.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="block rounded-md border border-neutral-200 bg-white p-3 hover:border-primary-300 hover:shadow-sm transition">
<p className="text-sm font-medium text-neutral-900">{a.crewName}</p>
<p className="text-xs text-neutral-500 mt-0.5">
{Math.floor(a.experienceMonths / 12)} yrs
{a.isExHand && <span className="ml-1 text-purple-600">· ex-hand</span>}
</p>
</Link>
))}
{cards.length === 0 && <p className="text-center text-xs text-neutral-300 py-2"></p>}
</div>
</div>
);
})}
</div>
{rejected.length > 0 && (
<div className="mt-6">
<p className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Rejected ({rejected.length})</p>
<div className="flex flex-wrap gap-2">
{rejected.map((a) => (
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-50">
{a.crewName}
</Link>
))}
</div>
</div>
)}
<AdminDialog title="Add candidate to pipeline" open={open} onClose={() => setOpen(false)}>
<form onSubmit={add} className="space-y-4">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Candidate</label>
<select className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" value={crewMemberId} onChange={(e) => setCrewMemberId(e.target.value)} required>
<option value=""> Select from the pool </option>
{pool.map((c) => (
<option key={c.id} value={c.id}>{c.name}{c.type === "EX_HAND" ? " (ex-hand)" : ""}</option>
))}
</select>
{pool.length === 0 && <p className="mt-1 text-xs text-neutral-400">No available candidates. Add candidates from the Candidates page first.</p>}
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending || !crewMemberId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Adding…" : "Add to pipeline"}</button>
</div>
</form>
</AdminDialog>
</div>
);
}

View file

@ -1,62 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { cancelRequisition } from "../actions";
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
export function WithdrawRequisitionButton({ id }: { id: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [reason, setReason] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const result = await cancelRequisition(id, reason);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50"
>
Withdraw
</button>
<AdminDialog title="Withdraw requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-neutral-600">
Withdrawing closes this requisition. A reason is required and is recorded on the audit trail.
</p>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason *</label>
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">
{pending ? "Withdrawing…" : "Withdraw requisition"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -1,303 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import {
canCancel,
canPerformAction,
getTransition,
type RequisitionAction,
} from "@/lib/requisition-state-machine";
import {
createRequisitionTx,
getMpoRecipients,
getOfficeRecipients,
requisitionLocationLabel,
} from "@/lib/requisition-service";
import { notifyCrew } from "@/lib/notifier";
import { RequisitionReason } from "@prisma/client";
import type { Role } from "@prisma/client";
import { z } from "zod";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true; id?: string } | { error: string };
const LIST_PATH = "/crewing/requisitions";
// Crewing flag + permission guard. Returns the actor on success.
async function guard(
permission: Permission
): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// ── Raise a requisition (MPO / Manager) ───────────────────────────────────────
const raiseSchema = z
.object({
rankId: z.string().min(1, "Rank is required"),
vesselId: z.string().optional(),
siteId: z.string().optional(),
reason: z.nativeEnum(RequisitionReason).default("NEW_VACANCY"),
neededBy: z.string().optional(),
notes: z.string().optional(),
})
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
message: "A vessel or site is required",
});
export async function raiseRequisition(formData: FormData): Promise<ActionResult> {
const g = await guard("raise_requisition");
if ("error" in g) return g;
const parsed = raiseSchema.safeParse({
rankId: formData.get("rankId"),
vesselId: (formData.get("vesselId") as string) || undefined,
siteId: (formData.get("siteId") as string) || undefined,
reason: (formData.get("reason") as string) || undefined,
neededBy: (formData.get("neededBy") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const requisition = await db.$transaction((tx) =>
createRequisitionTx(tx, {
rankId: d.rankId,
vesselId: d.vesselId || null,
siteId: d.siteId || null,
reason: d.reason,
neededBy: d.neededBy ? new Date(d.neededBy) : null,
notes: d.notes || null,
raisedById: g.userId,
})
);
// Notify the MPO pool so it can start sourcing (spec §11). Don't self-notify.
const recipients = (await getMpoRecipients()).filter((u) => u.id !== g.userId);
if (recipients.length) {
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} has been raised (${requisition.code}).`,
link: `${LIST_PATH}/${requisition.id}`,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: requisition.id };
}
// ── Withdraw / cancel a requisition (Manager, from OPEN/SHORTLISTING) ──────────
export async function cancelRequisition(id: string, reason: string): Promise<ActionResult> {
const g = await guard("cancel_requisition");
if ("error" in g) return g;
const trimmed = reason?.trim();
if (!trimmed) return { error: "A reason is required to withdraw a requisition" };
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
if (!req) return { error: "Requisition not found" };
if (!canCancel(req.status, g.role)) {
return { error: `A requisition cannot be withdrawn once it is ${req.status}` };
}
await db.requisition.update({
where: { id },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancellationReason: trimmed,
actions: {
create: { actionType: "REQUISITION_CANCELLED", actorId: g.userId, note: trimmed },
},
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true };
}
// ── Advance a requisition through the pipeline stages ──────────────────────────
// Phase 2 exposes the transitions; the recruitment pipeline (Phase 3) drives
// them as candidates progress. Role gating comes from the state machine.
export async function transitionRequisition(
id: string,
action: RequisitionAction
): Promise<ActionResult> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
if (!req) return { error: "Requisition not found" };
const transition = getTransition(req.status, action);
if (!transition) return { error: `Cannot ${action} from ${req.status}` };
if (!canPerformAction(req.status, action, session.user.role)) return { error: "Unauthorized" };
await db.requisition.update({
where: { id },
data: {
status: transition.to,
filledAt: transition.to === "FILLED" ? new Date() : undefined,
actions: {
create: {
actionType: transition.to === "FILLED" ? "REQUISITION_FILLED" : "REQUISITION_ADVANCED",
actorId: session.user.id,
metadata: { from: req.status, to: transition.to },
},
},
},
});
revalidatePath(LIST_PATH);
revalidatePath(`${LIST_PATH}/${id}`);
return { ok: true };
}
// ── Relief cover request (site staff) ──────────────────────────────────────────
// Site staff flag a foreseen gap; the office converts it into a requisition. The
// site-staff origination UI lands with the Leave/clash screen (Phase 4); the
// action exists now so the office-side convert flow and auto-raise share a path.
const reliefSchema = z
.object({
rankId: z.string().min(1, "Rank is required"),
vesselId: z.string().optional(),
siteId: z.string().optional(),
note: z.string().optional(),
})
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
message: "A vessel or site is required",
});
export async function requestReliefCover(formData: FormData): Promise<ActionResult> {
const g = await guard("request_relief_cover");
if ("error" in g) return g;
const parsed = reliefSchema.safeParse({
rankId: formData.get("rankId"),
vesselId: (formData.get("vesselId") as string) || undefined,
siteId: (formData.get("siteId") as string) || undefined,
note: (formData.get("note") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const relief = await db.$transaction(async (tx) => {
const created = await tx.reliefRequest.create({
data: {
rankId: d.rankId,
vesselId: d.vesselId || null,
siteId: d.siteId || null,
note: d.note || null,
requestedById: g.userId,
},
include: { rank: true, vessel: true, site: true },
});
// CrewAction has no relief relation; record the id in metadata.
await tx.crewAction.create({
data: {
actionType: "RELIEF_REQUESTED",
actorId: g.userId,
metadata: { reliefRequestId: created.id, rankId: d.rankId },
},
});
return created;
});
const recipients = await getOfficeRecipients();
if (recipients.length) {
const loc = requisitionLocationLabel(relief);
await notifyCrew({
event: "RELIEF_REQUESTED",
recipients,
subject: `Relief cover requested — ${relief.rank.name} on ${loc}`,
body: `A site has requested relief cover for a ${relief.rank.name} on ${loc}. Convert it to a requisition to start sourcing.`,
link: LIST_PATH,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: relief.id };
}
// ── Convert a relief request into a requisition (MPO / Manager) ────────────────
const convertSchema = z.object({
reliefRequestId: z.string().min(1, "Relief request is required"),
reason: z.nativeEnum(RequisitionReason).default("REPLACEMENT"),
neededBy: z.string().optional(),
notes: z.string().optional(),
});
export async function convertReliefToRequisition(formData: FormData): Promise<ActionResult> {
const g = await guard("convert_relief_to_requisition");
if ("error" in g) return g;
const parsed = convertSchema.safeParse({
reliefRequestId: formData.get("reliefRequestId"),
reason: (formData.get("reason") as string) || undefined,
neededBy: (formData.get("neededBy") as string) || undefined,
notes: (formData.get("notes") as string) || undefined,
});
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const relief = await db.reliefRequest.findUnique({ where: { id: d.reliefRequestId } });
if (!relief) return { error: "Relief request not found" };
if (relief.status !== "OPEN") return { error: "This relief request has already been handled" };
const requisition = await db.$transaction(async (tx) => {
const req = await createRequisitionTx(tx, {
rankId: relief.rankId,
vesselId: relief.vesselId,
siteId: relief.siteId,
reason: d.reason,
neededBy: d.neededBy ? new Date(d.neededBy) : null,
notes: d.notes || null,
raisedById: g.userId,
});
await tx.reliefRequest.update({
where: { id: relief.id },
data: { status: "CONVERTED", convertedRequisitionId: req.id },
});
await tx.crewAction.create({
data: {
actionType: "RELIEF_CONVERTED",
actorId: g.userId,
requisitionId: req.id,
metadata: { reliefRequestId: relief.id },
},
});
return req;
});
// Let the requester know their relief request became a requisition.
const requester = await db.user.findUnique({ where: { id: relief.requestedById } });
if (requester && requester.isActive && requester.id !== g.userId) {
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "RELIEF_CONVERTED",
recipients: [requester],
subject: `Relief cover converted — ${requisition.code}`,
body: `Your relief request for a ${requisition.rank.name} on ${loc} is now requisition ${requisition.code}.`,
link: `${LIST_PATH}/${requisition.id}`,
});
}
revalidatePath(LIST_PATH);
return { ok: true, id: requisition.id };
}

View file

@ -1,80 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { RequisitionsManager } from "./requisitions-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Requisitions" };
export default async function RequisitionsPage() {
// Dark unless the crewing module is switched on.
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
const role = session.user.role;
const [requisitions, reliefRequests, ranks, vessels, sites] = await Promise.all([
db.requisition.findMany({
orderBy: { createdAt: "desc" },
include: {
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
raisedBy: { select: { name: true } },
_count: { select: { applications: true } },
},
}),
db.reliefRequest.findMany({
where: { status: "OPEN" },
orderBy: { createdAt: "desc" },
include: {
rank: { select: { name: true } },
vessel: { select: { name: true } },
site: { select: { name: true } },
requestedBy: { select: { name: true } },
},
}),
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Flatten to plain props — no Date/Decimal crosses the server→client boundary.
const rows = requisitions.map((r) => ({
id: r.id,
code: r.code,
status: r.status,
reason: r.reason,
autoRaised: r.autoRaised,
rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—",
raisedBy: r.raisedBy?.name ?? "System",
candidateCount: r._count.applications,
createdAt: r.createdAt.toISOString(),
}));
const relief = reliefRequests.map((r) => ({
id: r.id,
rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—",
note: r.note,
requestedBy: r.requestedBy.name,
createdAt: r.createdAt.toISOString(),
}));
return (
<RequisitionsManager
requisitions={rows}
reliefRequests={relief}
ranks={ranks}
vessels={vessels}
sites={sites}
canRaise={hasPermission(role, "raise_requisition")}
canConvert={hasPermission(role, "convert_relief_to_requisition")}
/>
);
}

View file

@ -1,242 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { raiseRequisition, convertReliefToRequisition } from "./actions";
import { REASON_OPTIONS, REASON_LABEL } from "./requisition-ui";
const INPUT =
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
// A single "Vessel / site" picker — values are encoded "v:<id>" / "s:<id>" so
// one control covers both cost axes (spec §9 modal). Returns "" when unset.
function LocationSelect({
value,
onChange,
vessels,
sites,
}: {
value: string;
onChange: (v: string) => void;
vessels: Opt[];
sites: Opt[];
}) {
return (
<select className={INPUT} value={value} onChange={(e) => onChange(e.target.value)}>
<option value=""> Select vessel or site </option>
{vessels.length > 0 && (
<optgroup label="Vessels">
{vessels.map((v) => (
<option key={v.id} value={`v:${v.id}`}>{v.name}</option>
))}
</optgroup>
)}
{sites.length > 0 && (
<optgroup label="Sites">
{sites.map((s) => (
<option key={s.id} value={`s:${s.id}`}>{s.name}</option>
))}
</optgroup>
)}
</select>
);
}
function applyLocation(fd: FormData, location: string) {
if (location.startsWith("v:")) fd.set("vesselId", location.slice(2));
else if (location.startsWith("s:")) fd.set("siteId", location.slice(2));
}
// ── Raise requisition (MPO / Manager) ──────────────────────────────────────────
export function RaiseRequisitionButton({
ranks,
vessels,
sites,
}: {
ranks: RankOpt[];
vessels: Opt[];
sites: Opt[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [rankId, setRankId] = useState("");
const [location, setLocation] = useState("");
const [reason, setReason] = useState(REASON_OPTIONS[0]);
const [neededBy, setNeededBy] = useState("");
const [notes, setNotes] = useState("");
function reset() {
setRankId(""); setLocation(""); setReason(REASON_OPTIONS[0]); setNeededBy(""); setNotes(""); setError("");
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData();
fd.set("rankId", rankId);
applyLocation(fd, location);
fd.set("reason", reason);
if (neededBy) fd.set("neededBy", neededBy);
if (notes) fd.set("notes", notes);
const result = await raiseRequisition(fd);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
reset();
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
>
+ Raise requisition
</button>
<AdminDialog title="Raise requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
<select className={INPUT} value={rankId} onChange={(e) => setRankId(e.target.value)} required>
<option value=""> Select rank </option>
{ranks.map((r) => (
<option key={r.id} value={r.id}>{r.code} {r.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
<LocationSelect value={location} onChange={setLocation} vessels={vessels} sites={sites} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
{REASON_OPTIONS.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
</div>
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Raising…" : "Raise requisition"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}
// ── Convert a relief request into a requisition (MPO / Manager) ─────────────────
export function ConvertReliefButton({
reliefRequestId,
label,
}: {
reliefRequestId: string;
label: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [reason, setReason] = useState<typeof REASON_OPTIONS[number]>("REPLACEMENT");
const [neededBy, setNeededBy] = useState("");
const [notes, setNotes] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError("");
const fd = new FormData();
fd.set("reliefRequestId", reliefRequestId);
fd.set("reason", reason);
if (neededBy) fd.set("neededBy", neededBy);
if (notes) fd.set("notes", notes);
const result = await convertReliefToRequisition(fd);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
setOpen(false);
router.refresh();
}
}
return (
<>
<button
onClick={() => setOpen(true)}
className="rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
>
Open
</button>
<AdminDialog title="Convert to requisition" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm text-neutral-600">
Convert the relief request <span className="font-medium text-neutral-900">{label}</span> into an open
requisition so sourcing can begin.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
{REASON_OPTIONS.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
</div>
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Converting…" : "Convert"}
</button>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -1,52 +0,0 @@
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
import type { BadgeProps } from "@/components/ui/badge";
type Variant = NonNullable<BadgeProps["variant"]>;
// Status → badge variant (Crewing-Implementation-Spec §8.2).
export const STATUS_VARIANT: Record<RequisitionStatus, Variant> = {
OPEN: "outline",
SHORTLISTING: "default",
PROPOSING: "default",
INTERVIEWING: "warning",
SELECTED: "default",
FILLED: "success",
CANCELLED: "danger",
};
export const STATUS_LABEL: Record<RequisitionStatus, string> = {
OPEN: "Open",
SHORTLISTING: "Shortlisting",
PROPOSING: "Proposing",
INTERVIEWING: "Interviewing",
SELECTED: "Selected",
FILLED: "Filled",
CANCELLED: "Cancelled",
};
export const REASON_LABEL: Record<RequisitionReason, string> = {
NEW_VACANCY: "New vacancy",
REPLACEMENT: "Replacement",
LEAVE: "Leave cover",
SIGN_OFF: "Sign-off",
END_OF_CONTRACT: "End of contract",
OTHER: "Other",
};
export const REASON_OPTIONS: RequisitionReason[] = [
"NEW_VACANCY",
"REPLACEMENT",
"LEAVE",
"SIGN_OFF",
"END_OF_CONTRACT",
"OTHER",
];
// Compact "age" label (e.g. "3d", "5h", "12m") relative to now.
export function ageLabel(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
if (mins < 60) return `${Math.max(mins, 0)}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}

View file

@ -1,231 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RaiseRequisitionButton, ConvertReliefButton } from "./requisition-form";
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "./requisition-ui";
type RequisitionRow = {
id: string;
code: string;
status: RequisitionStatus;
reason: RequisitionReason;
autoRaised: boolean;
rankName: string;
location: string;
raisedBy: string;
candidateCount: number;
createdAt: string;
};
type ReliefRow = {
id: string;
rankName: string;
location: string;
note: string | null;
requestedBy: string;
createdAt: string;
};
type Opt = { id: string; name: string };
type RankOpt = { id: string; code: string; name: string };
const INPUT =
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
const STATUS_FILTERS: RequisitionStatus[] = [
"OPEN", "SHORTLISTING", "PROPOSING", "INTERVIEWING", "SELECTED", "FILLED", "CANCELLED",
];
export function RequisitionsManager({
requisitions,
reliefRequests,
ranks,
vessels,
sites,
canRaise,
canConvert,
}: {
requisitions: RequisitionRow[];
reliefRequests: ReliefRow[];
ranks: RankOpt[];
vessels: Opt[];
sites: Opt[];
canRaise: boolean;
canConvert: boolean;
}) {
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
const [location, setLocation] = useState("ALL");
const [rank, setRank] = useState("ALL");
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
const locations = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
[requisitions]
);
const rankNames = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(),
[requisitions]
);
const reasons = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.reason))),
[requisitions]
);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return requisitions.filter((r) => {
if (status !== "ALL" && r.status !== status) return false;
if (location !== "ALL" && r.location !== location) return false;
if (rank !== "ALL" && r.rankName !== rank) return false;
if (reason !== "ALL" && r.reason !== reason) return false;
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
return true;
});
}, [requisitions, search, status, location, rank, reason]);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Requisitions</h1>
<p className="text-sm text-neutral-500 mt-0.5">
{requisitions.length} requisition{requisitions.length === 1 ? "" : "s"} · vacancies being sourced and filled
</p>
</div>
{canRaise && <RaiseRequisitionButton ranks={ranks} vessels={vessels} sites={sites} />}
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap items-center gap-3">
<input
className={`${INPUT} flex-1 min-w-[200px]`}
placeholder="Search code, rank or location…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className={INPUT} value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
<option value="ALL">All statuses</option>
{STATUS_FILTERS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
<option value="ALL">All vessels / sites</option>
{locations.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
<option value="ALL">All ranks</option>
{rankNames.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
<option value="ALL">All reasons</option>
{reasons.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div>
{/* Requisitions table */}
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Requisition</th>
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Reason</th>
<th className="px-4 py-3">Candidates</th>
<th className="px-4 py-3">Raised by</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
No requisitions match these filters.
</td>
</tr>
) : (
filtered.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/requisitions/${r.id}`} className="block">
<span className="font-mono text-xs text-neutral-900">{r.code}</span>
<span className="ml-2 text-xs text-neutral-400">{ageLabel(r.createdAt)} ago</span>
{r.autoRaised && (
<span className="ml-2 rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-[10px] font-medium">
Auto
</span>
)}
</Link>
</td>
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
<td className="px-4 py-3 text-neutral-700 tabular-nums">{r.candidateCount}</td>
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Relief requests from sites (spec §8.2 / R3 / R6) */}
<div className="mt-8">
<h2 className="text-sm font-semibold text-neutral-900">Relief requests from sites</h2>
<p className="text-xs text-neutral-500 mt-0.5 mb-3">
Foreseen gaps flagged by site staff. Convert one into a requisition to start sourcing.
</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Note</th>
<th className="px-4 py-3">Requested by</th>
<th className="px-4 py-3 w-20"></th>
</tr>
</thead>
<tbody>
{reliefRequests.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
No open relief requests.
</td>
</tr>
) : (
reliefRequests.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
<td className="px-4 py-3 text-neutral-500">{r.note ?? "—"}</td>
<td className="px-4 py-3 text-neutral-500">{r.requestedBy}</td>
<td className="px-4 py-3 text-right">
{canConvert && (
<ConvertReliefButton reliefRequestId={r.id} label={`${r.rankName} on ${r.location}`} />
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View file

@ -1,165 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission, type Permission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import type { Role } from "@prisma/client";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
const PATH = "/crewing/verification";
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
return { userId: session.user.id, role: session.user.role };
}
// ── Document verification (MPO / Manager) ──────────────────────────────────────
export async function verifyDocument(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } });
if (!doc) return { error: "Document not found" };
if (doc.verificationStatus !== "PENDING") return { error: `This document is already ${doc.verificationStatus.toLowerCase()}` };
await db.seafarerDocument.update({
where: { id },
data: { verificationStatus: approve ? "VERIFIED" : "REJECTED", verifiedById: g.userId },
});
await db.crewAction.create({
data: {
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
actorId: g.userId,
crewMemberId: doc.crewMemberId,
note: remarks?.trim() || null,
metadata: { record: "document" },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${doc.crewMemberId}`);
return { ok: true };
}
// ── Bank / EPF verification (Accounts) ─────────────────────────────────────────
export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf", approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_bank_epf");
if ("error" in g) return g;
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const status = approve ? "VERIFIED" : "REJECTED";
if (kind === "bank") {
const rec = await db.bankDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
if (!rec) return { error: "Bank details not found" };
if (rec.verificationStatus !== "PENDING") return { error: `Bank details already ${rec.verificationStatus.toLowerCase()}` };
await db.bankDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
} else {
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
if (!rec) return { error: "EPF details not found" };
if (rec.verificationStatus !== "PENDING") return { error: `EPF details already ${rec.verificationStatus.toLowerCase()}` };
await db.epfDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
}
await db.crewAction.create({
data: {
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
actorId: g.userId,
crewMemberId,
note: remarks?.trim() || null,
metadata: { record: kind },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${crewMemberId}`);
return { ok: true };
}
// ── PPE / next-of-kin verification (MPO) ───────────────────────────────────────
async function verifyRecord(
load: () => Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>,
set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise<unknown>,
recordLabel: string,
approve: boolean,
remarks: string | undefined,
userId: string
): Promise<ActionResult> {
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
const rec = await load();
if (!rec) return { error: "Record not found" };
if (rec.verificationStatus !== "PENDING") return { error: `This record is already ${rec.verificationStatus.toLowerCase()}` };
await set(approve ? "VERIFIED" : "REJECTED", userId);
await db.crewAction.create({
data: { actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED", actorId: userId, crewMemberId: rec.crewMemberId, note: remarks?.trim() || null, metadata: { record: recordLabel } },
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${rec.crewMemberId}`);
return { ok: true };
}
export async function verifyPpe(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
return verifyRecord(
() => db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
(status, userId) => db.ppeIssue.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
"ppe",
approve,
remarks,
g.userId
);
}
export async function verifyNextOfKin(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
const g = await guard("verify_site_records");
if ("error" in g) return g;
return verifyRecord(
() => db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
(status, userId) => db.nextOfKin.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
"next_of_kin",
approve,
remarks,
g.userId
);
}
// ── EPFO assisted lookup (Accounts) ────────────────────────────────────────────
// Records the result of an EpfoService UAN check on the crew member's EpfDetail
// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo;
// this just persists the returned member name + a timestamp for the audit trail.
export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise<ActionResult> {
const g = await guard("verify_bank_epf");
if ("error" in g) return g;
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } });
if (!rec) return { error: "EPF details not found" };
await db.epfDetail.update({
where: { crewMemberId },
data: { epfoMemberName: memberName, epfoCheckedAt: new Date() },
});
await db.crewAction.create({
data: {
actionType: "RECORD_UPDATED",
actorId: g.userId,
crewMemberId,
note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match",
metadata: { record: "epfo_check" },
},
});
revalidatePath(PATH);
revalidatePath(`/crewing/crew/${crewMemberId}`);
return { ok: true };
}

View file

@ -1,82 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { CREWING_ENABLED } from "@/lib/feature-flags";
import { redirect, notFound } from "next/navigation";
import { VerificationManager } from "./verification-manager";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Verification" };
export default async function VerificationPage() {
if (!CREWING_ENABLED) notFound();
const session = await auth();
if (!session?.user) redirect("/login");
const role = session.user.role;
const canDocs = hasPermission(role, "verify_site_records");
const canBankEpf = hasPermission(role, "verify_bank_epf");
const canAppraisals = hasPermission(role, "verify_appraisal");
if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard");
const [docs, bank, epf, appraisals, ppe, nok] = await Promise.all([
canDocs
? db.seafarerDocument.findMany({
where: { verificationStatus: "PENDING" },
orderBy: { createdAt: "asc" },
include: {
crewMember: {
select: {
name: true,
assignments: { where: { status: { not: "SIGNED_OFF" } }, take: 1, include: { vessel: { select: { name: true } }, site: { select: { name: true } } } },
},
},
},
})
: [],
canBankEpf
? db.bankDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
canBankEpf
? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
canAppraisals
? db.appraisal.findMany({
where: { status: "SUBMITTED" },
orderBy: { createdAt: "asc" },
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
})
: [],
canDocs
? db.ppeIssue.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { issuedDate: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
canDocs
? db.nextOfKin.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
: [],
]);
return (
<VerificationManager
docs={docs.map((d) => {
const a = d.crewMember.assignments[0];
return {
id: d.id,
crewName: d.crewMember.name,
location: a?.vessel?.name ?? a?.site?.name ?? "—",
docType: d.docType,
number: d.number,
expiryDate: d.expiryDate?.toISOString() ?? null,
submitted: d.createdAt.toISOString(),
};
})}
bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))}
epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))}
appraisals={appraisals.map((a) => ({ id: a.id, crewName: a.assignment.crewMember.name, rank: a.assignment.rank.name, period: a.period, comments: a.comments }))}
ppe={ppe.map((p) => ({ id: p.id, crewName: p.crewMember.name, item: p.item, size: p.size }))}
nok={nok.map((n) => ({ id: n.id, crewName: n.crewMember.name, name: n.name, relationship: n.relationship }))}
canDocs={canDocs}
canBankEpf={canBankEpf}
canAppraisals={canAppraisals}
/>
);
}

View file

@ -1,283 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { SeafarerDocType } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
import { verifyAppraisal } from "../appraisals/actions";
import type { PpeItem } from "@prisma/client";
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Date());
type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string };
type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null };
type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null };
type Appr = { id: string; crewName: string; rank: string; period: string; comments: string | null };
type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null };
type Nok = { id: string; crewName: string; name: string; relationship: string | null };
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
async function verify() {
setPending(true); setError("");
const res = await onVerify();
setPending(false);
if ("error" in res) setError(res.error); else router.refresh();
}
async function reject(e: React.FormEvent) {
e.preventDefault();
setPending(true); setError("");
const res = await onReject(reason);
setPending(false);
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
}
return (
<div className="text-right">
<div className="flex justify-end gap-2">
<button onClick={verify} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Verify</button>
<button onClick={() => setOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Reject</button>
</div>
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
<AdminDialog title="Reject record" open={open} onClose={() => setOpen(false)}>
<form onSubmit={reject} className="space-y-4 text-left">
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for rejection" />
<div className="flex justify-end gap-3">
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Reject</button>
</div>
</form>
</AdminDialog>
</div>
);
}
// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo,
// then record the returned member name onto the EpfDetail (A3). Aadhaar is not
// checked here (UIDAI-restricted — stays manual).
function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<"start" | "otp" | "result">("start");
const [sessionId, setSessionId] = useState("");
const [mobileHint, setMobileHint] = useState("");
const [otp, setOtp] = useState("");
const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
if (!uan) return null;
function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); }
async function requestOtp() {
setPending(true); setError("");
try {
const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Failed to request OTP");
setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp");
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
setPending(false);
}
async function verify() {
setPending(true); setError("");
try {
const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "Lookup failed");
setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result");
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
setPending(false);
}
async function record() {
setPending(true);
await recordEpfoCheck(crewMemberId, result?.name ?? null);
setPending(false); setOpen(false); reset(); router.refresh();
}
return (
<>
<button onClick={() => { reset(); setOpen(true); }} className="rounded-md border border-primary-300 px-3 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50">EPFO check</button>
<AdminDialog title="EPFO / UAN check" open={open} onClose={() => setOpen(false)}>
<div className="space-y-4 text-left">
<p className="text-sm text-neutral-600">Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. <span className="text-neutral-400">(Aadhaar is verified manually not via this check.)</span></p>
<p className="text-xs text-neutral-500">UAN: <span className="font-mono">{uan}</span></p>
{step === "start" && (
<button onClick={requestOtp} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Requesting…" : "Request OTP"}</button>
)}
{step === "otp" && (
<div className="space-y-2">
<p className="text-xs text-neutral-500">OTP sent to {mobileHint || "the registered mobile"}.</p>
<input className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" placeholder="Enter OTP" value={otp} onChange={(e) => setOtp(e.target.value)} />
<button onClick={verify} disabled={pending || !otp} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Checking…" : "Submit OTP"}</button>
</div>
)}
{step === "result" && (
<div className="space-y-2">
{result?.matched ? (
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Matched EPFO member: <strong>{result.name}</strong></p>
) : (
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">No matching EPFO member for this UAN.</p>
)}
<button onClick={record} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Recording…" : "Record result"}</button>
</div>
)}
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
</div>
</AdminDialog>
</>
);
}
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
return (
<div className="mb-8">
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
<p className="text-xs text-neutral-500 mt-0.5 mb-3">{sub}</p>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
{empty ? <p className="px-4 py-10 text-center text-sm text-neutral-400">Nothing awaiting verification.</p> : (
<table className="w-full text-sm">{children}</table>
)}
</div>
</div>
);
}
export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) {
return (
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Verification</h1>
<p className="text-sm text-neutral-500 mt-0.5">Site-entered records awaiting office verification.</p>
</div>
{canDocs && (
<Card title="Documents" sub="Verify or reject crew documents (MPO)." empty={docs.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Vessel / site</th><th className="px-4 py-3">Document</th><th className="px-4 py-3">Expiry</th><th className="px-4 py-3">Submitted</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{docs.map((d) => (
<tr key={d.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{d.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{d.location}</td>
<td className="px-4 py-3 text-neutral-700">{label(d.docType)}{d.number ? ` · ${d.number}` : ""}</td>
<td className="px-4 py-3">{d.expiryDate ? <span className={isExpired(d.expiryDate) ? "text-danger-700 font-medium" : "text-neutral-600"}>{fmt(d.expiryDate)}{isExpired(d.expiryDate) ? " · expired" : ""}</span> : "—"}</td>
<td className="px-4 py-3 text-neutral-500">{fmt(d.submitted)}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyDocument(d.id, true)} onReject={(r) => verifyDocument(d.id, false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canDocs && (
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{ppe.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canDocs && (
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{nok.map((r) => (
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canBankEpf && (
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Account</th><th className="px-4 py-3">IFSC</th><th className="px-4 py-3">Bank</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{bank.map((b) => (
<tr key={b.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{b.crewName}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{b.accountNumber ?? "—"}{b.accountName ? ` (${b.accountName})` : ""}</td>
<td className="px-4 py-3 text-neutral-600">{b.ifsc ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{b.bankName ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(b.crewMemberId, "bank", true)} onReject={(r) => verifyBankEpf(b.crewMemberId, "bank", false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
{canBankEpf && (
<Card title="EPF details" sub="Verify or reject crew EPF / identity details (Accounts)." empty={epf.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">UAN</th><th className="px-4 py-3">Aadhaar</th><th className="px-4 py-3">PF no.</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{epf.map((e) => (
<tr key={e.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{e.crewName}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
<td className="px-4 py-3">
<div className="flex flex-col items-end gap-1.5">
<EpfoAssist crewMemberId={e.crewMemberId} uan={e.uan} />
<Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} />
</div>
</td>
</tr>
))}
</tbody>
</Card>
)}
{canAppraisals && (
<Card title="Appraisals" sub="Verify or reject submitted appraisals (MPO)." empty={appraisals.length === 0}>
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Rank</th><th className="px-4 py-3">Period</th><th className="px-4 py-3">Comments</th><th className="px-4 py-3 w-32"></th>
</tr></thead>
<tbody>
{appraisals.map((a) => (
<tr key={a.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{a.crewName}</td>
<td className="px-4 py-3 text-neutral-600">{a.rank}</td>
<td className="px-4 py-3 text-neutral-700">{a.period}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{a.comments ?? "—"}</td>
<td className="px-4 py-3"><Actions onVerify={() => verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /></td>
</tr>
))}
</tbody>
</Card>
)}
</div>
);
}

View file

@ -3,8 +3,8 @@ import { db } from "@/lib/db";
import { StatCard } from "@/components/dashboard/stat-card";
import { SpendCharts } from "@/components/dashboard/spend-charts";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
import Link from "next/link";
import type { Metadata } from "next";
@ -182,7 +182,7 @@ async function ManagerDashboard() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
</div>
{/* Recent approved POs */}

View file

@ -14,7 +14,6 @@ const STATUSES = [
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
{ value: "CLOSED", label: "Closed" },
{ value: "REJECTED", label: "Rejected" },
{ value: "CANCELLED", label: "Cancelled" },
];
interface Props {

View file

@ -115,10 +115,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</thead>
<tbody className="divide-y divide-neutral-100">
{orders.map((po) => (
<tr
key={po.id}
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
>
<tr key={po.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
{po.poNumber}

View file

@ -41,7 +41,6 @@ export function VendorsTable({
? vendors.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
(v.vendorId && v.vendorId.toLowerCase().includes(q)) ||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
(v.address && v.address.toLowerCase().includes(q))
)
@ -90,7 +89,7 @@ export function VendorsTable({
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, ID, GSTIN or address…"
placeholder="Search by name, GSTIN or address…"
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
{query && (
@ -152,9 +151,6 @@ export function VendorsTable({
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
{vendor.name}
</Link>
{vendor.vendorId && (
<span className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-500">{vendor.vendorId}</span>
)}
{vendor.isVerified && (
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
)}

View file

@ -2,8 +2,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction, canCancel } from "@/lib/po-state-machine";
import { hasPermission } from "@/lib/permissions";
import { canPerformAction } from "@/lib/po-state-machine";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -114,118 +113,3 @@ export async function discardDraftPo(
revalidatePath("/dashboard");
return { ok: true };
}
// ── Cancel a PO ───────────────────────────────────────────────────────────────
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
// explicit whitelists, none of which include CANCELLED).
export async function cancelPo({
poId,
reason,
}: {
poId: string;
reason: string;
}): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "cancel_po")) {
return { error: "You do not have permission to cancel purchase orders." };
}
const trimmed = (reason ?? "").trim();
if (!trimmed) return { error: "A cancellation reason is required." };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
});
if (!po) return { error: "PO not found" };
if (!canCancel(po.status, session.user.role)) {
return {
error: po.status === "CANCELLED"
? "This purchase order is already cancelled."
: "You cannot cancel this purchase order.",
};
}
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "CANCELLED",
cancelledAt: new Date(),
cancellationReason: trimmed,
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
},
});
// Notify the submitter and Accounts (they track spend).
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
const recipients = [po.submitter, ...accounts].filter(
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
);
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
revalidatePath(`/po/${poId}`);
revalidatePath("/dashboard");
revalidatePath("/history");
revalidatePath("/my-orders");
revalidatePath("/payments");
return { ok: true };
}
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
// surfaced on the replacement via the schema self-relation.
export async function supersedePo({
poId,
replacementPoNumber,
}: {
poId: string;
replacementPoNumber: string;
}): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
if (!hasPermission(session.user.role, "cancel_po")) {
return { error: "You do not have permission to link a superseding purchase order." };
}
const num = (replacementPoNumber ?? "").trim();
if (!num) return { error: "Enter the PO number that supersedes this one." };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
select: { id: true, status: true },
});
if (!po) return { error: "PO not found" };
if (po.status !== "CANCELLED") {
return { error: "Only a cancelled purchase order can be superseded." };
}
const replacement = await db.purchaseOrder.findUnique({
where: { poNumber: num },
select: { id: true, poNumber: true },
});
if (!replacement) return { error: `No purchase order found with number "${num}".` };
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
await db.purchaseOrder.update({
where: { id: poId },
data: {
supersededById: replacement.id,
actions: {
create: {
actionType: "SUPERSEDED",
actorId: session.user.id,
note: `Superseded by ${replacement.poNumber}`,
},
},
},
});
revalidatePath(`/po/${poId}`);
revalidatePath(`/po/${replacement.id}`);
return { ok: true };
}

View file

@ -32,8 +32,6 @@ export default async function PoDetailPage({ params }: Props) {
documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
receipt: true,
supersededBy: { select: { id: true, poNumber: true } },
supersedes: { select: { id: true, poNumber: true } },
},
});

View file

@ -18,7 +18,6 @@ const ROLE_LABELS: Record<string, string> = {
SUPERUSER: "SuperUser",
AUDITOR: "Auditor",
ADMIN: "Admin",
SITE_STAFF: "Site Staff",
};
export default async function ProfilePage() {

View file

@ -1,30 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 });
try {
const res = await fetch(`${EPFO_SERVICE}/otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uan: body.uan }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

View file

@ -1,32 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.sessionId || !body.uan || !body.otp) {
return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 });
}
try {
const res = await fetch(`${EPFO_SERVICE}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

View file

@ -4,9 +4,6 @@ import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size";
import { signatoryLayout } from "@/lib/po-export-layout";
// ── Company fallback constants (used when no company is linked to a PO) ──────
@ -34,15 +31,12 @@ function mimeForKey(key: string): string {
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
}
interface EmbeddedImage { base64: string; mime: string; width: number; height: number }
// Download a stored image; return base64 + mime + pixel dimensions (or null if missing).
async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage | null> {
// Download a stored image and return it base64-encoded (or null if missing).
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
if (!key) return null;
const buf = await downloadBuffer(key);
if (!buf) return null;
const size = getImageSize(buf) ?? { width: 100, height: 100 };
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
}
// ── Route ─────────────────────────────────────────────────────────────────────
@ -71,11 +65,9 @@ export async function GET(request: NextRequest, { params }: Props) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
// The submitter's signature is never embedded; only the approving manager's signature is used.
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
const isCancelled = po.status === "CANCELLED";
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
if (!EXPORTABLE_STATUSES.includes(po.status)) {
return NextResponse.json(
{ error: "Export is only available for approved purchase orders." },
@ -134,7 +126,6 @@ export async function GET(request: NextRequest, { params }: Props) {
// Fetch approver's signature for embedding in the document
let signatureBase64: string | null = null;
let signatureMime = "image/png";
let signatureSize: { width: number; height: number } | null = null;
if (approvalAction) {
const approver = await db.user.findUnique({
where: { id: approvalAction.actorId },
@ -146,7 +137,6 @@ export async function GET(request: NextRequest, { params }: Props) {
signatureBase64 = buf.toString("base64");
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
signatureSize = getImageSize(buf) ?? { width: 360, height: 96 };
}
}
}
@ -285,15 +275,15 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.mergeCells("A4:I4");
ws.getRow(4).border = { top: thin(), bottom: thin() };
// ══ Company logo (floats top-left over the header; aspect preserved) ═════
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
if (logoImg) {
const logoId = wb.addImage({
base64: logoImg.base64,
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(logoId, {
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
ext: scaleToBox(logoImg, 96, 52),
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}
@ -460,47 +450,16 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.getRow(SIG_ROW + 1).height = 14;
ws.getRow(SIG_ROW + 2).height = 14;
// Left signatory block (cols A-D). Position images by absolute pixels via native
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels.
const EMU = 9525; // EMU per pixel
const COL_PX = [22, 4, 28, 15, 8, 15, 15, 8, 16].map((w) => Math.round(w * 7 + 5));
const SIG_BLOCK_PX = COL_PX[0] + COL_PX[1] + COL_PX[2] + COL_PX[3]; // A-D
const anchorAt = (leftPx: number, row: number) => {
let x = 0;
for (let c = 0; c < COL_PX.length - 1; c++) {
if (leftPx < x + COL_PX[c]) {
return { nativeCol: c, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
}
x += COL_PX[c];
}
return { nativeCol: COL_PX.length - 1, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
};
const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null;
const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null;
// Signature centred over the name; stamp to its RIGHT with a gap (no overlap).
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt });
// Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch.
if (stampImg && stampExt && stampLeft != null) {
const stampId = wb.addImage({
base64: stampImg.base64,
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(stampId, {
tl: anchorAt(stampLeft, SIG_ROW - 1),
ext: stampExt,
editAs: "oneCell",
});
}
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
if (signatureBase64 && sigExt && sigLeft != null) {
// Left sig block (approver — the manager who authorized the PO)
if (signatureBase64) {
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
// Span the image across columns A-D in the sig row
ws.addImage(imgId, {
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
ext: sigExt,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tl: { col: 0, row: SIG_ROW - 1 } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
br: { col: 4, row: SIG_ROW } as any,
editAs: "oneCell",
});
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
@ -519,6 +478,19 @@ export async function GET(request: NextRequest, { params }: Props) {
ws.getRow(SIG_ROW + 2).height = 14;
ws.getRow(SIG_ROW + 3).height = 14;
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
if (stampImg) {
const stampId = wb.addImage({
base64: stampImg.base64,
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
});
ws.addImage(stampId, {
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
editAs: "oneCell",
});
}
// Right sig block (vendor)
const vName = po.vendor?.name ?? "";
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
@ -536,19 +508,6 @@ export async function GET(request: NextRequest, { params }: Props) {
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
// ══ Cancelled watermark — diagonal "CANCELLED" centred over the sheet ════
// Pixel-sized (aspect preserved) so the text spans the page like the PDF,
// rather than being stretched/squished by a cell-range anchor.
if (isCancelled) {
const wmId = wb.addImage({ base64: CANCELLED_WATERMARK_PNG_BASE64, extension: "png" });
const ext = scaleToBox({ width: CANCELLED_WATERMARK_W, height: CANCELLED_WATERMARK_H }, 880, 720);
ws.addImage(wmId, {
tl: { col: 0.15, row: 5 } as unknown as ExcelJS.Anchor,
ext,
editAs: "oneCell",
});
}
// ── Serialise ─────────────────────────────────────────────────────────
const buf = await wb.xlsx.writeBuffer();
const slug = po.poNumber.replace(/\//g, "-");
@ -706,24 +665,6 @@ export async function GET(request: NextRequest, { params }: Props) {
background: ${BRAND_BAR_COLOR};
}
/* ── Cancelled watermark ── */
.cancelled-watermark {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-35deg);
font-size: 96pt;
font-weight: 800;
letter-spacing: 8px;
color: rgba(200, 0, 0, 0.18);
border: 6px solid rgba(200, 0, 0, 0.18);
padding: 8px 32px;
border-radius: 8px;
white-space: nowrap;
z-index: 9999;
pointer-events: none;
}
@media print {
.no-print { display: none; }
body { margin: 8mm 10mm; }
@ -733,8 +674,6 @@ export async function GET(request: NextRequest, { params }: Props) {
</head>
<body>
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<div class="no-print" style="margin-bottom:8px">
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
🖨 Print / Save as PDF

View file

@ -8,7 +8,7 @@ const PO_STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", CANCELLED: "Cancelled",
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
};
export async function GET(request: NextRequest) {

View file

@ -15,7 +15,6 @@ const ROLE_LABELS: Record<Role, string> = {
SUPERUSER: "SuperUser",
AUDITOR: "Auditor",
ADMIN: "Admin",
SITE_STAFF: "Site Staff",
};
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];

View file

@ -2,7 +2,7 @@
import { usePathname } from "next/navigation";
import Link from "next/link";
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
@ -24,15 +24,6 @@ import {
ShoppingCart,
UserCircle,
ShieldCheck,
Network,
ClipboardList,
UserSearch,
Contact,
CalendarDays,
CalendarCheck,
UserCog,
Gauge,
BadgeCheck,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -76,35 +67,11 @@ const PURCHASING_MGMT: NavItem[] = [
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
// Crewing-Implementation-Spec §7); later phases append Candidates / Crew / Leave
// / Attendance / Verification with their per-role visibility. "Ranks & documents"
// lives under Administration.
const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
? [
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
]
: [];
// ── Administration section ────────────────────────────────────────────────────
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED
? [
{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] },
{ href: "/admin/crew", label: "Crew management", icon: UserCog, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
{ href: "/admin/crew-strength", label: "Crew strength", icon: Gauge, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
]
: []),
];
// Full Administration section (ADMIN only)
@ -123,7 +90,6 @@ export function Sidebar({ userRole }: { userRole: Role }) {
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
return (
@ -149,16 +115,6 @@ export function Sidebar({ userRole }: { userRole: Role }) {
</>
)}
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
{visibleCrewing.length > 0 && (
<>
<SectionHeader label="Crewing" />
{visibleCrewing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Vendors under Administration for MANAGER / ACCOUNTS */}
{!isAdmin && visibleMgrAdmin.length > 0 && (
<>

View file

@ -1,158 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
// ── Cancel PO button + confirmation modal ──────────────────────────────────────
// The manager must type the word "cancel" and provide a reason before the action
// is enabled — a deliberate friction step for an irreversible, terminal action.
export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: string }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [confirmText, setConfirmText] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const confirmed = confirmText.trim().toLowerCase() === "cancel";
const canSubmit = confirmed && reason.trim().length > 0 && !pending;
function close() {
if (pending) return;
setOpen(false);
setReason("");
setConfirmText("");
setError("");
}
async function handleCancel() {
if (!canSubmit) return;
setPending(true);
setError("");
const result = await cancelPo({ poId, reason: reason.trim() });
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setPending(false);
setOpen(false);
router.refresh();
}
}
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-lg bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 transition-colors"
>
Cancel PO
</button>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={close}>
<div
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold text-neutral-900">Cancel {poNumber}?</h2>
<p className="mt-1.5 text-sm text-neutral-600">
This marks the purchase order as <strong>cancelled</strong> and removes its value from
all spend trackers and graphs. This cannot be undone.
</p>
<label className="mt-4 block text-xs font-medium text-neutral-700">
Reason for cancellation <span className="text-danger">*</span>
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
autoFocus
placeholder="e.g. Duplicate order — superseded by a corrected PO"
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
/>
<label className="mt-3 block text-xs font-medium text-neutral-700">
Type <span className="font-mono font-semibold">cancel</span> to confirm
</label>
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="cancel"
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
/>
{error && <p className="mt-3 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={close}
disabled={pending}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
Keep PO
</button>
<button
type="button"
onClick={handleCancel}
disabled={!canSubmit}
className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
>
{pending ? "Cancelling…" : "Cancel this PO"}
</button>
</div>
</div>
</div>
)}
</>
);
}
// ── Supersede: link a cancelled PO to the existing PO that replaces it ──────────
export function SupersedeForm({ poId }: { poId: string }) {
const router = useRouter();
const [value, setValue] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleLink(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!value.trim()) return;
setPending(true);
setError("");
const result = await supersedePo({ poId, replacementPoNumber: value.trim() });
if ("error" in result) {
setError(result.error);
setPending(false);
} else {
setPending(false);
setValue("");
router.refresh();
}
}
return (
<form onSubmit={handleLink} className="mt-2 flex flex-wrap items-start gap-2">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Replacement PO number, e.g. PMS/HNR1/9001/2026-27"
className="min-w-[260px] flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
<button
type="submit"
disabled={pending || !value.trim()}
className="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50"
>
{pending ? "Linking…" : "Link replacement"}
</button>
{error && <p className="w-full text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
</form>
);
}

View file

@ -3,7 +3,6 @@ import { PoStatusBadge } from "@/components/po/po-status-badge";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
@ -41,10 +40,6 @@ type PoWithRelations = {
approvedAt: Date | null;
paidAt: Date | null;
closedAt: Date | null;
cancelledAt?: Date | null;
cancellationReason?: string | null;
supersededBy?: { id: string; poNumber: string } | null;
supersedes?: { id: string; poNumber: string }[];
submitter: { id: string; name: string; email: string };
vessel: { id: string; name: string };
account: { id: string; name: string; code: string };
@ -97,8 +92,6 @@ const ACTION_LABELS: Record<string, string> = {
CLOSED: "Closed",
MANAGER_LINE_EDIT: "Manager amended line items",
PRODUCT_PRICE_UPDATED: "Product prices updated",
CANCELLED: "Cancelled",
SUPERSEDED: "Superseded",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
@ -210,8 +203,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
!readOnly && (
<DiscardDraftButton poId={po.id} />
)}
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
{/* Export buttons — only available once the PO has been approved by a manager */}
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
<a
href={`/api/po/${po.id}/export?format=pdf`}
target="_blank"
@ -227,59 +220,9 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Export XLSX
</a>
</>)}
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
{po.status !== "CANCELLED" &&
["MANAGER", "SUPERUSER"].includes(currentRole) &&
!readOnly && (
<CancelPoButton poId={po.id} poNumber={po.poNumber} />
)}
</div>
</div>
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
{po.status === "CANCELLED" && (
<div className="rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
<p className="text-sm font-semibold text-danger-700">
Cancelled{po.cancelledAt ? ` on ${formatDate(po.cancelledAt)}` : ""}
</p>
{po.cancellationReason && (
<p className="mt-0.5 text-sm text-danger-700">Reason: {po.cancellationReason}</p>
)}
<div className="mt-2 text-sm text-danger-700">
{po.supersededBy ? (
<p>
Superseded by{" "}
<Link href={`/po/${po.supersededBy.id}`} className="font-mono font-medium underline">
{po.supersededBy.poNumber}
</Link>
</p>
) : ["MANAGER", "SUPERUSER"].includes(currentRole) && !readOnly ? (
<div>
<p className="text-danger-700/80">Optionally link the PO that replaces this one:</p>
<SupersedeForm poId={po.id} />
</div>
) : null}
</div>
</div>
)}
{/* Reciprocal "supersedes" link — shown on the replacement PO */}
{po.supersedes && po.supersedes.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3">
<p className="text-sm text-neutral-700">
Supersedes{" "}
{po.supersedes.map((s, i) => (
<span key={s.id}>
{i > 0 && ", "}
<Link href={`/po/${s.id}`} className="font-mono font-medium text-primary-600 underline">
{s.poNumber}
</Link>
</span>
))}
</p>
</div>
)}
{/* Manager note banner */}
{po.managerNote && (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">

View file

@ -1,99 +0,0 @@
import type { ApplicationStage, Role } from "@prisma/client";
// Recruitment pipeline state machine (Crewing-Implementation-Spec §5.1) — mirrors
// po-state-machine / requisition-state-machine. The 7 board stages advance in
// order; ONBOARDED is the terminal system state set at onboarding (Phase 3c);
// REJECTED is an orthogonal branch reachable from any active stage.
//
// Stage advances are modelled here. The within-stage work — recording reference
// checks, capturing bank/EPF, agreeing the salary, recording the interview
// result, requesting a waiver — happens in server actions; this machine governs
// when a candidate may move to the next column and who may move them.
//
// Manager-gated advances (spec §6): SALARY_AGREEMENT → PROPOSED (salary approval)
// and INTERVIEW → SELECTED (final selection) are Manager-only. The interview
// waiver is a separate Manager-approved action (R2), never automatic.
export type ApplicationAction =
| "start_competency" // SHORTLISTED → COMPETENCY_AND_REFERENCES
| "verify_competency" // COMPETENCY_AND_REFERENCES → DOC_VERIFICATION
| "verify_docs" // DOC_VERIFICATION → SALARY_AGREEMENT
| "approve_salary" // SALARY_AGREEMENT → PROPOSED (Manager)
| "propose_accepted" // PROPOSED → INTERVIEW
| "select" // INTERVIEW → SELECTED (Manager)
| "onboard"; // SELECTED → ONBOARDED (Phase 3c)
interface Transition {
to: ApplicationStage;
allowedRoles: Role[];
}
type TransitionMap = Partial<Record<ApplicationAction, Transition>>;
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
const TRANSITIONS: Partial<Record<ApplicationStage, TransitionMap>> = {
SHORTLISTED: {
start_competency: { to: "COMPETENCY_AND_REFERENCES", allowedRoles: SOURCING_ROLES },
},
COMPETENCY_AND_REFERENCES: {
verify_competency: { to: "DOC_VERIFICATION", allowedRoles: SOURCING_ROLES },
},
DOC_VERIFICATION: {
verify_docs: { to: "SALARY_AGREEMENT", allowedRoles: SOURCING_ROLES },
},
SALARY_AGREEMENT: {
// Manager approves the agreed salary structure (spec §6).
approve_salary: { to: "PROPOSED", allowedRoles: MANAGER_ROLES },
},
PROPOSED: {
propose_accepted: { to: "INTERVIEW", allowedRoles: SOURCING_ROLES },
},
INTERVIEW: {
// Final selection is a Manager approval (spec §6). The action enforces that
// the interview was accepted or a Manager-approved waiver is in place (R2).
select: { to: "SELECTED", allowedRoles: MANAGER_ROLES },
},
SELECTED: {
// The onboarding side-effect (Phase 3c) moves SELECTED → ONBOARDED.
onboard: { to: "ONBOARDED", allowedRoles: SOURCING_ROLES },
},
};
// The 7 visible board columns, in order (spec §8.4). ONBOARDED/REJECTED are not
// board columns — they are terminal/branch states.
export const BOARD_STAGES: ApplicationStage[] = [
"SHORTLISTED",
"COMPETENCY_AND_REFERENCES",
"DOC_VERIFICATION",
"SALARY_AGREEMENT",
"PROPOSED",
"INTERVIEW",
"SELECTED",
];
export function getTransition(from: ApplicationStage, action: ApplicationAction): Transition | null {
return TRANSITIONS[from]?.[action] ?? null;
}
export function canPerformAction(from: ApplicationStage, action: ApplicationAction, role: Role): boolean {
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
}
export function getAvailableActions(stage: ApplicationStage, role: Role): ApplicationAction[] {
const map = TRANSITIONS[stage];
if (!map) return [];
return (Object.keys(map) as ApplicationAction[]).filter((a) => canPerformAction(stage, a, role));
}
// ── Rejection (orthogonal) ───────────────────────────────────────────────────
// A candidate may be rejected with remarks from any active stage (not once
// SELECTED/ONBOARDED, and not again if already REJECTED), by MPO or Manager.
export const REJECT_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const TERMINAL: ApplicationStage[] = ["SELECTED", "ONBOARDED", "REJECTED"];
export function canReject(from: ApplicationStage, role: Role): boolean {
return !TERMINAL.includes(from) && REJECT_ROLES.includes(role);
}

View file

@ -1,40 +0,0 @@
import type { AppraisalStatus, Role } from "@prisma/client";
// Appraisal lifecycle (Crewing-Implementation-Spec §5.4) — mirrors the other
// crewing state machines. A PM raises the appraisal directly into SUBMITTED; this
// machine governs the two review advances. Rejection is orthogonal (handled in
// the actions: an MPO or Manager declines → REJECTED with remarks).
export type AppraisalAction = "verify" | "approve";
interface Transition {
to: AppraisalStatus;
allowedRoles: Role[];
}
const VERIFY_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"]; // verify_appraisal
const APPROVE_ROLES: Role[] = ["MANAGER", "SUPERUSER"]; // approve_appraisal
const TRANSITIONS: Partial<Record<AppraisalStatus, Partial<Record<AppraisalAction, Transition>>>> = {
SUBMITTED: {
verify: { to: "MPO_VERIFIED", allowedRoles: VERIFY_ROLES },
},
MPO_VERIFIED: {
approve: { to: "MANAGER_APPROVED", allowedRoles: APPROVE_ROLES },
},
};
export function getTransition(from: AppraisalStatus, action: AppraisalAction): Transition | null {
return TRANSITIONS[from]?.[action] ?? null;
}
export function canPerformAction(from: AppraisalStatus, action: AppraisalAction, role: Role): boolean {
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
}
// A review may be declined while the appraisal is still in flight.
const REJECTABLE_FROM: AppraisalStatus[] = ["SUBMITTED", "MPO_VERIFIED"];
export function canReject(from: AppraisalStatus): boolean {
return REJECTABLE_FROM.includes(from);
}

File diff suppressed because one or more lines are too long

View file

@ -1,37 +0,0 @@
import type { Prisma } from "@prisma/client";
// Promote a crew member to a portal login when their rank grants one (PM /
// Assistant PM / Site In-charge — Rank.grantsLogin, spec §3/§4.1). Called from
// onboarding and direct placement, inside their transaction. Creates a SITE_STAFF
// User with no password (set later via the profile / SSO). No-op when the rank
// doesn't grant a login, the crew member has no email/employee no., or a matching
// user already exists. Returns true when a login was created.
export async function maybeCreateSiteStaffLogin(
tx: Prisma.TransactionClient,
crew: { name: string; email: string | null; employeeId: string | null },
rankId: string,
siteId?: string | null
): Promise<boolean> {
const rank = await tx.rank.findUnique({ where: { id: rankId }, select: { grantsLogin: true } });
if (!rank?.grantsLogin) return false;
if (!crew.email || !crew.employeeId) return false;
const existing = await tx.user.findFirst({
where: { OR: [{ email: crew.email }, { employeeId: crew.employeeId }] },
select: { id: true },
});
if (existing) return false;
await tx.user.create({
data: {
employeeId: crew.employeeId,
email: crew.email,
name: crew.name,
role: "SITE_STAFF",
passwordHash: null,
siteId: siteId ?? null,
},
});
return true;
}

View file

@ -1,48 +0,0 @@
import type { Role, SeafarerDocType } from "@prisma/client";
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
// masked for everyone else. Salary is hidden from site staff (office-only).
export function canViewFullBankEpf(role: Role): boolean {
return role === "ACCOUNTS" || role === "SUPERUSER";
}
// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated
// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents
// (passport, CDC, STCW, COC, medical…) are not number-restricted.
const RESTRICTED_DOC_TYPES = new Set<SeafarerDocType>(["AADHAAR", "PAN"]);
export function canViewSalary(role: Role): boolean {
// Office roles see salary; site staff see status only (§6, R7).
return role !== "SITE_STAFF";
}
// "•••• 4471" — keep only the last `visible` chars; null/short values render "—".
export function maskTail(value: string | null | undefined, visible = 4): string {
if (!value) return "—";
const v = value.trim();
if (v.length <= visible) return "••••";
return `•••• ${v.slice(-visible)}`;
}
// Show the value in full only when allowed, else mask it.
export function bankEpfValue(value: string | null | undefined, role: Role): string {
if (!value) return "—";
return canViewFullBankEpf(role) ? value : maskTail(value);
}
// A seafarer document number, masked for non-privileged roles when the document
// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass
// through unchanged. Preserves the `string | null` contract the profile expects.
export function documentNumberValue(
value: string | null | undefined,
docType: SeafarerDocType,
role: Role
): string | null {
if (!value) return null;
if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) {
return maskTail(value);
}
return value;
}

View file

@ -1,29 +0,0 @@
/**
* Crew employee-number generator. Format: CRW-<id>, e.g. CRW-1000.
*
* Sequential, floored at 1000, scanning existing CrewMember.employeeId values.
* Assigned at onboarding (Phase 3c). Call inside the onboarding transaction to
* minimise the race window (the unique constraint is the backstop).
*/
import { db } from "@/lib/db";
import type { Prisma } from "@prisma/client";
const PREFIX = "CRW-";
const FLOOR = 999; // first generated id is 1000
export async function generateEmployeeId(
client: Prisma.TransactionClient | typeof db = db
): Promise<string> {
const rows = await client.crewMember.findMany({
where: { employeeId: { startsWith: PREFIX } },
select: { employeeId: true },
});
let maxId = FLOOR;
for (const { employeeId } of rows) {
if (!employeeId) continue;
const n = parseInt(employeeId.slice(PREFIX.length), 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
return `${PREFIX}${maxId + 1}`;
}

View file

@ -4,15 +4,7 @@
*
* NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
*
* NEXT_PUBLIC_CREWING_ENABLED=true exposes the Crewing module (crew/ranks/requisitions
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
* and wiki Crewing-Implementation-Spec.
*/
export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";

View file

@ -6,7 +6,6 @@ export const ROLE_PREFIX: Record<string, string> = {
SUPERUSER: "SUP",
AUDITOR: "AUD",
ADMIN: "ADM",
SITE_STAFF: "SIT",
};
/** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */

View file

@ -1,46 +0,0 @@
// Image dimension helpers used to size XLSX floating images by pixels with the
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
// an image to fill a cell range, which distorts logos / signatures / stamps.
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
// PNG — IHDR width/height at byte offsets 16 / 20
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
// JPEG — scan segments for a Start-Of-Frame marker
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
let o = 2;
while (o + 9 < buf.length) {
if (buf[o] !== 0xff) { o++; continue; }
const m = buf[o + 1];
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
}
o += 2 + buf.readUInt16BE(o + 2);
}
}
// WebP — RIFF container, VP8 / VP8L / VP8X
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
const fmt = buf.toString("ascii", 12, 16);
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
if (fmt === "VP8X") {
return {
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
};
}
}
return null;
}
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
export function scaleToBox(
natural: { width: number; height: number },
maxW: number,
maxH: number
): { width: number; height: number } {
const s = Math.min(maxW / natural.width, maxH / natural.height);
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
}

View file

@ -1,54 +0,0 @@
import type { Prisma } from "@prisma/client";
// Leave-clash detection (Crewing-Implementation-Spec §5.3, R6 — Option A).
// Approving a leave is a clash when the remaining ACTIVE same-rank cover on the
// vessel over the leave window would fall BELOW the rank's required strength for
// that vessel (VesselRankRequirement.minStrength, default 1 when unconfigured).
// A clash auto-raises a LEAVE requisition.
interface ClashInput {
assignmentId: string;
rankId: string;
vesselId: string | null;
fromDate: Date;
toDate: Date;
}
export async function leaveCausesClash(
tx: Prisma.TransactionClient,
{ assignmentId, rankId, vesselId, fromDate, toDate }: ClashInput
): Promise<boolean> {
// No vessel cost axis → no rank-cover check.
if (!vesselId) return false;
const requirement = await tx.vesselRankRequirement.findUnique({
where: { vesselId_rankId: { vesselId, rankId } },
select: { minStrength: true },
});
const requiredStrength = requirement?.minStrength ?? 1;
if (requiredStrength <= 0) return false;
// Other not-signed-off same-rank crew on the vessel (excludes the one going on leave).
const others = await tx.crewAssignment.findMany({
where: { rankId, vesselId, status: { not: "SIGNED_OFF" }, id: { not: assignmentId } },
select: { id: true },
});
let remainingCover = 0;
if (others.length > 0) {
const otherIds = others.map((o) => o.id);
const overlapping = await tx.leaveRequest.findMany({
where: {
assignmentId: { in: otherIds },
status: "APPROVED",
fromDate: { lte: toDate },
toDate: { gte: fromDate },
},
select: { assignmentId: true },
});
const out = new Set(overlapping.map((l) => l.assignmentId));
remainingCover = otherIds.filter((id) => !out.has(id)).length;
}
return remainingCover < requiredStrength;
}

View file

@ -3,10 +3,7 @@ import { db } from "@/lib/db";
import type { PurchaseOrder, User } from "@prisma/client";
const isDev = process.env.NODE_ENV === "development";
// Construct the Resend client only when a key is actually present — in dev, CI,
// or any env without RESEND_API_KEY we fall back to console logging (the Resend
// v4 constructor throws on a missing key). `canSend` gates the real send path.
const resend = !isDev && process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null;
const resend = isDev ? null : new Resend(process.env.RESEND_API_KEY);
const FROM = `${process.env.EMAIL_FROM_NAME ?? "PPMS"} <${process.env.EMAIL_FROM ?? "noreply@ppms.pelagiamarine.com"}>`;
const APP_URL = (process.env.NEXTAUTH_URL ?? "https://portal.pelagiamarine.com").replace(/\/$/, "");
@ -15,7 +12,6 @@ export type NotificationEvent =
| "PO_APPROVED"
| "PO_APPROVED_WITH_NOTE"
| "PO_REJECTED"
| "PO_CANCELLED"
| "EDITS_REQUESTED"
| "VENDOR_ID_REQUESTED"
| "VENDOR_ID_PROVIDED"
@ -24,22 +20,6 @@ export type NotificationEvent =
| "RECEIPT_CONFIRMED"
| "PARTIAL_RECEIPT_CONFIRMED";
// Crewing notification events (Crewing-Implementation-Spec §4.5/§11). These are
// not tied to a PurchaseOrder, so they go through notifyCrew() and store a
// Notification row with a null poId. Extended per phase; Phase 2 covers
// requisitions + relief.
export type CrewNotificationEvent =
| "REQUISITION_RAISED"
| "RELIEF_REQUESTED"
| "RELIEF_CONVERTED"
| "CANDIDATE_PROPOSED"
| "SALARY_FOR_APPROVAL"
| "SELECTION_FOR_APPROVAL"
| "WAIVER_REQUESTED"
| "LEAVE_FOR_APPROVAL"
| "APPRAISAL_FOR_VERIFICATION"
| "APPRAISAL_FOR_APPROVAL";
interface NotifyParams {
event: NotificationEvent;
po: PurchaseOrder & { submitter: User };
@ -89,13 +69,13 @@ export async function notify({ event, po, recipients, note }: NotifyParams) {
const link = buildInAppLink(event, po, recipient);
let status = "sent";
if (!resend) {
if (isDev) {
console.log(
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${buildEmailBody(event, po, note)}\n Link: ${APP_URL}${link}\n`
);
} else {
try {
const { error } = await resend.emails.send({
const { error } = await resend!.emails.send({
from: FROM,
to: recipient.email,
subject,
@ -139,9 +119,6 @@ function buildInAppBody(
case "PO_REJECTED":
return `${pn} rejected`;
case "PO_CANCELLED":
return `${pn} has been cancelled`;
case "EDITS_REQUESTED":
return `Edits requested on ${pn}`;
@ -238,7 +215,6 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
PO_APPROVED: `${base} has been approved`,
PO_APPROVED_WITH_NOTE: `${base} has been approved`,
PO_REJECTED: `${base} has been rejected`,
PO_CANCELLED: `${base} has been cancelled`,
EDITS_REQUESTED: `Edits requested on ${base}`,
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
@ -269,8 +245,6 @@ function buildEmailBody(
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
case "PO_REJECTED":
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
case "PO_CANCELLED":
return `Purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">cancelled</span>.${noteHtml}`;
case "EDITS_REQUESTED":
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
case "VENDOR_ID_REQUESTED":
@ -417,106 +391,3 @@ function buildHtml(
</body>
</html>`;
}
// ── Crewing notifications ──────────────────────────────────────────────────────
// A PO-independent path: callers compose the subject/body/link (which embed the
// crewing entity details) and pick recipients. Mirrors notify()'s dev-console /
// Resend / Notification-row behaviour, but writes rows with a null poId.
interface CrewNotifyParams {
event: CrewNotificationEvent;
recipients: User[];
subject: string;
body: string;
link?: string;
}
const CREW_ACTION_LABEL: Record<CrewNotificationEvent, string> = {
REQUISITION_RAISED: "View Requisition",
RELIEF_REQUESTED: "View Requisitions",
RELIEF_CONVERTED: "View Requisition",
CANDIDATE_PROPOSED: "View Candidate",
SALARY_FOR_APPROVAL: "Review Salary",
SELECTION_FOR_APPROVAL: "Review Selection",
WAIVER_REQUESTED: "Review Waiver",
LEAVE_FOR_APPROVAL: "Review Leave",
APPRAISAL_FOR_VERIFICATION: "Verify Appraisal",
APPRAISAL_FOR_APPROVAL: "Review Appraisal",
};
export async function notifyCrew({ event, recipients, subject, body, link }: CrewNotifyParams) {
await Promise.allSettled(
recipients.map(async (recipient) => {
let status = "sent";
if (!resend) {
console.log(
`\n📧 [DEV EMAIL] To: ${recipient.email}\n Subject: ${subject}\n Body: ${body}\n Link: ${APP_URL}${link ?? ""}\n`
);
} else {
try {
const { error } = await resend.emails.send({
from: FROM,
to: recipient.email,
subject,
html: buildCrewHtml(event, recipient, subject, body, link),
});
if (error) status = "failed";
} catch {
status = "failed";
}
}
await db.notification.create({
data: { subject, body, link: link ?? null, status, userId: recipient.id },
});
})
);
}
function buildCrewHtml(
event: CrewNotificationEvent,
recipient: User,
subject: string,
body: string,
link?: string
): string {
const actionUrl = link ? `${APP_URL}${link}` : APP_URL;
const actionLabel = CREW_ACTION_LABEL[event] ?? "Open PPMS";
return `<!DOCTYPE html>
<html>
<head><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,-apple-system,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 16px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;">
<!-- Header -->
<tr><td style="background:#1d4ed8;padding:20px 32px;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">PPMS</span>
<span style="font-size:13px;color:#93c5fd;margin-left:10px;">Crewing</span>
</td></tr>
<!-- Body -->
<tr><td style="padding:32px;">
<p style="margin:0 0 20px;font-size:15px;color:#111827;">Hi ${recipient.name},</p>
<p style="margin:0 0 24px;font-size:15px;color:#374151;line-height:1.6;">${body}</p>
<table cellpadding="0" cellspacing="0">
<tr><td style="background:#2563eb;border-radius:8px;">
<a href="${actionUrl}" style="display:inline-block;padding:12px 24px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${actionLabel} </a>
</td></tr>
</table>
</td></tr>
<!-- Footer -->
<tr><td style="background:#f8fafc;border-top:1px solid #e5e7eb;padding:16px 32px;text-align:center;">
<p style="margin:0;font-size:12px;color:#9ca3af;">${subject}</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}

View file

@ -8,7 +8,6 @@ export type Permission =
| "view_all_pos"
| "approve_po"
| "reject_po"
| "cancel_po"
| "request_edits"
| "request_vendor_id"
| "process_payment"
@ -20,45 +19,9 @@ export type Permission =
| "create_vendor"
| "manage_vessels_accounts"
| "manage_products"
| "manage_sites"
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
| "raise_requisition"
| "request_relief_cover"
| "convert_relief_to_requisition"
| "cancel_requisition"
| "view_requisitions"
| "manage_candidates"
| "record_reference_check"
| "record_interview_result"
| "request_interview_waiver"
| "approve_interview_waiver"
| "approve_salary_structure"
| "select_candidate"
| "onboard_crew"
| "sign_off_crew"
| "view_crew_records"
| "upload_crew_records"
| "issue_ppe"
| "apply_leave"
| "decide_leave"
| "record_attendance"
| "view_attendance"
| "verify_site_records"
| "verify_bank_epf"
| "raise_appraisal"
| "verify_appraisal"
| "approve_appraisal"
| "generate_wage_report"
| "approve_wage_report"
| "view_wage_report"
| "manage_ranks"
// Office/admin crew management — direct placement (no requisition), crew CRUD,
// and per-vessel rank-strength config. Held by Manager + Admin (+ SuperUser).
| "manage_crew";
| "manage_sites";
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a
// crewing-only role and holds no purchasing permissions.
const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
@ -70,7 +33,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_all_pos",
"approve_po",
"reject_po",
"cancel_po",
"request_edits",
"request_vendor_id",
"view_analytics",
@ -91,7 +53,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_all_pos",
"approve_po",
"reject_po",
"cancel_po",
"request_edits",
"request_vendor_id",
"process_payment",
@ -113,117 +74,8 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_products",
"manage_sites",
],
SITE_STAFF: [],
};
// Crewing permissions — a verbatim transcription of the §6 grant matrix in
// wiki Crewing-Implementation-Spec. Gating these is harmless until the screens
// land (the module is behind NEXT_PUBLIC_CREWING_ENABLED). Notes from the spec:
// MPO (MANNING) has NO attendance/leave; decide_leave/approve_* and selection are
// Manager-only; manage_ranks is Manager + Admin (not SuperUser).
const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: [],
SITE_STAFF: [
"request_relief_cover",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"record_attendance",
"view_attendance",
"raise_appraisal",
],
MANNING: [
"raise_requisition",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"request_interview_waiver",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"verify_site_records",
"verify_appraisal",
],
ACCOUNTS: ["view_crew_records", "verify_bank_epf", "view_wage_report"],
MANAGER: [
"raise_requisition",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"approve_interview_waiver",
"approve_salary_structure",
"select_candidate",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"decide_leave",
"view_attendance",
"verify_site_records",
"raise_appraisal",
"verify_appraisal",
"approve_appraisal",
"generate_wage_report",
"approve_wage_report",
"view_wage_report",
"manage_ranks",
"manage_crew",
],
SUPERUSER: [
"raise_requisition",
"request_relief_cover",
"convert_relief_to_requisition",
"cancel_requisition",
"view_requisitions",
"manage_candidates",
"record_reference_check",
"record_interview_result",
"request_interview_waiver",
"approve_interview_waiver",
"approve_salary_structure",
"select_candidate",
"onboard_crew",
"sign_off_crew",
"view_crew_records",
"upload_crew_records",
"issue_ppe",
"apply_leave",
"decide_leave",
"record_attendance",
"view_attendance",
"verify_site_records",
"verify_bank_epf",
"raise_appraisal",
"verify_appraisal",
"approve_appraisal",
"generate_wage_report",
"approve_wage_report",
"view_wage_report",
"manage_crew",
],
AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"],
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks", "manage_crew"],
};
const ROLE_PERMISSIONS: Record<Role, Permission[]> = Object.fromEntries(
(Object.keys(PO_ROLE_PERMISSIONS) as Role[]).map((role) => [
role,
[...PO_ROLE_PERMISSIONS[role], ...CREWING_ROLE_PERMISSIONS[role]],
])
) as Record<Role, Permission[]>;
export function hasPermission(role: Role, permission: Permission): boolean {
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
}

View file

@ -1,32 +0,0 @@
// Geometry for the exported PO's left signatory block (cols A-D).
// The approver signature is centred over the name; the company stamp/seal sits to
// its RIGHT with a gap so it never overlays the signature or name — important
// because uploaded signatures/stamps aren't always transparent PNGs.
export interface Size { width: number; height: number }
export interface SignatoryLayout {
sigLeft: number | null; // px from the block's left edge, or null when no signature
stampLeft: number | null; // px from the block's left edge, or null when no stamp
}
export function signatoryLayout(opts: {
blockPx: number;
sig: Size | null;
stamp: Size | null;
gap?: number;
}): SignatoryLayout {
const gap = opts.gap ?? 10;
const sigLeft = opts.sig ? Math.round((opts.blockPx - opts.sig.width) / 2) : null; // centred
let stampLeft: number | null = null;
if (opts.stamp) {
stampLeft =
sigLeft != null && opts.sig
? Math.min(opts.blockPx - opts.stamp.width, sigLeft + opts.sig.width + gap) // clear of the signature
: opts.blockPx - opts.stamp.width - 6; // no signature → right-align in the block
stampLeft = Math.max(0, stampLeft);
}
return { sigLeft, stampLeft };
}

View file

@ -187,15 +187,3 @@ export function getAvailableActions(status: POStatus, role: Role): POAction[] {
export function requiresNote(from: POStatus, action: POAction): boolean {
return getTransition(from, action)?.requiresNote ?? false;
}
// ── Cancellation ──────────────────────────────────────────────────────────────
// Cancellation is orthogonal to the normal lifecycle: a PO can be cancelled from
// ANY state (except when it is already cancelled), by a MANAGER or SUPERUSER, and
// always requires a reason. It is modelled separately from TRANSITIONS so it does
// not have to be enumerated on every source state.
export const CANCEL_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
export function canCancel(from: POStatus, role: Role): boolean {
return from !== "CANCELLED" && CANCEL_ROLES.includes(role);
}

View file

@ -1,34 +0,0 @@
/**
* Requisition code generator. Format: REQ-<id>, e.g. REQ-9000.
*
* The id is a globally sequential integer floored at 9000 (mirroring the PO
* numbering convention in lib/po-number.ts) so generated codes never collide
* with any future imported/historical numbering. Call inside the same
* transaction that creates the requisition to minimise race windows.
*/
import { db } from "@/lib/db";
import type { Prisma } from "@prisma/client";
const PREFIX = "REQ-";
const FLOOR = 8999; // first generated id is 9000
/** Next sequential requisition id by scanning existing REQ- codes. */
async function nextRequisitionId(client: Prisma.TransactionClient | typeof db): Promise<number> {
const rows = await client.requisition.findMany({ select: { code: true } });
let maxId = FLOOR;
for (const { code } of rows) {
if (!code.startsWith(PREFIX)) continue;
const n = parseInt(code.slice(PREFIX.length), 10);
if (!isNaN(n) && n > maxId) maxId = n;
}
return maxId + 1;
}
/** Generate the next requisition code (e.g. "REQ-9000"). */
export async function generateRequisitionCode(
client: Prisma.TransactionClient | typeof db = db
): Promise<string> {
const id = await nextRequisitionId(client);
return `${PREFIX}${id}`;
}

View file

@ -1,128 +0,0 @@
/**
* Requisition service helpers shared by the crewing server actions and by the
* system auto-raise paths (sign-off / end-of-contract / leave-clash backfill,
* Phase 3/4). Kept out of the "use server" action module so non-action code can
* import the auto-raise helper. See Crewing-Implementation-Spec §5.2/§5.3 (R6).
*/
import { db } from "@/lib/db";
import { generateRequisitionCode } from "@/lib/requisition-number";
import { notifyCrew } from "@/lib/notifier";
import type { Prisma, RequisitionReason, User } from "@prisma/client";
type Tx = Prisma.TransactionClient;
export interface NewRequisitionInput {
rankId: string;
vesselId?: string | null;
siteId?: string | null;
reason: RequisitionReason;
neededBy?: Date | null;
notes?: string | null;
raisedById?: string | null; // null = system-raised
autoRaised?: boolean;
}
type RequisitionWithRefs = Prisma.RequisitionGetPayload<{
include: { rank: true; vessel: true; site: true };
}>;
/**
* Core requisition creator run inside a transaction. Generates the code and
* writes the REQUISITION_RAISED CrewAction. Callers own notification + any
* relief-request linking afterwards.
*/
export async function createRequisitionTx(
tx: Tx,
input: NewRequisitionInput
): Promise<RequisitionWithRefs> {
const code = await generateRequisitionCode(tx);
return tx.requisition.create({
data: {
code,
reason: input.reason,
autoRaised: input.autoRaised ?? false,
neededBy: input.neededBy ?? null,
notes: input.notes ?? null,
rankId: input.rankId,
vesselId: input.vesselId ?? null,
siteId: input.siteId ?? null,
raisedById: input.raisedById ?? null,
actions: {
create: {
actionType: "REQUISITION_RAISED",
actorId: input.raisedById ?? null,
metadata: input.autoRaised ? { auto: true, reason: input.reason } : undefined,
},
},
},
include: { rank: true, vessel: true, site: true },
});
}
/** Human label for a requisition's cost axis (vessel preferred, else site). */
export function requisitionLocationLabel(r: {
vessel: { name: string } | null;
site: { name: string } | null;
}): string {
return r.vessel?.name ?? r.site?.name ?? "—";
}
/** Office recipients (MPO sources recruitment; Manager oversees). */
export function getOfficeRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "MANAGER", "SUPERUSER"] } },
});
}
/** MPO recipients — for "requisition raised → MPO" (spec §11). */
export function getMpoRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANNING", "SUPERUSER"] } },
});
}
/** Manager recipients — for the approval gates (salary / selection / waiver). */
export function getManagerRecipients(): Promise<User[]> {
return db.user.findMany({
where: { isActive: true, role: { in: ["MANAGER", "SUPERUSER"] } },
});
}
/** Notify the office that a requisition was auto-raised. Call AFTER the
* creating transaction commits (notifications are not part of the atomic write). */
export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise<void> {
const recipients = await getOfficeRecipients();
const loc = requisitionLocationLabel(requisition);
await notifyCrew({
event: "REQUISITION_RAISED",
recipients,
subject: `Requisition ${requisition.code} auto-raised`,
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
link: `/crewing/requisitions/${requisition.id}`,
});
}
/**
* System auto-raise: an OPEN requisition with no human actor (autoRaised).
* Sign-off, end-of-contract and the leave-clash detector funnel through here.
* See spec §5.2/§5.3 (R6).
*
* Pass `tx` to create the backfill **atomically inside the caller's transaction**
* (so an approved leave / sign-off can never commit without its backfill) the
* caller then owns the post-commit `notifyAutoRaised`. Called without `tx`, it
* runs its own transaction and notifies itself.
*/
export async function autoRaiseRequisition(
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">,
tx?: Tx
): Promise<RequisitionWithRefs> {
const data = { ...input, raisedById: null, autoRaised: true };
if (tx) {
// Caller's transaction — caller is responsible for notifyAutoRaised after commit.
return createRequisitionTx(tx, data);
}
const requisition = await db.$transaction((t) => createRequisitionTx(t, data));
await notifyAutoRaised(requisition);
return requisition;
}

View file

@ -1,88 +0,0 @@
import type { RequisitionStatus, Role } from "@prisma/client";
// Requisition lifecycle state machine — mirrors the PO state machine
// (lib/po-state-machine.ts) and the reconciled spec (Crewing-Implementation-Spec
// §5.2): OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,
// with CANCELLED reachable from OPEN/SHORTLISTING (Manager).
//
// The intermediate stage advances are driven by the recruitment pipeline that
// lands in Phase 3; they are modelled here now so the transitions, allowed
// roles and audit are settled and testable. Phase 2 wires raise (create OPEN)
// and cancel via server actions; selection is Manager-only (spec §6).
export type RequisitionAction =
| "start_shortlisting"
| "mark_proposing"
| "start_interviewing"
| "mark_selected"
| "mark_filled";
interface Transition {
to: RequisitionStatus;
allowedRoles: Role[];
requiresNote: boolean;
}
type TransitionMap = Partial<Record<RequisitionAction, Transition>>;
// MPO (MANNING) and Manager source recruitment; final selection is Manager-only.
const SOURCING_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
const MANAGER_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
const TRANSITIONS: Partial<Record<RequisitionStatus, TransitionMap>> = {
OPEN: {
start_shortlisting: { to: "SHORTLISTING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
SHORTLISTING: {
mark_proposing: { to: "PROPOSING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
PROPOSING: {
start_interviewing: { to: "INTERVIEWING", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
INTERVIEWING: {
// Final selection of a candidate is a Manager approval (spec §6).
mark_selected: { to: "SELECTED", allowedRoles: MANAGER_ROLES, requiresNote: false },
},
SELECTED: {
// The onboarding side-effect (Phase 3) fills the vacancy.
mark_filled: { to: "FILLED", allowedRoles: SOURCING_ROLES, requiresNote: false },
},
};
export function getTransition(from: RequisitionStatus, action: RequisitionAction): Transition | null {
return TRANSITIONS[from]?.[action] ?? null;
}
export function canPerformAction(
from: RequisitionStatus,
action: RequisitionAction,
role: Role
): boolean {
return getTransition(from, action)?.allowedRoles.includes(role) ?? false;
}
export function getAvailableActions(status: RequisitionStatus, role: Role): RequisitionAction[] {
const map = TRANSITIONS[status];
if (!map) return [];
return (Object.keys(map) as RequisitionAction[]).filter((action) =>
canPerformAction(status, action, role)
);
}
export function requiresNote(from: RequisitionStatus, action: RequisitionAction): boolean {
return getTransition(from, action)?.requiresNote ?? false;
}
// ── Cancellation (orthogonal) ────────────────────────────────────────────────
// A requisition may be withdrawn while it is still early in the pipeline — OPEN
// or SHORTLISTING (spec §5.2) — and a reason is required. WHO may cancel is the
// `cancel_requisition` grant (spec §6: MPO + Manager + SuperUser); the actions
// enforce that permission, and CANCEL_ROLES mirrors it so the state machine and
// the matrix agree. Modelled separately from TRANSITIONS, like PO CANCEL_ROLES.
export const CANCEL_ROLES: Role[] = ["MANNING", "MANAGER", "SUPERUSER"];
export const CANCELLABLE_FROM: RequisitionStatus[] = ["OPEN", "SHORTLISTING"];
export function canCancel(from: RequisitionStatus, role: Role): boolean {
return CANCELLABLE_FROM.includes(from) && CANCEL_ROLES.includes(role);
}

View file

@ -44,15 +44,13 @@ export async function generateDownloadUrl(
}
export function buildStorageKey(
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
// phases — see Crewing-Implementation-Spec §4.5.
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
ownerId: string,
type: "po-document" | "receipt",
poId: string,
fileName: string
): string {
const timestamp = Date.now();
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `${type}/${ownerId}/${timestamp}-${safe}`;
return `${type}/${poId}/${timestamp}-${safe}`;
}
export function buildSignatureKey(userId: string, ext: string): string {

View file

@ -12,30 +12,6 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
);
}
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
export function formatCompactINR(amount: number | string): string {
const n = Number(amount);
if (!Number.isFinite(n)) return "₹0";
const sign = n < 0 ? "-" : "";
const abs = Math.abs(n);
const format = (value: number, suffix: string) => {
const rounded = Math.round(value * 100) / 100;
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
return `${sign}${text}${suffix}`;
};
if (abs >= 1e7) return format(abs / 1e7, " Cr");
if (abs >= 1e5) return format(abs / 1e5, " L");
if (abs >= 1e3) return format(abs / 1e3, " K");
return format(abs, "");
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
@ -75,7 +51,6 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
PAID_DELIVERED: "Paid",
PARTIALLY_CLOSED: "Partially Received",
CLOSED: "Closed",
CANCELLED: "Cancelled",
};
// Statuses a PO can be in once it has received manager approval. A PO keeps its
@ -111,5 +86,4 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
PAID_DELIVERED: "success",
PARTIALLY_CLOSED: "warning",
CLOSED: "secondary",
CANCELLED: "danger",
};

View file

@ -1,12 +0,0 @@
-- Cancel + supersede: a new terminal CANCELLED status, cancel metadata, and a
-- self-referential supersede link (cancelled PO -> the existing PO that replaces it).
ALTER TYPE "POStatus" ADD VALUE 'CANCELLED';
ALTER TYPE "ActionType" ADD VALUE 'CANCELLED';
ALTER TYPE "ActionType" ADD VALUE 'SUPERSEDED';
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancelledAt" TIMESTAMP(3);
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancellationReason" TEXT;
ALTER TABLE "PurchaseOrder" ADD COLUMN "supersededById" TEXT;
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supersededById_fkey"
FOREIGN KEY ("supersededById") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,49 +0,0 @@
-- CreateEnum
CREATE TYPE "RankCategory" AS ENUM ('OPERATIONAL', 'SUPPORT');
-- CreateEnum
CREATE TYPE "SeafarerDocType" AS ENUM ('STCW', 'AADHAAR', 'PAN', 'PASSPORT', 'CDC', 'COC', 'PHOTOGRAPH', 'DRIVING_LICENSE', 'MEDICAL_FITNESS', 'CONTRACT_LETTER');
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'SITE_STAFF';
-- CreateTable
CREATE TABLE "Rank" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"category" "RankCategory" NOT NULL DEFAULT 'OPERATIONAL',
"isSeafarer" BOOLEAN NOT NULL DEFAULT false,
"grantsLogin" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"parentId" TEXT,
CONSTRAINT "Rank_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RankDocRequirement" (
"id" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"docType" "SeafarerDocType" NOT NULL,
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RankDocRequirement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Rank_code_key" ON "Rank"("code");
-- CreateIndex
CREATE UNIQUE INDEX "RankDocRequirement_rankId_docType_key" ON "RankDocRequirement"("rankId", "docType");
-- AddForeignKey
ALTER TABLE "Rank" ADD CONSTRAINT "Rank_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RankDocRequirement" ADD CONSTRAINT "RankDocRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,101 +0,0 @@
-- CreateEnum
CREATE TYPE "RequisitionStatus" AS ENUM ('OPEN', 'SHORTLISTING', 'PROPOSING', 'INTERVIEWING', 'SELECTED', 'FILLED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "RequisitionReason" AS ENUM ('NEW_VACANCY', 'REPLACEMENT', 'LEAVE', 'SIGN_OFF', 'END_OF_CONTRACT', 'OTHER');
-- CreateEnum
CREATE TYPE "ReliefRequestStatus" AS ENUM ('OPEN', 'CONVERTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "CrewActionType" AS ENUM ('REQUISITION_RAISED', 'REQUISITION_ADVANCED', 'REQUISITION_FILLED', 'REQUISITION_CANCELLED', 'RELIEF_REQUESTED', 'RELIEF_CONVERTED', 'RELIEF_CANCELLED');
-- CreateTable
CREATE TABLE "Requisition" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"status" "RequisitionStatus" NOT NULL DEFAULT 'OPEN',
"reason" "RequisitionReason" NOT NULL DEFAULT 'NEW_VACANCY',
"autoRaised" BOOLEAN NOT NULL DEFAULT false,
"neededBy" TIMESTAMP(3),
"notes" TEXT,
"cancelledAt" TIMESTAMP(3),
"cancellationReason" TEXT,
"filledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"raisedById" TEXT,
CONSTRAINT "Requisition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReliefRequest" (
"id" TEXT NOT NULL,
"status" "ReliefRequestStatus" NOT NULL DEFAULT 'OPEN',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"requestedById" TEXT NOT NULL,
"convertedRequisitionId" TEXT,
CONSTRAINT "ReliefRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CrewAction" (
"id" TEXT NOT NULL,
"actionType" "CrewActionType" NOT NULL,
"note" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"actorId" TEXT,
"requisitionId" TEXT,
CONSTRAINT "CrewAction_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Requisition_code_key" ON "Requisition"("code");
-- CreateIndex
CREATE UNIQUE INDEX "ReliefRequest_convertedRequisitionId_key" ON "ReliefRequest"("convertedRequisitionId");
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Requisition" ADD CONSTRAINT "Requisition_raisedById_fkey" FOREIGN KEY ("raisedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReliefRequest" ADD CONSTRAINT "ReliefRequest_convertedRequisitionId_fkey" FOREIGN KEY ("convertedRequisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,57 +0,0 @@
-- CreateEnum
CREATE TYPE "CrewStatus" AS ENUM ('PROSPECT', 'CANDIDATE', 'EMPLOYEE', 'EX_HAND', 'BLACKLISTED');
-- CreateEnum
CREATE TYPE "CandidateType" AS ENUM ('NEW', 'EX_HAND');
-- CreateEnum
CREATE TYPE "CandidateSource" AS ENUM ('CAREERS', 'EX_HAND', 'WALK_IN', 'REFERRAL', 'OTHER');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_ADDED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_UPDATED';
-- AlterTable
ALTER TABLE "CrewAction" ADD COLUMN "crewMemberId" TEXT;
-- CreateTable
CREATE TABLE "CrewMember" (
"id" TEXT NOT NULL,
"employeeId" TEXT,
"name" TEXT NOT NULL,
"status" "CrewStatus" NOT NULL DEFAULT 'CANDIDATE',
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
"source" "CandidateSource" NOT NULL DEFAULT 'CAREERS',
"email" TEXT,
"phone" TEXT,
"dob" TIMESTAMP(3),
"experienceMonths" INTEGER NOT NULL DEFAULT 0,
"vesselTypeExperience" TEXT,
"cvKey" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"currentRankId" TEXT,
"appliedRankId" TEXT,
CONSTRAINT "CrewMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewMember_employeeId_key" ON "CrewMember"("employeeId");
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_currentRankId_fkey" FOREIGN KEY ("currentRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewMember" ADD CONSTRAINT "CrewMember_appliedRankId_fkey" FOREIGN KEY ("appliedRankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -1,168 +0,0 @@
-- CreateEnum
CREATE TYPE "ApplicationStage" AS ENUM ('SHORTLISTED', 'COMPETENCY_AND_REFERENCES', 'DOC_VERIFICATION', 'SALARY_AGREEMENT', 'PROPOSED', 'INTERVIEW', 'SELECTED', 'REJECTED', 'ONBOARDED');
-- CreateEnum
CREATE TYPE "ApplicationGateType" AS ENUM ('COMPETENCY_REFERENCE', 'DOCUMENT', 'SALARY', 'INTERVIEW', 'WAIVER', 'SELECTION');
-- CreateEnum
CREATE TYPE "GateResult" AS ENUM ('PENDING', 'VERIFIED', 'REJECTED');
-- CreateEnum
CREATE TYPE "InterviewOutcome" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED');
-- CreateEnum
CREATE TYPE "SalaryRateBasis" AS ENUM ('MONTHLY', 'DAILY');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_CREATED';
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_PASSED';
ALTER TYPE "CrewActionType" ADD VALUE 'GATE_FAILED';
ALTER TYPE "CrewActionType" ADD VALUE 'REFERENCE_RECORDED';
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_AGREED';
ALTER TYPE "CrewActionType" ADD VALUE 'SALARY_APPROVED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_PROPOSED';
ALTER TYPE "CrewActionType" ADD VALUE 'INTERVIEW_RECORDED';
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_REQUESTED';
ALTER TYPE "CrewActionType" ADD VALUE 'WAIVER_APPROVED';
ALTER TYPE "CrewActionType" ADD VALUE 'CANDIDATE_SELECTED';
ALTER TYPE "CrewActionType" ADD VALUE 'APPLICATION_REJECTED';
-- AlterTable
ALTER TABLE "CrewAction" ADD COLUMN "applicationId" TEXT;
-- CreateTable
CREATE TABLE "Application" (
"id" TEXT NOT NULL,
"stage" "ApplicationStage" NOT NULL DEFAULT 'SHORTLISTED',
"type" "CandidateType" NOT NULL DEFAULT 'NEW',
"interviewResult" "InterviewOutcome" NOT NULL DEFAULT 'PENDING',
"interviewWaived" BOOLEAN NOT NULL DEFAULT false,
"rejectedReason" TEXT,
"rejectedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"requisitionId" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
CONSTRAINT "Application_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ApplicationGate" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"gate" "ApplicationGateType" NOT NULL,
"result" "GateResult" NOT NULL DEFAULT 'PENDING',
"note" TEXT,
"decidedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ApplicationGate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReferenceCheck" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"refereeName" TEXT NOT NULL,
"refereeContact" TEXT,
"outcome" TEXT,
"note" TEXT,
"recordedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ReferenceCheck_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SalaryStructure" (
"id" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"rateBasis" "SalaryRateBasis" NOT NULL DEFAULT 'MONTHLY',
"basic" DECIMAL(12,2) NOT NULL,
"victualingPerDay" DECIMAL(12,2) NOT NULL DEFAULT 0,
"allowances" JSONB,
"currency" TEXT NOT NULL DEFAULT 'INR',
"effectiveFrom" TIMESTAMP(3),
"effectiveTo" TIMESTAMP(3),
"approvedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SalaryStructure_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BankDetail" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"accountName" TEXT,
"accountNumber" TEXT,
"ifsc" TEXT,
"bankName" TEXT,
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BankDetail_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EpfDetail" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"uan" TEXT,
"aadhaarLast4" TEXT,
"pfNumber" TEXT,
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EpfDetail_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Application_requisitionId_crewMemberId_key" ON "Application"("requisitionId", "crewMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "ApplicationGate_applicationId_gate_key" ON "ApplicationGate"("applicationId", "gate");
-- CreateIndex
CREATE UNIQUE INDEX "BankDetail_crewMemberId_key" ON "BankDetail"("crewMemberId");
-- CreateIndex
CREATE UNIQUE INDEX "EpfDetail_crewMemberId_key" ON "EpfDetail"("crewMemberId");
-- AddForeignKey
ALTER TABLE "CrewAction" ADD CONSTRAINT "CrewAction_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Application" ADD CONSTRAINT "Application_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Application" ADD CONSTRAINT "Application_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApplicationGate" ADD CONSTRAINT "ApplicationGate_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReferenceCheck" ADD CONSTRAINT "ReferenceCheck_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BankDetail" ADD CONSTRAINT "BankDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EpfDetail" ADD CONSTRAINT "EpfDetail_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,63 +0,0 @@
-- CreateEnum
CREATE TYPE "AssignmentStatus" AS ENUM ('ACTIVE', 'ON_LEAVE', 'SIGNED_OFF');
-- AlterEnum
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_ONBOARDED';
-- AlterTable
ALTER TABLE "SalaryStructure" ADD COLUMN "assignmentId" TEXT;
-- CreateTable
CREATE TABLE "CrewAssignment" (
"id" TEXT NOT NULL,
"status" "AssignmentStatus" NOT NULL DEFAULT 'ACTIVE',
"signOnDate" TIMESTAMP(3) NOT NULL,
"signOffDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"crewMemberId" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"vesselId" TEXT,
"siteId" TEXT,
"requisitionId" TEXT,
CONSTRAINT "CrewAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ContractLetter" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"fileKey" TEXT NOT NULL,
"salaryRestricted" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ContractLetter_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "CrewAssignment_requisitionId_key" ON "CrewAssignment"("requisitionId");
-- CreateIndex
CREATE UNIQUE INDEX "ContractLetter_assignmentId_key" ON "ContractLetter"("assignmentId");
-- AddForeignKey
ALTER TABLE "SalaryStructure" ADD CONSTRAINT "SalaryStructure_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CrewAssignment" ADD CONSTRAINT "CrewAssignment_requisitionId_fkey" FOREIGN KEY ("requisitionId") REFERENCES "Requisition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ContractLetter" ADD CONSTRAINT "ContractLetter_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,93 +0,0 @@
-- CreateEnum
CREATE TYPE "PpeItem" AS ENUM ('BOILER_SUIT', 'SAFETY_SHOES', 'HELMET', 'VEST', 'GLOVES', 'MASK', 'GOGGLES', 'TIFFIN', 'TORCH', 'WALKIE_TALKIE');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'DOCUMENT_UPLOADED';
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_UPDATED';
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_ISSUED';
ALTER TYPE "CrewActionType" ADD VALUE 'PPE_RETURNED';
ALTER TYPE "CrewActionType" ADD VALUE 'EXPERIENCE_ADDED';
-- CreateTable
CREATE TABLE "SeafarerDocument" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"docType" "SeafarerDocType" NOT NULL,
"number" TEXT,
"fileKey" TEXT,
"issueDate" TIMESTAMP(3),
"expiryDate" TIMESTAMP(3),
"verificationStatus" "GateResult" NOT NULL DEFAULT 'PENDING',
"verifiedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SeafarerDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NextOfKin" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"relationship" TEXT,
"phone" TEXT,
"address" TEXT,
"isEmergency" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NextOfKin_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ExperienceRecord" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"vesselType" TEXT,
"rankId" TEXT,
"fromDate" TIMESTAMP(3),
"toDate" TIMESTAMP(3),
"durationMonths" INTEGER,
"source" TEXT NOT NULL DEFAULT 'declared',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ExperienceRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PpeIssue" (
"id" TEXT NOT NULL,
"crewMemberId" TEXT NOT NULL,
"item" "PpeItem" NOT NULL,
"size" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"issuedDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"returnedDate" TIMESTAMP(3),
"issuedById" TEXT,
"comment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PpeIssue_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SeafarerDocument" ADD CONSTRAINT "SeafarerDocument_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NextOfKin" ADD CONSTRAINT "NextOfKin_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ExperienceRecord" ADD CONSTRAINT "ExperienceRecord_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PpeIssue" ADD CONSTRAINT "PpeIssue_crewMemberId_fkey" FOREIGN KEY ("crewMemberId") REFERENCES "CrewMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,61 +0,0 @@
-- CreateEnum
CREATE TYPE "LeaveType" AS ENUM ('ANNUAL', 'MEDICAL', 'EMERGENCY', 'UNPAID', 'OTHER');
-- CreateEnum
CREATE TYPE "LeaveStatus" AS ENUM ('APPLIED', 'APPROVED', 'REJECTED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "AttendanceStatus" AS ENUM ('PRESENT', 'ABSENT', 'HALF_DAY', 'ON_LEAVE', 'SIGN_OFF');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'LEAVE_APPLIED';
ALTER TYPE "CrewActionType" ADD VALUE 'LEAVE_DECIDED';
ALTER TYPE "CrewActionType" ADD VALUE 'ATTENDANCE_RECORDED';
-- CreateTable
CREATE TABLE "LeaveRequest" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"type" "LeaveType" NOT NULL DEFAULT 'ANNUAL',
"fromDate" TIMESTAMP(3) NOT NULL,
"toDate" TIMESTAMP(3) NOT NULL,
"reason" TEXT,
"status" "LeaveStatus" NOT NULL DEFAULT 'APPLIED',
"appliedById" TEXT NOT NULL,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LeaveRequest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Attendance" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"date" DATE NOT NULL,
"status" "AttendanceStatus" NOT NULL,
"note" TEXT,
"recordedById" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Attendance_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Attendance_assignmentId_date_key" ON "Attendance"("assignmentId", "date");
-- AddForeignKey
ALTER TABLE "LeaveRequest" ADD CONSTRAINT "LeaveRequest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Attendance" ADD CONSTRAINT "Attendance_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "CrewAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,20 +0,0 @@
-- CreateTable
CREATE TABLE "VesselRankRequirement" (
"id" TEXT NOT NULL,
"vesselId" TEXT NOT NULL,
"rankId" TEXT NOT NULL,
"minStrength" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "VesselRankRequirement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "VesselRankRequirement_vesselId_rankId_key" ON "VesselRankRequirement"("vesselId", "rankId");
-- AddForeignKey
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_vesselId_fkey" FOREIGN KEY ("vesselId") REFERENCES "Vessel"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VesselRankRequirement" ADD CONSTRAINT "VesselRankRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -1,2 +0,0 @@
-- AlterEnum
ALTER TYPE "CrewActionType" ADD VALUE 'CREW_SIGNED_OFF';

View file

@ -1,10 +0,0 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_VERIFIED';
ALTER TYPE "CrewActionType" ADD VALUE 'RECORD_REJECTED';

Some files were not shown because too many files have changed in this diff Show more