pelagia-portal/App/CLAUDE.md
Hardik 7d4ad6a9b8
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 32s
feat(po): prompt to save as draft when leaving with unsaved changes
Closes #18. Navigating away from a PO create/edit screen with unsaved
changes could silently lose in-progress work. The forms now track a dirty
flag and guard both navigation paths:

- Hard navigations (refresh / tab close / external link) → the browser's
  native "Leave site?" prompt via beforeunload.
- In-app navigations (sidebar / header / any internal link) → a capture-phase
  click interceptor opens a modal offering Save as draft / Discard changes /
  Stay on page. Save as draft runs the form's existing draft save (which
  redirects to the PO); Discard continues to the intended destination.

The guard (components/po/unsaved-changes-guard.tsx) is reusable and wired into
both new-po-form and edit-po-form. dirty is cleared before a successful submit
so saving never trips the prompt. SPA back-button (popstate) is left to
beforeunload only; the manager inline-edit panel is out of scope (saves in
place, no draft concept).

Tests: 7 new unit cases for the guard (intercept-when-dirty, no-op-when-clean,
external links pass through, Stay/Discard/Save actions, beforeunload arming).
Unit suite 296 green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:37:33 +05:30

34 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).

Delivery Locations (issue #19)

DeliveryLocation (a Company FK + free-text address + isActive) is an admin-managed list that backs the PO Place of Delivery dropdown. Managed at /admin/delivery-locations, gated by the manage_delivery_locations permission (Manager + SuperUser + Admin — explicitly not admin-only, per the issue). The CRUD mirrors /admin/sites (table + Add/Edit dialogs + activate/deactivate + delete).

The three PO forms (new-po-form, edit-po-form, manager-edit-po-form) render a shared <DeliveryLocationField> — a native <select name="placeOfDelivery"> populated from the active locations, each formatted by lib/delivery-location.ts formatDeliveryLocation(company, address)"Company — address". PurchaseOrder.placeOfDelivery stays a free-text snapshot (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).

Terms & Conditions catalogue (issue #11)

Admin-managed T&C with user-defined categories (not a fixed set) feeding a dynamic PO editor.

  • Models: TermsCategory (name unique + sortOrder + isActive) and TermsCondition (categoryId FK + text + isDefault + isActive + sortOrder). Managed at /admin/terms (gated by manage_terms — Manager + SuperUser + Admin). The migration seeds every standard PO T&C line as a clause: the five named slots keep their wording, the previously-fixed boilerplate lines live under a "General" category, and an empty "Others" category is provided. isDefault clauses pre-fill new POs.
  • Admin (/admin/terms): the Add/Edit clause form's category is a combobox — typing a new name creates the category ("add a new category along with the clause"). isDefault is a checkbox.
  • PO editor (components/po/po-terms-editor.tsx, used by all three PO forms): a dynamic list — "+ Add term" appends a row; each row is a category combobox + a clause combobox (both <input list> so you can pick a catalogued value or type a one-off). New POs pre-fill from getDefaultPoTerms(); editing a PO loads po.terms, or (for pre-feature POs) legacyPoTerms() maps the old tc* columns + fixed lines onto rows.
  • Storage: the chosen rows are a JSON snapshot on PurchaseOrder.terms ([{ category, text }]). It supersedes the legacy tc* columns for the export (route.ts) and PO detail; old POs with null terms still render from tc* + the fixed lines. lib/terms.ts parsePoTerms validates the JSON; lib/terms-data.ts exposes getTermsCatalogue / getDefaultPoTerms. No "work order" type — POs only (per the issue's steer).

Unsaved-changes prompt (issue #18)

The PO create (new-po-form) and edit (edit-po-form) screens guard against losing in-progress work. components/po/unsaved-changes-guard.tsx <UnsavedChangesGuard> arms once the form is dirty (any onInput/onChange on the form, plus the React-state editors — line items, terms, files, accounting code) and:

  • Hard navigations (refresh, tab close, external link) → the browser's native "Leave site?" prompt (beforeunload; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
  • In-app navigations (sidebar / header / any internal <a>) → a capture-phase click interceptor opens an AdminDialog offering Save as draft (runs the form's draft save, which redirects to the PO) / Discard changes (navigates to the intended URL) / Stay on page.

dirty is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA back button (popstate) is not intercepted — only beforeunload covers it. The manager inline-edit panel on /approvals/[id] is out of scope (it saves in place via router.refresh() with no draft concept).

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).

Advance payment (issue #92): the approving Manager sets how much of the PO is paid first via a 0100% slider on the approval card (approval-actions.tsx, default 100%). The slider is convenience only — the resolved absolute amount is stored on PurchaseOrder.suggestedAdvancePayment (Decimal(12,2), nullable; null = no explicit advance ⇒ full payment). approvePo() clamps it to [0, totalAmount] and records it on the APPROVED audit row; it is set once at approval and never edited after. Accounts sees it on the payment queue + PO detail, and it prefills the first payment's amount (payment-actions.tsx, only when nothing is paid yet and the advance is a true partial); the balance then runs through the normal PARTIALLY_PAID loop. It does not appear on the exported PO/invoice.

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).

Email PO to vendor (issue #14)

An Email to vendor button on the PO detail (po-detail.tsx, available once the PO is approved — MGR_APPROVED through CLOSED, and again after payment — when the vendor has a primary-contact email) opens an Outlook draft addressed to that contact with a time-limited PDF download link in the body. The user reviews and sends it.

The pipeline (no mailto attachment — mailto: can't carry files): prepareVendorEmail(poId) (po/[id]/email-actions.ts) → renderPoPdf (lib/pdf-service.ts) → PdfService (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing /api/po/[id]/export?format=pdf&pdf=1 page to a real PDF via headless Chromium → uploadBuffer to R2 (po-pdf/…) → generateDownloadUrl (presigned, 7-day TTL) → returns a mailto: with the link. The export route accepts a server-only svc token (PDF_SERVICE_TOKEN) so PdfService can fetch the page without a user session, and pdf=1 drops the on-screen print button + window.print() auto-trigger. Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN — if unset the action returns a friendly "not configured" error. No new DB model/migration.

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.

Product catalogue sync (lib/product-catalog.ts)

syncProductCatalog(poId, lineItems, vendorId, actorId) registers a PO's line items as reusable Products (the /catalogue/items catalogue): a line item with no productId is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; lastPrice/lastVendorId and the per-vendor ProductVendorPrice are upserted. It runs at approval (approvePo) so an approved PO's items are immediately reusable in further POs, and again at full payment (markPaid) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)

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).

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/placementlib/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 gatesPpeIssue / 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.

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)
EPFO_SERVICE_URL        # EpfoService microservice for UAN lookup (defaults to localhost:3004)
PDF_SERVICE_URL         # PdfService microservice for PO→PDF render (defaults to localhost:3005)
PDF_SERVICE_TOKEN       # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
APP_INTERNAL_URL        # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
NEXT_PUBLIC_INVENTORY_ENABLED   # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED  # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED     # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_ENV_LABEL   # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.

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.