# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Commands ```bash # 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.tsx` → `lineItemsForEditor` 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.ts` — `makeSession()`, `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=`. **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 (Apr–Mar) 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). ### 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. ### 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.2–8.3):** - **Models:** `Requisition` (lifecycle `OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED`, `→ CANCELLED`), `ReliefRequest` (site-flagged gap the office converts), and `CrewAction` (the crewing audit trail — the `POAction` mirror). `Requisition.autoRaised` marks system-raised vacancies. - **State machine:** `lib/requisition-state-machine.ts` mirrors `po-state-machine.ts` (`TRANSITIONS`, `canPerformAction`, `getAvailableActions`; orthogonal `CANCEL_ROLES`/`canCancel`). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING by `cancel_requisition` holders (MPO + Manager, per §6). Codes (`REQ-9000…`) come from `lib/requisition-number.ts`. - **Actions** (`app/(portal)/crewing/requisitions/actions.ts`): `raiseRequisition`, `cancelRequisition`, `transitionRequisition`, `requestReliefCover`, `convertReliefToRequisition` — each guards flag + permission + state, writes a `CrewAction`, and notifies via `notifyCrew`. The shared `autoRaiseRequisition()` in `lib/requisition-service.ts` is the backfill entry point sign-off / leave-clash (later phases) will call. - **Screens:** `/crewing/requisitions` (list + Raise modal + "Relief requests from sites" convert) and `/crewing/requisitions/[id]` (detail; the recruitment pipeline is a later phase). **Requisitions** is in the flag-gated sidebar **Crewing** section (`CREWING_ITEMS`, Manager + MPO). The Ranks link stays under Administration. - **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`. - **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it. **Phase 3a — Candidates (Epic B; spec §8.6):** Phase 3 (candidate intake + 7-stage pipeline + onboarding) ships as **stacked sub-PRs** — 3a candidates, 3b pipeline, 3c onboarding. - **Model:** `CrewMember` is the talent-pool spine — one row per person, created on first contact and kept through `CANDIDATE → EMPLOYEE → EX_HAND` (`CrewStatus`). `employeeId` is assigned only at onboarding (3c). `CandidateType` (NEW/EX_HAND) and `CandidateSource` derive from the chosen source; `currentRankId` (rank held) + `appliedRankId` (rank applied for). `CrewAction` gained a nullable `crewMemberId` (it now references at most one entity). - **Actions** (`app/(portal)/crewing/candidates/actions.ts`): `addCandidate` / `updateCandidate` — guard flag + `manage_candidates`, write a `CrewAction`, optional CV upload via `buildStorageKey("cv", …)` + `uploadBuffer`. An EX_HAND source maps to `type/status = EX_HAND`; an edit never downgrades an `EMPLOYEE`. - **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO). - **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed. **Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):** - **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`. - **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns. - **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`). - **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail. - **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue. **Phase 3c — Onboarding (Epic D; spec §8.5/§9/§11):** - **Models:** `CrewAssignment` (a tour of duty, `AssignmentStatus` ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) and `ContractLetter` (`salaryRestricted`). `SalaryStructure` gained `assignmentId` (bound at onboarding). `CrewActionType += CREW_ONBOARDED`. Employee numbers `CRW-xxxx` via `lib/employee-number.ts`. - **Action** (`onboardCandidate`, `onboard_crew`): one transaction off a `SELECTED` application — assign `employeeId`, create `CrewAssignment(ACTIVE, signOnDate)`, bind the approved `SalaryStructure` (`assignmentId` + `effectiveFrom`), `Application → ONBOARDED`, `Requisition → FILLED`, `CrewMember → EMPLOYEE` (+ `currentRank`); contract letter stored after. Onboarded crew leave the Candidates pool (the Crew directory is Phase 4). - **Screen:** the SELECTED action card's **Onboard to crew** modal (joining date, contract upload, starts-automatically chips); the assigned `CRW-` number shows on the ONBOARDED card. - **Deferred:** SITE_STAFF **login creation** for management ranks (grantsLogin) is a follow-up; attendance/experience/PPE records (the "starts automatically" chips) begin in Phase 4. **Phase 4a — Crew records & profile + PPE (Epics E + F; spec §8.7–8.8):** Phase 4 (crew records, PPE, leave/attendance + sign-off) ships as **stacked sub-PRs** — 4a records/profile/PPE, 4b leave/attendance, 4c sign-off/experience. - **Models:** `SeafarerDocument`, `NextOfKin` (`isEmergency`), `ExperienceRecord`, `PpeIssue` (`PpeItem` enum) — all on `CrewMember`. `CrewActionType += DOCUMENT_UPLOADED / RECORD_UPDATED / PPE_ISSUED / PPE_RETURNED / EXPERIENCE_ADDED`. (`BankDetail`/`EpfDetail` already exist from 3b.) - **PII masking** (`lib/crew-pii.ts`, spec §6/§8.8): bank account number + Aadhaar are full only for **Accounts/SuperUser**, masked (`•••• 1234`) otherwise; salary hidden from **site staff**. Masking is applied **server-side** before data crosses to the client. - **Actions** (`app/(portal)/crewing/crew/actions.ts`): `uploadDocument`/`deleteDocument`, `saveBankEpf`, `addNextOfKin`/`deleteNextOfKin`, `issuePpe`/`returnPpe`, `addExperience` — guarded by `upload_crew_records` / `issue_ppe`, each writes a `CrewAction`. Document/contract files via `buildStorageKey("crew-document", …)`. - **Screens:** `/crewing/crew` (directory — active `EMPLOYEE` crew, search + vessel filter; ex-hands excluded) and `/crewing/crew/[id]` (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). **Crew** added to the flag-gated nav (MGR/MPO/Site/Accounts). - **Deferred:** site-staff **own-site scoping** (needs a User↔Site link, not modelled — all crew show for now); the records **verify queue** (§8.11, Phase 5); the Pay-status tab shows the salary structure only until wage reports (Phase 6). **Phase 4b — Leave & attendance (Epic G; spec §5.3/§8.9–8.10):** - **Models:** `LeaveRequest` (`LeaveType`, `LeaveStatus`) and `Attendance` (`AttendanceStatus`, `@@unique([assignmentId, date])`) hang off `CrewAssignment`. `CrewActionType += LEAVE_APPLIED / LEAVE_DECIDED / ATTENDANCE_RECORDED`. - **Leave (R1):** **Site staff apply on behalf** (`apply_leave`); the **Manager decides** (`decide_leave`) — the **MPO has no leave role**. On approval the assignment goes `ON_LEAVE`. Leave approvals also surface in the central `/approvals` queue (§8.13 "Leave" kind, inline Approve/Decline). Notification `LEAVE_FOR_APPROVAL`. - **Clash auto-backfill (R6, Option A):** `VesselRankRequirement{vesselId, rankId, minStrength}` configures required crew strength per rank per vessel. `lib/leave-clash.ts` flags a clash when approving a leave would drop the **active same-rank cover over the window below `minStrength`** (default **1** when unconfigured) → auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. The requirement is managed by the office (`manage_crew`). - **Attendance (R5):** daily month calendar, **site staff record** (`record_attendance`), **Manager views** (`view_attendance`) but cannot edit, **MPO has neither**. `saveAttendance(assignmentId, marks)` bulk-upserts the dirty cells. - **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only). - **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred. **Crewing admin (office/admin management):** a new `manage_crew` permission (Manager + SuperUser + Admin) gates a small Administration surface: - **Crew management** (`/admin/crew`): full CRUD over `CrewMember` (any status), and **direct placement** — `placeCrew` assigns a crew member to a vessel/site **without a requisition** (creates an `ACTIVE` `CrewAssignment`; promotes a candidate to `EMPLOYEE` with a `CRW-` number; blocked if they already have an active assignment). - **Crew strength** (`/admin/crew-strength`): CRUD over `VesselRankRequirement` (the `minStrength` that drives R6 leave-clash detection). - Both links sit under **Administration** (flag-gated, Manager/Admin/SuperUser). **Phase 4c — Sign-off & experience (Epic K; spec §5.3):** completes Phase 4 (and the Epic K piece deferred from Phase 2). - **`signOffCrew(assignmentId, date, remarks)`** (`crewing/crew/actions.ts`, `sign_off_crew`): one transaction — assignment → `SIGNED_OFF` (+ `signOffDate`), append an internal `ExperienceRecord` (rank, on/off dates, computed `durationMonths`), flip the **same `CrewMember`** `EMPLOYEE → EX_HAND` (so they return to the Candidates pool as a returning hand), `CrewAction CREW_SIGNED_OFF`; then auto-raise a `SIGN_OFF` backfill requisition via `autoRaiseRequisition`. (`CrewActionType += CREW_SIGNED_OFF`.) - **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`). - This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications). **Phase 5a — Verification (Epic I; spec §8.11/R11):** the office queue for site-entered records (Phase 5 ships as 5a verification → 5b appraisal). - **Actions** (`crewing/verification/actions.ts`): `verifyDocument(id, approve, remarks)` (`verify_site_records` — MPO/Manager) sets a `SeafarerDocument`'s `verificationStatus` + `verifiedById`; `verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)` (`verify_bank_epf` — Accounts) does the same for `BankDetail`/`EpfDetail`. Rejection requires remarks; both write a `CrewAction` (`RECORD_VERIFIED`/`RECORD_REJECTED`). No new models — the verification fields already existed (3b/4a). - **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7). - **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models). **Phase 5b — Appraisal (Epic H; spec §5.4/§8.14):** completes Phase 5. - **Model:** `Appraisal` (on `CrewAssignment`) + `AppraisalStatus` (`DRAFT → SUBMITTED → MPO_VERIFIED → MANAGER_APPROVED`; `→ REJECTED`). `ratings` is a small JSON (competence/conduct/safety). `CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED`. - **State machine** `lib/appraisal-state-machine.ts`: `verify` (SUBMITTED→MPO_VERIFIED, MPO/Manager) and `approve` (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. - **Actions** (`crewing/appraisals/actions.ts`): `raiseAppraisal` (`raise_appraisal` — PM/site staff; creates `SUBMITTED`), `verifyAppraisal` (`verify_appraisal` — MPO), `approveAppraisal` (`approve_appraisal` — Manager); reject paths require remarks; notifications `APPRAISAL_FOR_VERIFICATION` / `APPRAISAL_FOR_APPROVAL`. - **Three surfaces** (§8.14): the PM raises + sees status on the crew profile **Appraisals** tab; the MPO verifies in the **Verification** queue (Appraisals section); the Manager approves in the central **/approvals** queue (Appraisal kind). - This completes **Phase 5** (I + H). Remaining roadmap: **Phase 6** — payroll (Pay-status tab + Approvals "Wage"), dashboards, notifications (J, M). **Crewing follow-ups (resolved deferrals):** the self-contained deferrals from earlier phases are now done: - **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site. - **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above. - **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`. - **EPFO / UAN assisted verification (A3):** `EpfoService/` is a standalone Express + Playwright proxy (the **GstService pattern**) that does an OTP-handshake UAN lookup against the EPFO member portal — `POST /otp` then `POST /verify`. The app proxies via `/api/epfo/otp` + `/api/epfo` (gated by `verify_bank_epf`), and the **EPFO check** affordance in the verification queue records the returned member name onto `EpfDetail.epfoMemberName` (`recordEpfoCheck`). The live portal navigation is **stubbed behind `EPFO_LIVE`** (deterministic in dev/CI: OTP `000000` → matched) until the real selectors/OTP are validated. **Aadhaar is intentionally not handled** (UIDAI-restricted — stays assisted-manual; only `aadhaarLast4` stored, masked). - Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll). ### GST Calculation `totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. ### 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_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`](../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.