pelagia-portal/App/CLAUDE.md
Hardik bb5f4126b0
All checks were successful
PR checks / checks (pull_request) Successful in 39s
PR checks / integration (pull_request) Successful in 28s
feat(crewing): admin crew management — direct placement, CRUD, strength config
Office/admin crewing-management surface behind a new manage_crew permission
(Manager + SuperUser + Admin). Stacks on 4b. Behind NEXT_PUBLIC_CREWING_ENABLED.

What's in
- Permission: manage_crew added to the §6 matrix (MGR/SU/ADMIN).
- Direct placement (placeCrew): a Manager assigns a crew member to a vessel/site
  WITHOUT a requisition — creates an ACTIVE CrewAssignment, promotes a candidate to
  EMPLOYEE with a CRW- number (generateEmployeeId), blocked if already actively
  assigned.
- Admin crew CRUD: createCrewMember / updateCrewMember / deleteCrewMember (delete
  blocked when assignments/applications exist).
- Crew strength config: upsert/delete VesselRankRequirement (the minStrength that
  drives R6 leave-clash detection).
- Screens under Administration (flag-gated, MGR/SU/ADMIN): /admin/crew (list + add/
  edit/delete + Place modal) and /admin/crew-strength (requirement table + form).

Tests & docs
- Unit: permissions-crewing.test.ts gains a manage_crew check. Integration:
  crewing-admin.test.ts (9) — CRUD, delete guard, direct placement (+promotion,
  +active-assignment guard), strength upsert/delete, manage_crew gating.
  type-check clean; full unit (241) + integration (192) green.
- CLAUDE.md updated with the crewing-admin surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:31 +05:30

22 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

# Development
pnpm dev                    # Next.js + Turbopack at localhost:3000
pnpm lint                   # ESLint
pnpm type-check             # tsc --noEmit

# Tests
pnpm test                   # Unit tests (Vitest, jsdom)
pnpm test:watch             # Unit tests in watch mode
pnpm test:integration       # Integration tests (Vitest, node + real DB)
pnpm test:e2e               # E2E tests (Playwright, headless)
pnpm test:e2e:ui            # E2E tests with interactive UI
pnpm test:all               # All test suites

# Run a single test file
pnpm test -- tests/unit/po-line-items-editor.test.tsx
pnpm test:integration -- tests/integration/create-po.test.ts

# Database
pnpm db:migrate             # Create + apply migration (dev)
pnpm db:migrate:deploy      # Apply migrations (CI/prod)
pnpm db:seed                # Populate sample data
pnpm db:studio              # Prisma GUI at localhost:5555
pnpm db:reset               # Drop + recreate + seed (dev)

Architecture

Overview

Internal purchase order management system for a maritime company. Full-stack Next.js 15 App Router app with Prisma + PostgreSQL, NextAuth v5 credentials auth, and Tailwind CSS v4.

Key design decisions:

  • Server Components for all data-fetching pages; Client Components only where interactivity is needed
  • Server Actions for all mutations (form submissions, approvals, etc.)
  • Prisma Decimal fields cannot be passed directly to Client Components — convert with Number() in the Server Component before passing as props (see po-detail.tsxlineItemsForEditor pattern)
  • File storage toggles automatically: Cloudflare R2 in production, .dev-uploads/ directory in development
  • Email toggles automatically: Resend in production, console log in development

PO Lifecycle (State Machine)

lib/po-state-machine.ts enforces all status transitions. The canonical flow:

DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
                        ↓↑                              ↕                  ↕
               EDITS_REQUESTED / REJECTED        PARTIALLY_PAID    PARTIALLY_CLOSED
               / VENDOR_ID_PENDING

Partial payments (PARTIALLY_PAID) and partial receipts (PARTIALLY_CLOSED) loop until the full amount/quantity is settled. Imported POs are created directly in CLOSED. Every status change is validated against the state machine and recorded as a POAction row (audit trail).

Role-Based Permissions

lib/permissions.ts defines hasPermission(role, permission) and requirePermission(role, permission). Roles: TECHNICAL, MANNING, ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN. Permissions include (non-exhaustive): create_po, approve_po, process_payment, confirm_receipt, create_vendor, manage_vendors, manage_products, manage_sites, manage_vessels_accounts, manage_users. create_vendor is held by submitters too; manage_* by Manager/Admin.

Pattern: Server Actions call requirePermission() (or hasPermission()) at the top before any DB write.

Auth: NextAuth v5 with a Microsoft Entra SSO provider and a credentials provider. SSO-only users have no passwordHash (it is nullable) — the profile page lets them optionally set one, and is reachable by every role. Only approvers (approve_po) can upload a signature.

