Previously every "Email to vendor" click re-rendered the PO via PdfService and re-uploaded to R2 under a timestamped key — wasteful, and it orphaned a new object each time. Now the PDF is stored at a deterministic per-PO key (buildPoPdfKey → po-pdf/<poId>/<slug>.pdf). On each send, statObject() checks for an existing copy: if it exists and is at least as new as the PO's updatedAt, it's reused (no re-render, no re-upload) and only a fresh presigned URL is minted — refreshing the 7-day download timer. It re-renders only when there's no copy yet or the PO changed since the cached one (so an edited PO never emails a stale PDF). - lib/storage.ts: buildPoPdfKey (deterministic) + statObject (HEAD/stat, no body transfer; null when absent). - email-actions.ts: reuse-or-render decision keyed on updatedAt; always re-presign. - Tests: +2 (reuse-on-second-send-only-refreshes-link, re-render-when-changed). email-vendor suite 8 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
37 KiB
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
Decimalfields cannot be passed directly to Client Components — convert withNumber()in the Server Component before passing as props (seepo-detail.tsx→lineItemsForEditorpattern) - 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 endpointlib/validations/po.ts— Zod schemas for PO forms; exportsTC_FIXED_LINEandTC_DEFAULTSlib/po-state-machine.ts— All valid status transitions with required roleslib/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=<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(nameunique +sortOrder+isActive) andTermsCondition(categoryIdFK +text+isDefault+isActive+sortOrder). Managed at/admin/terms(gated bymanage_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.isDefaultclauses 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").isDefaultis 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 fromgetDefaultPoTerms(); editing a PO loadspo.terms, or (for pre-feature POs)legacyPoTerms()maps the oldtc*columns + fixed lines onto rows. - Storage: the chosen rows are a JSON snapshot on
PurchaseOrder.terms([{ category, text }]). It supersedes the legacytc*columns for the export (route.ts) and PO detail; old POs with nulltermsstill render fromtc*+ the fixed lines.lib/terms.tsparsePoTermsvalidates the JSON;lib/terms-data.tsexposesgetTermsCatalogue/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 anAdminDialogoffering 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 (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).
Advance payment (issue #92): the approving Manager sets how much of the PO is paid first via a 0–100% 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.
Caching: the PDF is stored at a deterministic per-PO key (buildPoPdfKey → po-pdf/<poId>/<slug>.pdf, no timestamp). On each send, statObject(key) checks for an existing copy: if one exists and its lastModified >= po.updatedAt, it's reused (no re-render, no re-upload) and only a fresh presigned URL is minted (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
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.
Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
Spend analytics under a Reports sidebar section (with a "Purchasing" subheading, so other domains can add report groups later). Gated by view_analytics (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an index → drill/detail pair:
- Cost Centres (
/reports/cost-centres) — spend compared across vessels (the PO cost centre). Row →/reports/cost-centres/[id]detail: trend + a Top accounting codes breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N. - Accounting Codes (
/reports/accounting-codes) — drills theAccounttree (headings → sub-headings → leaves) via a?parent=query; leaf rows open/reports/accounting-codes/[id]: trend + breakdown by cost centre (or, for a non-leaf, by sub-account).
Spend definition (lib/reports.ts, the pure/unit-tested core): a PO counts once it reaches POST_APPROVAL_STATUSES, dated by approvedAt, valued at the full totalAmount — the same basis as the dashboard tiles. FY is the Indian Apr–Mar year. getReportDataset() does one query pass; everything else is pure functions over it. allocatePoSpend() splits each PO across the accounting codes its line items carry (line accountId, falling back to the PO-level account), proportionally so the per-PO rows always sum back to totalAmount — so multi-account POs are attributed correctly in the accounting-code report. poCount is distinct POs (a multi-account PO yields several rows). Account spend rolls leaf descendants up via buildAccountIndex().leavesUnder.
Filters live in the URL query so the server component re-renders — no client fetching: gran (weekly / monthly / yearly), fy, month (weekly), scope (Top/Bottom-N), parent (accounting drill), tier / break / topn (detail breakdowns), and sel + cmp (the custom "Add to graph" multi-select — tick rows via the <SelectCheckbox> links, then cmp=1 compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1–W5). The shared <ReportsToolbar> (client) writes the params; charts are recharts (components/reports/charts.tsx) — the comparison chart plots one colour-coded series per item (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → /api/reports/spend?dim=… (CSV mirroring the on-screen view, incl. the custom selection).
Sites are not cost centres (only vessels are).
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 newRoleenum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO isMANNING. - Permissions:
lib/permissions.tsholds the full crewing grant matrix (spec §6) as the source of truth —PO_ROLE_PERMISSIONS+CREWING_ROLE_PERMISSIONSare merged intoROLE_PERMISSIONS. Notable rules: MPO has no attendance/leave;decide_leave/approve_*/select_candidateare Manager-only;manage_ranksis Manager + Admin. - Reference data:
Rankis a self-referential org-chart hierarchy (likeAccount), seeded fromprisma/rank-data.ts;RankDocRequirement(seeded fromprisma/rank-doc-data.ts) lists the documents each rank must hold. Both seed via the sharedprisma/seed-ranks.tsin dev and prod seeds.Rank.grantsLoginis true only for the three management ranks. - Admin screen:
/admin/ranks("Ranks & documents", gated bymanage_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(lifecycleOPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED,→ CANCELLED),ReliefRequest(site-flagged gap the office converts), andCrewAction(the crewing audit trail — thePOActionmirror).Requisition.autoRaisedmarks system-raised vacancies. - State machine:
lib/requisition-state-machine.tsmirrorspo-state-machine.ts(TRANSITIONS,canPerformAction,getAvailableActions; orthogonalCANCEL_ROLES/canCancel). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING bycancel_requisitionholders (MPO + Manager, per §6). Codes (REQ-9000…) come fromlib/requisition-number.ts. - Actions (
app/(portal)/crewing/requisitions/actions.ts):raiseRequisition,cancelRequisition,transitionRequisition,requestReliefCover,convertReliefToRequisition— each guards flag + permission + state, writes aCrewAction, and notifies vianotifyCrew. The sharedautoRaiseRequisition()inlib/requisition-service.tsis 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.tsnotifyCrew()is the PO-independent path (writesNotificationrows with a nullpoId);CrewNotificationEventcoversREQUISITION_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:
CrewMemberis the talent-pool spine — one row per person, created on first contact and kept throughCANDIDATE → EMPLOYEE → EX_HAND(CrewStatus).employeeIdis assigned only at onboarding (3c).CandidateType(NEW/EX_HAND) andCandidateSourcederive from the chosen source;currentRankId(rank held) +appliedRankId(rank applied for).CrewActiongained a nullablecrewMemberId(it now references at most one entity). - Actions (
app/(portal)/crewing/candidates/actions.ts):addCandidate/updateCandidate— guard flag +manage_candidates, write aCrewAction, optional CV upload viabuildStorageKey("cv", …)+uploadBuffer. An EX_HAND source maps totype/status = EX_HAND; an edit never downgrades anEMPLOYEE. - 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-stageApplicationStage(SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED;→ REJECTED;ONBOARDEDis 3c).ApplicationGaterecords each vetting gate —SALARY/SELECTION/WAIVERgates withresult=PENDINGare the Manager's queue items.ReferenceCheck, effective-datedSalaryStructure(attached to the Application in 3b; bound to the assignment in 3c), and minimalBankDetail/EpfDetailcaptured at DOC_VERIFICATION (PII encryption deferred to Phase 4).CrewActiongainedapplicationId. - State machine:
lib/application-pipeline.ts(mirrors po/requisition machines) — sourcing advances are MPO/Manager;approve_salaryandselectare Manager-only;canRejectis orthogonal.BOARD_STAGESis 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):
/approvalsnow 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,AssignmentStatusACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) andContractLetter(salaryRestricted).SalaryStructuregainedassignmentId(bound at onboarding).CrewActionType += CREW_ONBOARDED. Employee numbersCRW-xxxxvialib/employee-number.ts. - Action (
onboardCandidate,onboard_crew): one transaction off aSELECTEDapplication — assignemployeeId, createCrewAssignment(ACTIVE, signOnDate), bind the approvedSalaryStructure(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(PpeItemenum) — all onCrewMember.CrewActionType += DOCUMENT_UPLOADED / RECORD_UPDATED / PPE_ISSUED / PPE_RETURNED / EXPERIENCE_ADDED. (BankDetail/EpfDetailalready 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 byupload_crew_records/issue_ppe, each writes aCrewAction. Document/contract files viabuildStorageKey("crew-document", …). - Screens:
/crewing/crew(directory — activeEMPLOYEEcrew, 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) andAttendance(AttendanceStatus,@@unique([assignmentId, date])) hang offCrewAssignment.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 goesON_LEAVE. Leave approvals also surface in the central/approvalsqueue (§8.13 "Leave" kind, inline Approve/Decline). NotificationLEAVE_FOR_APPROVAL. - Clash auto-backfill (R6, Option A):
VesselRankRequirement{vesselId, rankId, minStrength}configures required crew strength per rank per vessel.lib/leave-clash.tsflags a clash when approving a leave would drop the active same-rank cover over the window belowminStrength(default 1 when unconfigured) → auto-raises aLEAVErequisition via the Phase-2autoRaiseRequisition. 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 overCrewMember(any status), and direct placement —placeCrewassigns a crew member to a vessel/site without a requisition (creates anACTIVECrewAssignment; promotes a candidate toEMPLOYEEwith aCRW-number; blocked if they already have an active assignment). - Crew strength (
/admin/crew-strength): CRUD overVesselRankRequirement(theminStrengththat 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 internalExperienceRecord(rank, on/off dates, computeddurationMonths), flip the sameCrewMemberEMPLOYEE → EX_HAND(so they return to the Candidates pool as a returning hand),CrewAction CREW_SIGNED_OFF; then auto-raise aSIGN_OFFbackfill requisition viaautoRaiseRequisition. (CrewActionType += CREW_SIGNED_OFF.)- Screen: a Sign off button on the crew-profile header (
/crewing/crew/[id],sign_off_crewholders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longerEMPLOYEE). - 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 aSeafarerDocument'sverificationStatus+verifiedById;verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)(verify_bank_epf— Accounts) does the same forBankDetail/EpfDetail. Rejection requires remarks; both write aCrewAction(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
verificationStatuson those models).
Phase 5b — Appraisal (Epic H; spec §5.4/§8.14): completes Phase 5.
- Model:
Appraisal(onCrewAssignment) +AppraisalStatus(DRAFT → SUBMITTED → MPO_VERIFIED → MANAGER_APPROVED;→ REJECTED).ratingsis 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) andapprove(MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject. - Actions (
crewing/appraisals/actions.ts):raiseAppraisal(raise_appraisal— PM/site staff; createsSUBMITTED),verifyAppraisal(verify_appraisal— MPO),approveAppraisal(approve_appraisal— Manager); reject paths require remarks; notificationsAPPRAISAL_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.tsmaybeCreateSiteStaffLogincreates a passwordlessSITE_STAFFUser(sharing theCRW-employee no.) when agrantsLoginrank is onboarded (onboardCandidate) or placed (placeCrew) and the crew member has an email; the login'ssiteIdis set to the assignment's site. - Own-site scoping (§8.7) —
User.siteIdadded; the Crew directory filters aSITE_STAFFuser with a home site to crew whose active assignment is at that site (graceful: nositeId→ unscoped). The link is set at login creation above. - PPE / next-of-kin verify gates —
PpeIssue/NextOfKingainedverificationStatus+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 /otpthenPOST /verify. The app proxies via/api/epfo/otp+/api/epfo(gated byverify_bank_epf), and the EPFO check affordance in the verification queue records the returned member name ontoEpfDetail.epfoMemberName(recordEpfoCheck). The live portal navigation is stubbed behindEPFO_LIVE(deterministic in dev/CI: OTP000000→ matched) until the real selectors/OTP are validated. Aadhaar is intentionally not handled (UIDAI-restricted — stays assisted-manual; onlyaadhaarLast4stored, 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.Ztag. - 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 tracksmaster. Never assume an empty DB — it holds prod-like data.