Key Directories

  • app/(portal)/ — All authenticated pages (portal layout with sidebar)
  • app/api/po/[id]/export/ — PDF and XLSX export endpoint
  • lib/validations/po.ts — Zod schemas for PO forms; exports TC_FIXED_LINE and TC_DEFAULTS
  • lib/po-state-machine.ts — All valid status transitions with required roles
  • lib/notifier.ts — Email dispatch (Resend in prod, console in dev)
  • lib/storage.ts — File upload/download (R2 in prod, local in dev)
  • components/po/ — PO-specific components (line items editor, status badge, etc.)
  • tests/integration/helpers.tsmakeSession(), makePoForm(), fd() for integration test setup

Cost Centre Model

A PO's cost centre is a Vessel (the Vessel model). PurchaseOrder.vesselId is required. POs no longer reference a Site as a cost centre — that earlier dual Vessel-or-Site design was removed.

Form field: PO create/edit/import forms use a plain vesselId select (no more costCentreRef encoding).

Display pattern: po.vessel?.name ?? "—".

URL pre-select: /po/new?vesselId=<id>.

Terminology: "Vessel" is surfaced as "Cost Centre" everywhere in the UI, including the admin page (/admin/vessels → "Cost Centre Management"). Site still exists as a separate construct (used for vendor-distance and inventory), but is not a PO cost centre. Budget heads are labelled "Accounting Code" (not "Account").

Accounting Code Hierarchy

Account is a self-referential 3-level tree via parentId (AccountHierarchy relation): Top Category (6-digit, e.g. 100000) → Sub-Category (100100) → Leaf Item (100101). Codes are 6-digit numeric strings. Seed data lives in prisma/accounting-codes-data.ts.

  • Only leaf items (accounts with no children) are selectable on a PO.
  • PO forms group leaf codes by their sub-category in a searchable dropdown (components/ui/searchable-select.tsx, a portal-rendered combobox used in the line-items editor and the main accounting-code field).

Companies (multi-company invoicing)

Company represents the sister company a PO is billed under (PurchaseOrder.companyId, optional). Fields: name, code (unique short code, e.g. PMS), gstNumber, address, telephone, mobile, email, invoiceEmail, invoiceAddress. Managed at /admin/companies. The selected company's details populate the exported PO header / invoice block (falling back to hardcoded Pelagia defaults when no company is linked).

PO Numbering (lib/po-number.ts)

Structured format: COMPANY/VESSEL/PO_ID/FY — e.g. PMS/HNR1/9000/2024-25. The financial year is Indian (AprMar) rendered YYYY-YY. System-generated PO_ID starts at 9000 to avoid clashing with historical numbers. Imported POs keep their original PO number verbatim; parsePoNumber() extracts the company/vessel/id parts on import.

Payments

When Accounts records a payment, a compulsory payment date is captured (PurchaseOrder.paymentDate) — the input defaults to today and rejects future dates (validated in processPaymentSchema and markPaid). There is also an editable poDate field; the exported PO "Date" shows poDate ?? approvedAt ?? createdAt (i.e. the approval date once approved, not creation).

Vendors

Vendor carries isVerified, gstin, pincode + latitude/longitude (geocoded for vendor-distance sorting from a Site), and a VendorContact[] list. Submitters can create vendors (permission create_vendor) but they are created unverified; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs verifyVendor. Only manage_vendors holders may assign a vendorId (the formal verified code).

Inventory (feature-flagged)

Inventory (ItemInventory, keyed by productId + siteId) is incremented at PO approval — not on close — for the ordered quantities, when the PO has a siteId. The whole inventory surface (site stock, consumption) is gated by NEXT_PUBLIC_INVENTORY_ENABLED (see lib/feature-flags.ts); the vendor/product catalogue used for PO creation stays available regardless.

Import → Closed

/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), agreeSalaryapproveSalary/returnSalary, recordInterviewResult, requestInterviewWaiverapproveInterviewWaiver/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 placementplaceCrew 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).

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.

Environment Variables

NEXTAUTH_SECRET         # Required always
NEXTAUTH_URL            # Required always (e.g., http://localhost:3000)
DATABASE_URL            # PostgreSQL connection string

AZURE_AD_CLIENT_ID, AZURE_AD_CLIENT_SECRET, AZURE_AD_TENANT_ID
                        # Microsoft Entra SSO (prod). auth.ts reads them at module
                        # load — set placeholders in non-SSO/dev envs so it boots.

# Optional in dev (defaults to local storage + console email):
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL
RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME

# Report Issue button (lib/forgejo.ts); token needs write:issue:
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN

GST_SERVICE_URL         # GstService microservice (defaults to localhost:3003)
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.

Operations & automation

This repo runs a self-hosted issue-to-deploy pipeline on the pms1 server (Forgejo + headless Claude Code). See ../automation/README.md. Relevant when working in this codebase:

  • The Report Issue button (portal header) files a Forgejo issue; a watcher triages it and, for auto-fixable ones, implements a fix and opens a PR. Deploys are gated on a human merging the PR and pushing a vX.Y.Z tag.
  • Automated fixes and the staging instance run against pelagia_test, a daily mirror of the production database, in dev mode (console email, local storage). Migrations are applied to it, so its schema tracks master. Never assume an empty DB — it holds prod-like data.