docs(wiki): comprehensive project wiki

Hardik 2026-06-19 13:27:31 +05:30
parent 0c934e1c0b
commit e7882f07db
22 changed files with 1817 additions and 0 deletions

162
Architecture.md Normal file

@ -0,0 +1,162 @@
# Architecture
Pelagia Portal is a full-stack **Next.js 15 (App Router)** application with
**Prisma + PostgreSQL**, **NextAuth v5** auth, and **Tailwind CSS v4**. It is an
internal line-of-business app; the stack optimises for developer velocity,
end-to-end type safety, and operational simplicity (minimal infrastructure).
## Technology stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 15 (App Router) | Full-stack React; Server Components cut client JS; built-in API routes |
| Language | TypeScript 5 (strict) | Shared types front-to-back; contract mismatches caught at compile time |
| UI | React 19 | Concurrent rendering, Server Components |
| Components | shadcn/ui + Radix primitives | Accessible, unstyled, copy-owned source (no black-box upgrades) |
| Styling | Tailwind CSS v4 | Utility-first, consistent design tokens |
| ORM | Prisma 5 | Type-safe client; schema-first migrations; Studio for data inspection |
| Database | PostgreSQL 16 | ACID; JSON columns; mature tooling |
| Auth | NextAuth.js v5 | Microsoft Entra SSO **and** credentials provider |
| File storage | Cloudflare R2 (prod) / local FS (dev) | S3-compatible, presigned uploads off the app server; dev avoids paid services |
| Email | Resend + React Email (prod) / console (dev) | Transactional email, React-rendered templates; dev needs no API key |
| Charts | Recharts | Lightweight, composable, works in RSC |
| Validation | Zod | Shared between server actions and client forms |
| Testing | Vitest + Testing Library + Playwright | Fast unit/integration; E2E for critical paths |
| Export | `exceljs` / `xlsx` | XLSX export & Excel PO import parsing |
| CI/CD | Forgejo + Forgejo Actions (self-hosted) | Issue→fix→PR pipeline; tag-triggered deploy |
| Hosting | Self-hosted on `pms1` (Ubuntu), pm2 + native PostgreSQL | Single-VM, full data control |
## High-level system
```
┌─────────────────────────────────────────────┐
│ Browser — React 19 + shadcn/ui + Tailwind │
│ Server Components (read) + Client (forms) │
└───────────────────┬──────────────────────────┘
│ HTTPS
┌───────────────────▼──────────────────────────┐
│ Next.js 15 App Server │
│ App Router pages (RSC) │ Server Actions / │
│ │ Route Handlers │
│ ┌──────────────────────────────────────────┐│
│ │ Business logic: PO state machine, ││
│ │ permission checks, notifier ││
│ └──────────────────────────────────────────┘│
└───────┬───────────────┬───────────────┬───────┘
│ │ │
┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ PostgreSQL │ │ Cloudflare │ │ Resend │
│ (Prisma) │ │ R2 (docs) │ │ (email) │
└──────────────┘ └─────────────┘ └─────────────┘
┌───────▼──────────────────────────────────────┐
│ GstService (Express + Playwright, :3003) │
│ GST portal CAPTCHA / taxpayer lookup proxy │
└───────────────────────────────────────────────┘
```
## Key design decisions
- **Server Components for all data-fetching pages**; Client Components only where
interactivity is needed.
- **Server Actions for all mutations** (create PO, approve, pay, etc.). There are
**no REST endpoints for mutations** — route handlers exist only for auth, file
signing/serving, GST proxying, notifications, search, and exports.
- **Prisma `Decimal` cannot cross into Client Components** — convert with
`Number()` in the Server Component before passing as a prop (see
`po-detail.tsx``lineItemsForEditor`).
- **Storage and email toggle automatically** on `NODE_ENV` (R2/Resend in prod,
local disk/console in dev).
- **One source of truth for state** — every PO status change goes through
`lib/po-state-machine.ts` and is recorded as a `POAction` (audit trail).
## Application structure
```
app/
├── (auth)/login/
├── (portal)/ # authenticated shell
│ ├── layout.tsx # sidebar + header (role-aware)
│ ├── dashboard/
│ ├── my-orders/
│ ├── po/{new,import,[id],[id]/edit,[id]/receipt}/
│ ├── approvals/{,[id]}/
│ ├── payments/{,history}/
│ ├── history/
│ ├── inventory/{items,vendors,cart}/
│ └── admin/{users,companies,accounts,products,sites,vessels,vendors,superuser-requests}/
└── api/
├── auth/[...nextauth]/
├── files/{sign, dev/[...key]}/
├── gst/{, captcha}/
├── notifications/{, read}/
├── po/[id]/export/
├── po/import/
├── products/search/
└── reports/export/
lib/
├── db.ts # Prisma client singleton
├── auth helpers # (auth.ts at App root; NextAuth v5 config)
├── po-state-machine.ts # all valid status transitions + required roles
├── permissions.ts # role → allowed-action map
├── po-number.ts # structured PO number gen/parse
├── po-import-parser.ts # Excel PO parsing
├── notifier.ts # email dispatch (Resend prod / console dev)
├── storage.ts # file upload/download (R2 prod / local dev)
├── upload-files.ts # client-side upload helper
├── attachments.ts # PO document grouping
├── cart.ts # localStorage cart helpers
├── cost-centre-groups.ts # vessel grouping for selects
├── geo.ts # pincode → lat/long, distance
├── id-generators.ts # vendor/product code generation
├── feature-flags.ts # INVENTORY_ENABLED
├── forgejo.ts # Report-Issue → Forgejo API
├── utils.ts # formatCurrency, formatDate, status maps
└── validations/{po.ts,user.ts} # Zod schemas
```
See the [Data Model](Data-Model), [PO Lifecycle](PO-Lifecycle), and
[Roles and Permissions](Roles-and-Permissions) for the core domain logic.
## API surface
All data **mutations** are Server Actions co-located with their page
(`app/(portal)/*/actions.ts`). Route handlers are reserved for:
| Route Handler | Method | Purpose |
|---|---|---|
| `/api/auth/[...nextauth]` | GET/POST | Auth.js session endpoints |
| `/api/files/sign` | POST | Generate R2 presigned upload URL (prod) |
| `/api/files/dev/[...key]` | GET/PUT | Local upload/download (dev only; 404 in prod) |
| `/api/gst` | POST | Proxy GST taxpayer search via GstService |
| `/api/gst/captcha` | GET | Proxy GST portal CAPTCHA image/session |
| `/api/notifications` | GET | Fetch the current user's notifications |
| `/api/notifications/read` | POST | Mark notifications read |
| `/api/po/[id]/export` | GET | Export single PO as PDF/XLSX (gated to MGR_APPROVED+) |
| `/api/po/import` | POST | Parse an uploaded Excel PO (Manager/SuperUser/Admin) |
| `/api/products/search` | GET | Fuzzy product search for the line-item editor |
| `/api/reports/export` | GET | Export PO history as CSV/PDF |
## Auth & authorisation
- **NextAuth v5** with a **Microsoft Entra SSO** provider and a **credentials**
provider. Passwords hashed with bcrypt.
- SSO-only users have **no `passwordHash`** (nullable); the profile page lets
them optionally set one and is reachable by every role.
- Authorisation is centralised in `lib/permissions.ts`
(`hasPermission` / `requirePermission`). **Server Actions call
`requirePermission()` at the top before any DB write**; Server Components gate
whole page segments. Full matrix on [Roles and Permissions](Roles-and-Permissions).
## Development conventions
- **Trunk**: `master`. Work lands via PRs (`feat/`/`fix/`/`chore/`, or
`claude/issue-N` from the [automated pipeline](Issue-to-Deploy-Pipeline)).
Production is whatever `vX.Y.Z` tag is deployed; staging is a deployed instance
of latest `master`, not a branch.
- **Commits**: Conventional Commits (`feat:`, `fix:`, `refactor:`).
- **Migrations**: never edit `schema.prisma` without generating and committing a
migration; migration files are reviewed in PRs.
- **PR policy**: every change goes through a PR; PRs add docs + tests.
- **Secrets**: never committed — server `~/pms/App/.env`, local `.env.local`.

84
Changelog.md Normal file

@ -0,0 +1,84 @@
# Changelog
Mirrors `CHANGELOG.md` at the repo root (the authoritative copy). Releases are
tagged `vX.Y.Z`; the deployed production version is whichever tag is currently
checked out in `~/pms`. See [Deployment and Operations](Deployment-and-Operations).
## [Unreleased]
### Added
- **Companies (multi-company invoicing)**`Company` model and
`/admin/companies` CRUD. A PO is billed under a selected company (name, short
`code`, GST number, address, phone/mobile, contact + invoice email, invoice
address); details populate the exported PO header / invoice block. See
[Purchase Orders](Purchase-Orders#companies-multi-company-invoicing).
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY`; Indian
financial year; system IDs start at 9000; imported POs keep their original
number. See [Purchase Orders](Purchase-Orders#po-numbering).
- **3-level accounting-code hierarchy**`Account.parentId` self-relation
(Top → Sub → Leaf), 6-digit codes seeded from
`prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a
searchable combobox.
- **Compulsory payment date**`PurchaseOrder.paymentDate` captured at payment;
defaults to today, rejects future dates. Backfilled for existing POs.
- **Editable PO date (`poDate`)** — exported "Date" shows
`poDate ?? approvedAt ?? createdAt`.
- **Submitter vendor creation**`create_vendor` lets Technical/Manning add
vendors; created **unverified**, verified when a PO closes/pays with them, on
import, or via Manager/Accounts/Admin. See [Vendors](Vendors-and-GST-Lookup).
- **Import PO → Closed**`/po/import` saves a parsed Excel PO directly as
`CLOSED`, auto-detecting company, matching vessel, auto-creating vendor,
products, and per-vendor prices.
- **Inventory feature flag** (`NEXT_PUBLIC_INVENTORY_ENABLED`) — site
stock/consumption gated; PO catalogue stays available. Inventory increments at
**PO approval**. See [Inventory and Catalogue](Inventory-and-Catalogue).
- **Dashboards** — Accounts gains a "Payments Completed This Month" card.
- **Automated issue-to-deploy pipeline** — Report Issue button → Forgejo issue →
Claude watcher triage/fix → PR → tag-triggered deploy. See
[Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline).
### Changed
- **Cost centre is now a Vessel only.** The Vessel-or-Site cost-centre model was
removed: `PurchaseOrder.vesselId` is required, `costCentreRef` is gone, and
`Vessel` no longer links to a `Site`. Vessels are surfaced as **"Cost Centre"**
(`/admin/vessels` → "Cost Centre Management"). See [Glossary](Glossary).
- **Closed PO list** — submitters see only their own `CLOSED` POs;
Managers/SuperUsers see all.
- **Sidebar** reorganised into **Purchasing** and **Administration** (role-aware);
"Inventory" renamed to "Purchasing".
- **Items**`/admin/products` is the editable catalogue; `/inventory/items` is
read-only; both link to a shared item detail page.
- **Profile** reachable by every role (incl. SSO-only users, with an email
fallback lookup); only approvers can upload an approval signature.
- **Manager dashboard** "Approved This Month" now counts by `approvedAt` (no
longer undercounts once a PO progresses past `MGR_APPROVED`).
### Fixed
- Production `P2022 … column does not exist` after deploy — caused by shipping
code whose Prisma client expected a column before `migrate deploy` ran.
Migrations must be applied before the new build serves traffic (now in the
README and the [deploy workflow](Deployment-and-Operations#release--deploy-flow)).
---
## Recent fixes (from git history)
A sample of recently merged fixes, many via the automated `claude/issue-N`
pipeline:
- PO details: show all attachments, grouped by type (#27/#10).
- History: allow filtering by **multiple statuses** (#33/#31).
- Approved-this-month counts all POs approved in the period (#34/#32).
- Approved POs show approval date as the PO date (screen + export) (#22/#5).
- Closed-PO list filters corrected for manager and submitter (#21/#6).
- Exported PO includes optional line-item description (#23/#8).
- Allow attachments (incl. delivery receipt) at delivery confirmation (#25/#9);
`Receipt` upserted on repeat confirmations.
- Automation: test-DB mirror + dev-server env for autofix; staging on pms1;
SSH-tunnel lock + dev banner; ported watcher to bash for 24/7 cron.
> Tags so far: `0.1`, `0.1.1`. For the live history, see the repo's commit log
> and Forgejo releases.

189
Data-Model.md Normal file

@ -0,0 +1,189 @@
# Data Model
**Source of truth:** `App/prisma/schema.prisma`. This page mirrors the current
schema (PostgreSQL via Prisma 5). Monetary values are `Decimal`; quantities use
`Decimal(10,3)`; IDs are `cuid()`.
## Enums
```prisma
enum Role { TECHNICAL MANNING ACCOUNTS MANAGER SUPERUSER AUDITOR ADMIN }
enum POStatus {
DRAFT SUBMITTED MGR_REVIEW VENDOR_ID_PENDING EDITS_REQUESTED REJECTED
MGR_APPROVED SENT_FOR_PAYMENT PARTIALLY_PAID PAID_DELIVERED
PARTIALLY_CLOSED CLOSED
}
enum ActionType {
CREATED SUBMITTED APPROVED APPROVED_WITH_NOTE REJECTED EDITS_REQUESTED
VENDOR_ID_REQUESTED VENDOR_ID_PROVIDED PAYMENT_SENT PARTIAL_PAYMENT_CONFIRMED
RECEIPT_CONFIRMED PARTIAL_RECEIPT_CONFIRMED CLOSED REASSIGNED
PRODUCT_PRICE_UPDATED MANAGER_LINE_EDIT
}
enum RequestStatus { PENDING APPROVED DENIED }
```
`POStatus` drives the [PO Lifecycle](PO-Lifecycle); `ActionType` rows form the
per-PO audit trail.
## Entity relationships
```
User ──< PurchaseOrder (submitter) PurchaseOrder >── Vessel (cost centre, REQUIRED)
User ──< POAction (actor) PurchaseOrder >── Account (accounting code, REQUIRED)
User ──< Notification PurchaseOrder >── Company (optional, billing)
User ──< ItemConsumption (recordedBy) PurchaseOrder >── Vendor (optional)
User ──< SuperUserRequest PurchaseOrder >── Site (optional, delivery → inventory)
PurchaseOrder ──< POLineItem POLineItem >── Product (optional)
PurchaseOrder ──< PODocument POLineItem >── Account (optional, per-line)
PurchaseOrder ──< POAction Vendor < VendorContact
PurchaseOrder ──1 Receipt Vendor ──< ProductVendorPrice >── Product
PurchaseOrder ──< Notification Product/Site < ItemInventory (unique product+site)
Product/Site ──< ItemConsumption (unique product+site+date)
Account ──< Account (self: AccountHierarchy, 3 levels)
```
## Core models
### User
`id, employeeId (unique), email (unique), name, passwordHash?, role, isActive,
signatureKey?, createdAt, updatedAt`.
- `passwordHash` is **nullable** → SSO-only users (Microsoft Entra) have no
password; they may set one from their profile.
- `signatureKey` → storage key of an uploaded approval signature (only approvers
upload one; appears on exported docs).
- Relations: submitted POs, actions, notifications, recorded consumption,
super-user requests (as requester and as resolver).
### PurchaseOrder
The central entity. Key fields:
| Field | Notes |
|---|---|
| `poNumber` (unique) | `COMPANY/VESSEL/ID/FY` — see [Purchase Orders](Purchase-Orders) |
| `status` | `POStatus`, default `DRAFT` |
| `totalAmount` | `Decimal(12,2)`, **GST-inclusive** sum of line items |
| `paidAmount?` | accumulates across partial payments |
| `currency` | default `INR` |
| `poDate?` | editable PO date; export "Date" = `poDate ?? approvedAt ?? createdAt` |
| `dateRequired?`, `projectCode?` | |
| `managerNote?`, `paymentRef?`, `paymentDate?` | `paymentDate` compulsory at payment, no future dates |
| `piQuotationNo/Date?`, `requisitionNo/Date?`, `placeOfDelivery?` | quotation/requisition metadata |
| `tcDelivery / tcDispatch / tcInspection / tcTransitInsurance / tcPaymentTerms / tcOthers` | Terms & Conditions text |
| `submittedAt / approvedAt / paidAt / closedAt / createdAt / updatedAt` | lifecycle timestamps |
Required FKs: `submitterId → User`, `vesselId → Vessel` (**cost centre**),
`accountId → Account` (**accounting code**). Optional FKs: `companyId`,
`vendorId`, `siteId`. Cascade-deletes its `lineItems` and `documents`.
### POLineItem
`name, description?, quantity Decimal(10,3), unit, unitPrice Decimal(12,2),
totalPrice Decimal(12,2), gstRate Decimal(5,4) default 0.18, sortOrder, size?,
deliveredQuantity? , productId?, accountId?` + `poId` (cascade).
- `totalPrice = quantity × unitPrice × (1 + gstRate)`; the PO `totalAmount` is
the sum of line `totalPrice`. See [GST](Purchase-Orders#gst-calculation).
- `deliveredQuantity` supports **partial receipt**.
- `accountId` allows a **per-line accounting code** override.
### POAction (audit trail)
`actionType (ActionType), note?, metadata Json?, createdAt` + `poId`, `actorId`.
Every state transition and notable event writes one row. `metadata` is flexible
(payment ref, vendor ID, edit diffs, etc.).
### PODocument / Receipt
- `PODocument`: `fileName, fileSize, mimeType, storageKey, uploadedAt` (cascade
on PO delete). Attachments are grouped by type on the detail page.
- `Receipt`: one per PO (`@unique poId`); `storageKey, fileName, notes?,
confirmedAt`. Upserted on repeat confirmations.
### Notification
`subject, body, link?, isRead (default false), sentAt, status (default "sent")`
+ optional `poId`, `userId`. Backs the in-app notification bell; every email
event is also persisted here. See [Notifications](Notifications).
## Reference / catalogue models
### Vessel — the **Cost Centre**
`id, name, code (unique), isActive`. A PO's cost centre **is** a Vessel
(`PurchaseOrder.vesselId` required). Surfaced as **"Cost Centre"** throughout the
UI (`/admin/vessels` → "Cost Centre Management"). The earlier Vessel-or-Site
cost-centre design and `costCentreRef` encoding were removed; `Vessel` no longer
links to a `Site`.
### Account — the **Accounting Code** (3-level hierarchy)
`code (unique, 6-digit numeric), name, description?, isActive` + self-relation
`parentId`/`children` (`AccountHierarchy`). Three levels:
```
Top Category (100000) → Sub-Category (100100) → Leaf Item (100101)
```
Only **leaf** accounts (no children) are PO-selectable. Seed data:
`prisma/accounting-codes-data.ts`. Line items may carry a per-line `accountId`.
### Company — multi-company invoicing
`name, code? (unique, e.g. PMS), gstNumber?, address?, telephone?, mobile?,
email?, invoiceEmail?, invoiceAddress?, isActive`. The sister company a PO is
billed under (`PurchaseOrder.companyId`, optional); its details populate the
**exported PO header / invoice block** (falling back to Pelagia defaults).
Managed at `/admin/companies`.
### Vendor + VendorContact
`Vendor`: `name, vendorId? (unique formal code), address?, pincode?, gstin?,
latitude?, longitude?, isVerified (default false), isActive`. `VendorContact[]`:
`name, role?, mobile?, email?, isPrimary` (cascade).
- Submitters can **create vendors** (unverified). A vendor becomes verified on a
closing/paying PO, on import, or via Manager/Accounts/Admin.
- `latitude`/`longitude` geocoded from `pincode` for vendor-distance sorting.
- See [Vendors and GST Lookup](Vendors-and-GST-Lookup).
### Product + ProductVendorPrice
`Product`: `code (unique), name, description?, lastPrice?, lastVendorId?,
isActive`. `ProductVendorPrice`: one row per `(productId, vendorId)` with
`price`. On payment confirmation, `lastPrice`/`lastVendorId` and per-vendor
prices are updated. See [Inventory and Catalogue](Inventory-and-Catalogue).
### Site / ItemInventory / ItemConsumption (inventory, feature-flagged)
- `Site`: `name, code (unique), address?, latitude?, longitude?, isActive`.
Ports/depots/offices that hold stock; used for vendor-distance and delivery.
- `ItemInventory`: quantity of a product at a site — unique `(productId, siteId)`.
**Incremented at PO approval** (not on close) when the PO has a `siteId`.
- `ItemConsumption`: daily draw-down — unique `(productId, siteId, date)`, with
`recordedById`.
The whole inventory surface is gated by `NEXT_PUBLIC_INVENTORY_ENABLED`
(`lib/feature-flags.ts`). The vendor/product catalogue used for PO creation stays
available regardless.
### SuperUserRequest
`userId (requester), reason?, status (RequestStatus), createdAt, resolvedAt?,
resolvedById?`. Backs the "request SuperUser access" flow from the profile page,
resolved at `/admin/superuser-requests`.
## Migrations
Migrations live in `prisma/migrations/` and are committed/reviewed in PRs. The
chronology is a useful changelog of schema evolution, e.g.:
- `add_item_db`, `add_product_catalogue_size_manager_edit`
- `add_po_export_fields`, `structured_tc_fields`, `add_line_item_name`
- `add_product_vendor_price`, `add_site_inventory_consumption`
- `vendor_pincode`, `vendor_contacts`
- `partial_receipt`, `partial_payment`
- `user_profile_signature`, `notification_isread_link`
- `optional_password_hash_for_sso`
- `vessel_optional_cost_centre``account_hierarchy`
`vessel_no_site_po_vessel_required` (cost-centre / accounting-code rework)
- `add_company`, `company_invoice_email`, `company_code`
- `po_payment_date`, `add_po_date`
> **Always apply migrations before new code serves traffic.** `pnpm build` runs
> only `prisma generate`; deploying code whose client expects a not-yet-migrated
> column yields `P2022 … column does not exist`. The deploy workflow runs
> `migrate deploy`. See [Deployment and Operations](Deployment-and-Operations).

@ -0,0 +1,110 @@
# Deployment and Operations
The app is **self-hosted on a single Ubuntu server, `pms1`** — not a managed
PaaS. Public traffic reaches it through a **Pangolin/Traefik** tunnel; the
Next.js app, PostgreSQL, the Forgejo instance, and the CI runner all live on the
same host.
```
Internet (HTTPS, pms.pelagiamarine.com)
┌──────────▼───────────┐
│ Pangolin / Traefik │ reverse proxy + tunnel
└──────────┬───────────┘
┌─────────────────────────────────────────────────────────┐
│ pms1 (Ubuntu) │
│ ┌───────────────────────┐ ┌────────────────────────┐ │
│ │ Next.js (pm2: ppms) │──▶│ PostgreSQL 16 (native, │ │
│ │ next start, :3000 │ │ localhost:5432, db │ │
│ └───────────────────────┘ │ `pelagia`) │ │
│ ├─▶ Cloudflare R2 (documents, prod) │
│ └─▶ Resend (email, prod) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Forgejo (Docker) + Actions runner (pm2) │ │
│ │ issue→fix→PR→tag deploy (see pipeline page) │ │
│ │ also: pelagia_test (prod-mirror DB) + staging │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
- **App process**: pm2 process **`ppms`** running `next start` on port **3000**.
- **Database**: native PostgreSQL 16, `localhost:5432`, database `pelagia`.
- **Repo remotes**: `pms1``git.pelagiamarine.com/shad0w/pelagia-portal.git`;
`vgr``git.tunnel.pelagiamarine.com/...` (SSH-tunnel variant).
## Production environment
All production env vars must be set (auth, DB, R2, Resend, optionally
Forgejo/GST). Server-side env lives in `~/pms/App/.env`. The full list and the
dev/prod split is on [Environment Variables](Environment-Variables).
## Release & deploy flow
Deploys are **gated on a human merging a PR and pushing a release tag**.
```bash
git pull
git tag v0.2.0 # semver: patch for fixes, minor for features
git push pms1 master --tags
```
Pushing a `v*` tag triggers `.forgejo/workflows/deploy.yml` on the **`host`**
runner (pms1), which:
1. loads nvm, checks out the tag into `~/pms` (`git checkout -f refs/tags/$TAG`),
2. `cd App && pnpm install --frozen-lockfile`,
3. `pnpm build` (includes `prisma generate`),
4. **`pnpm db:migrate:deploy`** (applies migrations),
5. `pm2 restart ppms --update-env`,
6. verifies `GET http://127.0.0.1:3000/login` returns **HTTP 200**.
Watch progress under **Actions** in Forgejo, or `pm2 logs forgejo-runner`.
> **Migrations before traffic.** `pnpm build` only runs `prisma generate` — it
> does **not** apply migrations. Serving new code whose Prisma client expects a
> not-yet-migrated column yields `P2022 … column does not exist` at runtime. The
> deploy workflow runs `migrate deploy` for you; for manual deploys, run it (and
> restart) before/with the swap. This was a real production incident — see
> [Changelog](Changelog).
## Staging (smoke test before deploy)
`automation/staging-up.sh` brings up a **staging instance of the latest
`master`** so changes can be clicked through before a release tag deploys them.
- Checkout `~/pelagia-staging`; pm2 process **`ppms-staging`** on port **3200**.
- Runs against the **prod-mirror test DB** (`pelagia_test`) in **safe dev mode**
(console email, local storage, SSO disabled).
- **SSH-tunnel only** — binds `127.0.0.1:3200`, not publicly reachable:
`ssh -L 3200:localhost:3200 shad0w@<pms1>` then browse `http://localhost:3200`.
On Windows the **"Pelagia Staging (tunnel)"** desktop shortcut
(`automation/staging-tunnel.cmd`) opens tunnel + browser in one click.
- Shows the **"INTERNAL DEV / STAGING - NOT PRODUCTION"** banner via
`NEXT_PUBLIC_ENV_LABEL` (the `EnvBanner` component renders nothing when unset).
- Log in with a password user (SSO off), e.g. `admin@pelagiamarine.com`.
- Refresh to newer master + restart: re-run the script. Stop:
`pm2 delete ppms-staging`.
## Test database (`pelagia_test`)
A PostgreSQL DB on pms1 that is a **daily mirror of production** (`pelagia`),
refreshed by `automation/refresh-test-db.sh` via cron at **03:30**
(`pg_dump pelagia | psql pelagia_test`). Used by staging and by the automated
fixer for realistic verification. Because it is refreshed daily, anything
written to it is disposable. **Never assume an empty DB — it holds prod-like
data.**
## Operational notes
- The automation fixer and staging run on **port 3100 / 3200**; never broad-kill
(`pkill next`) on pms1 — production's `next-server` runs there too. Stop a dev
server by port (`fuser -k 3100/tcp`).
- Forgejo tokens: `portal-report-issue` (write:issue, used by the app) and
`claude-watcher` (write:issue + write:repository, used by the watcher).
- **Known Forgejo 10 bug:** clicking *Update branch* on a PR can show "broken due
to missing fork information" even when `mergeable: true`. Fix: close and reopen
the PR (UI or API). Resolves on upgrade past v10.
See [Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline) for the automation, and
`automation/README.md` for the full runbook.

51
Environment-Variables.md Normal file

@ -0,0 +1,51 @@
# Environment Variables
The required set differs between development and production; the switch is
automatic, driven by `NODE_ENV` (`next dev` → development, `next build/start`
production). In dev the app needs only a DB and an auth secret — R2 and Resend
fall back to local disk and console email.
Server-side env on pms1 lives in `~/pms/App/.env`; locally in `App/.env.local`
(git-ignored). Copy `App/.env.example` to start.
## Reference
| Variable | Dev | Prod | Notes |
|---|:--:|:--:|---|
| `NEXTAUTH_SECRET` | ✓ | ✓ | 32-char random (`openssl rand -base64 32`) |
| `NEXTAUTH_URL` | ✓ | ✓ | Full app URL (e.g. `http://localhost:3000`) |
| `DATABASE_URL` | ✓ | ✓ | PostgreSQL connection string |
| `AZURE_AD_CLIENT_ID` | placeholder | ✓ | Microsoft Entra SSO |
| `AZURE_AD_CLIENT_SECRET` | placeholder | ✓ | `auth.ts` reads these at **module load** — set placeholders in non-SSO/dev so the app boots |
| `AZURE_AD_TENANT_ID` | placeholder | ✓ | |
| `R2_ACCOUNT_ID` | — | ✓ | Cloudflare R2 (file storage) |
| `R2_ACCESS_KEY_ID` | — | ✓ | |
| `R2_SECRET_ACCESS_KEY` | — | ✓ | |
| `R2_BUCKET_NAME` | — | ✓ | e.g. `pelagia-portal` |
| `R2_PUBLIC_URL` | — | ✓ | Public bucket URL |
| `RESEND_API_KEY` | — | ✓ | Email delivery (`re_…`) |
| `EMAIL_FROM` | — | ✓ | Sender address |
| `EMAIL_FROM_NAME` | — | — | Display name (default "Pelagia Portal") |
| `FORGEJO_URL` | optional | optional | Report-Issue button → Forgejo API |
| `FORGEJO_REPO` | optional | optional | `owner/repo` |
| `FORGEJO_TOKEN` | optional | optional | Token scope `write:issue` |
| `GST_SERVICE_URL` | optional | optional | GstService base (default `http://localhost:3003`) |
| `NEXT_PUBLIC_INVENTORY_ENABLED` | optional | optional | Inventory flag — **off only when `"false"`** |
| `NEXT_PUBLIC_ENV_LABEL` | optional | **unset** | When set, shows the non-prod banner (`EnvBanner`). Leave unset in production |
| `PORT` | optional | optional | App port (default 3000; staging 3200; autofix 3100) |
## Notes
- **SSO at module load**`auth.ts` evaluates the `AZURE_AD_*` vars when the
module loads, so they must be *present* (even as placeholders) for the app to
start in non-SSO environments. See [Architecture](Architecture#auth--authorisation).
- **Storage / email auto-toggle** — with R2/Resend unset in dev, uploads go to
`.dev-uploads/` and emails print to the terminal. See
[File Storage](File-Storage) and [Notifications](Notifications).
- **Inventory flag** — `INVENTORY_ENABLED = NEXT_PUBLIC_INVENTORY_ENABLED !==
"false"`, i.e. enabled unless explicitly `"false"`.
- **Env banner**`EnvBanner` renders nothing when `NEXT_PUBLIC_ENV_LABEL` is
unset, so production is unaffected; staging sets it to the
"INTERNAL DEV / STAGING - NOT PRODUCTION" string.
- **GstService** has its own `PORT` (default 3003); the portal reaches it via
`GST_SERVICE_URL`. See [Vendors and GST Lookup](Vendors-and-GST-Lookup).

71
Feature-Catalogue.md Normal file

@ -0,0 +1,71 @@
# Feature Catalogue
A consolidated index of what the portal does today. Each item links to the page
with the detail, or names the code that implements it.
## Purchase orders
- **Full PO lifecycle** — DRAFT → … → CLOSED with manager approval, vendor
validation, payment, and receipt confirmation. Enforced by
[the state machine](PO-Lifecycle); every change is an audit row.
- **Partial payments & partial receipts**`PARTIALLY_PAID` /
`PARTIALLY_CLOSED` loop until fully settled (`deliveredQuantity` per line).
- **Structured PO numbers**`COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`);
system IDs start at 9000; imported POs keep their original number.
See [Purchase Orders](Purchase-Orders#po-numbering).
- **GST-inclusive totals** — per-line `gstRate` (default 18%); live taxable/GST/
grand-total summary in the form.
- **Manager line-item editing** — managers adjust quantities, prices, GST,
vendor/vessel/account inline before approving (`MANAGER_LINE_EDIT` audit).
- **Editable PO date** + **compulsory payment date** (no future dates).
- **Excel PO import → CLOSED** — historical records, auto-detecting company,
matching vessel, auto-creating vendor/products/prices.
- **PDF / XLSX export** — gated to `MGR_APPROVED`+; approver name + signature on
the document; company details populate the header.
- **Discard draft**, **edit & resubmit**, **edit-highlight diff** on resubmitted
POs.
## Catalogue, vendors, inventory
- **Multi-company invoicing** — bill a PO under a sister company (PMS/HNR/DEI);
details flow to the exported PO. See [Data Model](Data-Model#company--multi-company-invoicing).
- **3-level accounting codes** — Top → Sub → Leaf (6-digit); only leaf codes
selectable, via a searchable combobox.
- **Vendor management** — submitter-created (unverified) vendors;
auto-verify-on-payment; **GSTIN lookup** via the GST microservice; geocoded
vendor-distance sourcing. See [Vendors and GST Lookup](Vendors-and-GST-Lookup).
- **Product catalogue** — editable at `/admin/products`, read-only at
`/inventory/items`; per-vendor price history; "Cheapest" / "★ Closest" tags.
- **Cart** — collect items (localStorage) → create a PO pre-filled.
- **Site inventory (feature-flagged)** — stock per site, daily consumption log;
inventory incremented at PO **approval**. Gated by
`NEXT_PUBLIC_INVENTORY_ENABLED`. See [Inventory and Catalogue](Inventory-and-Catalogue).
## Platform
- **Auth** — Microsoft Entra SSO + credentials; nullable password for SSO users;
optional self-set password; approver signature upload.
- **Role-based dashboards** — submitter / manager / accounts / auditor views.
- **Spend analytics** — manager dashboard: approved-this-month, spend by cost
centre and by month (Recharts).
- **In-app notification bell** with unread badge, plus email at every transition.
- **History & export** — filter by date range, cost centre, and **multiple
statuses**; CSV/PDF export (up to ~200 rows).
- **Mobile experience** — manager/accounts get mobile cards + bottom nav; other
roles see a "Desktop Required" overlay.
- **PPMS rebrand** — login, sidebar, and title show "PPMS".
- **Environment banner** — non-prod banner via `NEXT_PUBLIC_ENV_LABEL`
(`EnvBanner`); renders nothing in prod.
- **SuperUser access requests** — request from profile; resolved at
`/admin/superuser-requests`.
## Operations
- **Report Issue button** — any signed-in user files a Forgejo issue from the
header (`lib/forgejo.ts`).
- **Automated issue→fix→PR pipeline** + **tag-triggered deploy**. See
[Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline).
- **Staging instance** against a daily **prod-mirror test DB** for smoke testing.
For the screen-by-screen breakdown, see [Pages and Navigation](Pages-and-Navigation).
For what shipped recently, see [Changelog](Changelog).

61
File-Storage.md Normal file

@ -0,0 +1,61 @@
# File Storage
PO documents and delivery receipts are uploaded files. To keep large files off
the app server, uploads use **presigned URLs** in production; development uses a
local equivalent so no Cloudflare credentials are needed. The switch is
automatic on `NODE_ENV`, centralised in `lib/storage.ts`.
| | Production | Development |
|---|---|---|
| Backend | Cloudflare R2 (S3-compatible) | `.dev-uploads/` (git-ignored) |
| Upload | Client → presigned `PUT` → R2 | Client → `PUT /api/files/dev/<key>` → disk |
| Download | R2 presigned GET | `GET /api/files/dev/<key>` (auth-gated, 404 in prod) |
## Upload flow (production — R2)
```
Client App Server Cloudflare R2
│── POST /api/files/sign ───────▶│ │
│ { fileName, mimeType } │── generate presigned ───▶│
│◀── { uploadUrl, key } ─────────│◀───── presigned URL ─────│
│── PUT uploadUrl (file bytes) ─────────────────────────────▶│
│── Server Action: link ────────▶│── INSERT PODocument ─────▶ (DB)
│ { poId, key, meta } │
```
## Upload flow (development — local FS)
```
Client App Server .dev-uploads/
│── POST /api/files/sign ───────▶│ │
│◀── { uploadUrl, key } ─────────│ uploadUrl = /api/files/dev/<key>
│── PUT /api/files/dev/<key> ───▶│── write to disk ────────▶│
│── Server Action: link ────────▶│── INSERT PODocument ─────▶ (DB)
```
Downloads mirror this: `generateDownloadUrl` returns a `/api/files/dev/<key>`
GET URL in dev and an R2 presigned URL in prod. The
`app/api/files/dev/[...key]/route.ts` handler is **auth-gated and returns 404 in
production**.
## Where files attach
- **PO documents** (`PODocument`) — uploaded from the PO form's Documents
section and at delivery confirmation. On the PO detail page they are **grouped
by type** (`lib/attachments.ts`).
- **Delivery receipt** (`Receipt`) — uploaded at receipt confirmation; one per PO
(`@unique poId`), upserted on repeat confirmations (notes preserved).
- **Approval signature** (`User.signatureKey`) — uploaded by approvers from the
profile page; appears on exported PO documents.
Client-side upload is helped by `lib/upload-files.ts`. The export endpoint embeds
the approver's signature in the generated PDF/XLSX.
## Required env (production)
```
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL
```
In development these can be left as placeholders. See
[Environment Variables](Environment-Variables).

115
Getting-Started.md Normal file

@ -0,0 +1,115 @@
# Getting Started
How to get the portal running locally for development. The app lives in
`App/` (package name `pelagia-portal`).
## Prerequisites
| Tool | Version |
|---|---|
| Node.js | ≥ 20.11.0 LTS |
| pnpm | ≥ 9.0.0 |
| PostgreSQL | ≥ 16 (local or Docker) |
```bash
npm install -g pnpm # if you don't have pnpm
```
## Dev mode keeps it simple
In development the app needs **only a database and an auth secret**. Cloudflare
R2 and Resend are **not** required — file uploads land in `.dev-uploads/` and
emails are printed to the terminal (lines prefixed `📧 [DEV EMAIL]`). The switch
is automatic, driven by `NODE_ENV` (`next dev` → development, `next build/start`
→ production). See [File Storage](File-Storage) and [Notifications](Notifications).
## Setup
```bash
cd App
pnpm install
# 1. Environment
cp .env.example .env.local
# minimum .env.local:
# NEXTAUTH_SECRET=<openssl rand -base64 32>
# NEXTAUTH_URL=http://localhost:3000
# DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pelagia_portal"
# 2. Database
pnpm db:migrate # prisma migrate dev — create + apply migrations
pnpm db:seed # vessels, accounts, vendors, products, demo users
# 3. Run
pnpm dev # Next.js + Turbopack at http://localhost:3000
```
> `auth.ts` reads the Azure/Entra SSO variables at **module load**. In a non-SSO
> dev environment, set placeholder values for `AZURE_AD_*` so the app boots. See
> [Environment Variables](Environment-Variables).
## Seed credentials
`pnpm db:seed` creates demo users (password = role name + `1234`):
| Role | Email | Password |
|---|---|---|
| Technical | `tech@pelagia.local` | `tech1234` |
| Manning | `manning@pelagia.local` | `manning1234` |
| Accounts | `accounts@pelagia.local` | `accounts1234` |
| Manager | `manager@pelagia.local` | `manager1234` |
| SuperUser | `superuser@pelagia.local` | `super1234` |
| Auditor | `auditor@pelagia.local` | `audit1234` |
| Admin | `admin@pelagia.local` | `admin1234` |
There is **no self-registration** — accounts are provisioned by an Admin or via SSO.
## Common commands
```bash
# Development
pnpm dev # dev server (Turbopack)
pnpm lint # ESLint
pnpm type-check # tsc --noEmit
# Tests (see the Testing page)
pnpm test # unit (Vitest, jsdom)
pnpm test:integration # integration (Vitest, node + real DB)
pnpm test:e2e # E2E (Playwright)
pnpm test:all # unit + integration
# Database
pnpm db:migrate # create + apply migration (dev)
pnpm db:migrate:deploy # apply migrations (CI/prod, non-interactive)
pnpm db:push # push schema without a migration (prototyping)
pnpm db:seed # demo data
pnpm db:seed:prod # real reference data (users, companies, cost centres, sites, accounting codes — idempotent)
pnpm db:studio # Prisma Studio at http://localhost:5555
pnpm db:reset # drop + recreate + reseed (dev only)
# Misc
pnpm email:preview # live-preview email templates at http://localhost:3001
```
## Related services
- **GstService** (`GstService/`) — a small Express + Playwright microservice that
proxies the GST portal CAPTCHA/lookup. Optional in dev; defaults to
`http://localhost:3003`. See [Vendors and GST Lookup](Vendors-and-GST-Lookup).
## Project layout
```
App/
├── app/ # Next.js App Router pages + API routes
│ ├── (auth)/login/
│ ├── (portal)/ # authenticated shell (sidebar + header)
│ └── api/ # auth, files, gst, notifications, po/export, reports
├── components/ # dashboard, inventory, layout, po, ui (shadcn/ui)
├── lib/ # business logic (state machine, permissions, notifier, storage, …)
├── emails/ # React Email templates
├── prisma/ # schema, migrations, seed.ts, seed-prod.ts, accounting-codes-data.ts
└── tests/ # unit (Vitest), integration (Vitest+DB), e2e (Playwright)
```
See [Architecture](Architecture) for the full layer breakdown and [Data Model](Data-Model) for the schema.

31
Glossary.md Normal file

@ -0,0 +1,31 @@
# Glossary
Domain vocabulary for Pelagia Portal (PPMS). Several terms shifted from the
original design — the definitions below are the **shipped** meanings.
| Term | Meaning |
|---|---|
| **PPMS** | "Pelagia Payment Management System" — the in-UI brand for Pelagia Portal (login, sidebar, title). |
| **Purchase Order (PO)** | The central record: a request to buy goods/services, tracked through its [lifecycle](PO-Lifecycle) from DRAFT to CLOSED. |
| **Cost Centre** | **A Vessel.** Every PO is raised against a Vessel (`PurchaseOrder.vesselId`, required). Surfaced as "Cost Centre" everywhere in the UI (`/admin/vessels` → "Cost Centre Management"). The earlier Vessel-or-Site cost-centre model was removed. |
| **Vessel** | A ship; the cost centre a PO is charged to. Has a unique `code` used in PO numbers. |
| **Accounting Code** | A budget head: a leaf in the 3-level `Account` hierarchy (Top Category → Sub-Category → Leaf), 6-digit numeric. Only leaf codes are PO-selectable. Previously labelled "Account". |
| **Company** | The sister company a PO is billed under (e.g. PMS, HNR, DEI). Its GST/address details appear on the exported PO. Optional per PO. |
| **PO Number** | Auto-formatted `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`). System IDs start at 9000; imported POs keep their original number. |
| **FY** | Indian financial year (AprMar), rendered `YYYY-YY` in PO numbers. |
| **Vendor** | A supplier. Submitter-created vendors are **unverified** until a PO closes/pays with them, on import, or a Manager/Accounts/Admin verifies. The formal `vendorId` is the verified code. |
| **GSTIN** | 15-char Indian GST identification number; looked up via the [GST microservice](Vendors-and-GST-Lookup) to auto-fill vendor details. |
| **Site** | A port/depot/office that holds inventory; used for delivery and vendor-distance sourcing. **Not** a cost centre. |
| **Product / Item** | A catalogue entry (`code`, `name`). Tracks `lastPrice`/`lastVendor` and per-vendor prices, updated on payment. |
| **Line Item** | A row on a PO: name, qty, unit, unit price, GST rate; optional product link and per-line accounting code. |
| **Submitter** | The user who raised a PO (Technical / Manning / Manager / SuperUser). |
| **Partial payment / receipt** | `PARTIALLY_PAID` / `PARTIALLY_CLOSED` states that loop until the full amount/quantity is settled. |
| **POAction** | An audit-trail row recording one event/transition on a PO (actor, type, note, metadata). |
| **Receipt** | Proof-of-delivery record (file + notes) confirming a PO; closes it. |
| **Import PO** | Uploading a Pelagia-format Excel PO straight into `CLOSED` as a historical record. |
| **Inventory flag** | `NEXT_PUBLIC_INVENTORY_ENABLED` — gates site stock/consumption surfaces. |
| **pms1** | The single Ubuntu server hosting the app, DB, Forgejo, and CI runner. |
| **`ppms`** | The pm2 process running the production app on port 3000. |
| **`pelagia` / `pelagia_test`** | Production DB / its daily mirror used for staging + autofix verification. |
| **Report Issue** | Header button that files a Forgejo issue, kicking off the [issue-to-deploy pipeline](Issue-to-Deploy-Pipeline). |
| **Staging** | A deployed instance of latest `master` (pm2 `ppms-staging`, port 3200, SSH-tunnel only) for pre-release smoke testing. |

39
Home.md Normal file

@ -0,0 +1,39 @@
# Pelagia Portal (PPMS)
**Pelagia Portal** — branded **PPMS** ("Pelagia Payment Management System") in the UI — is an internal **purchase-order management system** for a maritime / vessel-operations company. It digitises the full PO lifecycle — from a crew member raising a requisition, through manager approval and vendor validation, to accounts payment and receipt confirmation — replacing ad-hoc email chains and spreadsheets with a single, auditable workflow.
This wiki is the project's living reference. It is synthesised from the in-repo docs (`Docs/`, `CHANGELOG.md`, `App/CLAUDE.md`, `App/README.md`) and the current source (`prisma/schema.prisma`, `lib/`, `app/`, `automation/`). Where the original design specs and the shipped product diverge, **the code and this wiki reflect the shipped product**.
---
## What it does
- **Role-specific dashboards & workflows** so every actor sees only what's relevant to their job.
- **A structured, auditable approval chain** for every PO, enforced by a single state machine.
- **Automatic email notifications** at each state transition.
- **Spend visibility** for management by cost centre (vessel) and time period.
- **Vendor validation** — GSTIN lookup, geocoding for vendor-distance sourcing, and an auto-verify-on-payment flow.
- **Multi-company invoicing**, structured PO numbers, a 3-level accounting-code hierarchy, Excel PO import, and (feature-flagged) site inventory.
## The product at a glance
| | |
|---|---|
| **Stack** | Next.js 15 (App Router) · TypeScript 5 · PostgreSQL 16 + Prisma 5 · NextAuth v5 · Tailwind v4 + shadcn/ui |
| **Hosting** | Self-hosted on a single Ubuntu server (`pms1`), Next.js under pm2, fronted by a Pangolin/Traefik tunnel |
| **CI/CD** | Forgejo + Forgejo Actions; an automated issue→fix→PR pipeline; a release tag (`vX.Y.Z`) deploys |
| **Auth** | Microsoft Entra SSO **and** credentials; 7 roles |
| **Live at** | `pms.pelagiamarine.com` |
---
## Start here
- **New to the codebase?** → [Getting Started](Getting-Started)
- **Understanding the system?** → [Architecture](Architecture) · [Data Model](Data-Model) · [PO Lifecycle](PO-Lifecycle)
- **Who can do what?** → [Roles and Permissions](Roles-and-Permissions)
- **What's been built?** → [Feature Catalogue](Feature-Catalogue) · [Pages and Navigation](Pages-and-Navigation)
- **Operating it?** → [Deployment and Operations](Deployment-and-Operations) · [Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline)
- **Unsure of a term?** → [Glossary](Glossary)
> The full page list is in the sidebar. See [Changelog](Changelog) for what shipped recently and [Open Questions](Open-Questions) for decisions still pending sign-off.

@ -0,0 +1,82 @@
# Inventory and Catalogue
This covers the product catalogue, per-vendor pricing, the cart, and the
feature-flagged site-inventory surfaces.
## Products
Two views over the same `Product` records:
- **`/admin/products`** — the **editable** catalogue (MANAGER, ADMIN). Add a
product (code, name, description), toggle Active/Inactive, delete.
- **`/inventory/items`** — **read-only** catalogue, available to all roles for PO
creation. Expand a row to see per-vendor prices.
Both link to a shared **item detail** page. A product carries `lastPrice` and
`lastVendor` (read-only — auto-populated on payment) and per-vendor history via
`ProductVendorPrice` (one row per productvendor pair).
### Auto-sync on payment confirmation
When a PO is marked paid, for each line item:
- if it has a `productId`, set `Product.lastPrice = line.unitPrice` and
`Product.lastVendorId = po.vendorId`;
- **upsert** the `(product, vendor)` price into `ProductVendorPrice`;
- log a `PRODUCT_PRICE_UPDATED` action.
Future POs using that product show the vendor's latest price as a hint in the
line-item editor. (The original spec also described fuzzy-matching unlinked
items into new products; price/vendor tracking is the shipped behaviour.)
## Product search
`/api/products/search` powers the line-item name autocomplete: min 2 chars,
case-insensitive match on name / code / description, max 10 results, inactive
products excluded, `lastPrice` serialised as a plain `number` (not a Prisma
`Decimal`).
## Cheapest / ★ Closest tags
On the item detail and items pages, when a **Site** is selected each item's
vendor list is annotated:
- **Cheapest** — vendor with the minimum `ProductVendorPrice`.
- **★ Closest** — vendor nearest the selected site by geocoded distance
(`distanceKm`).
These are computed independently, so **both tags can appear simultaneously**
regardless of whether the list is sorted by Price or Distance. Selecting a site
also auto-switches the active sort to **Distance** (a `useEffect` keyed on the
site id resets it on every site change — important because Next.js soft
navigation preserves React state). With no site selected, neither distance tag
shows. See [Testing](Testing) for the specs pinning this down.
## Cart
A persistent cart (`lib/cart.ts`) stored in browser `localStorage` under a fixed
key, surviving navigation but local to the device/user. A `cart-updated` custom
event lets components (e.g. the header cart icon with its count badge) react in
real time.
Flow: add items from product/item detail → open `/inventory/cart` → adjust
quantities, remove items, pick a delivery site → **Create PO** opens `/po/new`
pre-filled with the cart line items and vendor/site.
## Site inventory (feature-flagged)
Gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (`lib/feature-flags.ts`):
`INVENTORY_ENABLED = process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false"`
(i.e. **on unless explicitly `"false"`**). When off, site stock/consumption
surfaces are hidden; the vendor/product catalogue and cart remain available.
- **Sites** (`/admin/sites`) — ports/depots/offices that hold stock; geocoded
from pincode; vessels can be associated.
- **`ItemInventory`** — quantity per `(product, site)`. **Incremented at PO
approval** (not on close) for the ordered quantities, when the PO has a
`siteId`.
- **`ItemConsumption`** — daily draw-down per `(product, site, date)`, recorded
via the "Log Consumption" form on the site detail page (with `recordedBy`).
Site detail (`/admin/sites/[id]`) shows a stock bar chart, a 30-day consumption
line chart, the inventory table, assigned vessels, and recent POs for the site.

@ -0,0 +1,95 @@
# Issue-to-Deploy Pipeline
A self-hosted pipeline takes a user-reported bug from a click in the portal all
the way to a production fix, with a human gate only at PR-merge / release. It
runs on **pms1** (Forgejo + headless Claude Code). Full runbook:
`automation/README.md`.
## End-to-end flow
```
Portal header "Report Issue" [components/layout/report-issue-button.tsx]
│ server action → Forgejo API (label: portal)
Forgejo issue [git.pelagiamarine.com/shad0w/pelagia-portal]
│ polled every 10 min (cron on pms1)
TRIAGE (watcher phase 1) [headless Claude Code, analysis only]
│ posts a requirements breakdown; routes the issue:
│ → claude-queue (auto-fixable) or → interactive (human)
FIX (watcher phase 2, claude-queue only) [in ~/pelagia-autofix clone]
│ implements + verifies; pushes branch claude/issue-N; opens PR (claude-pr)
Human: review + merge PR, then push a release tag vX.Y.Z
│ tag push triggers .forgejo/workflows/deploy.yml
forgejo-runner on pms1 (label "host")
│ checkout tag in ~/pms → pnpm install + build + migrate deploy
pm2 restart ppms → live at pms.pelagiamarine.com
```
`interactive`-routed issues stop after triage for a human to pick up. The triage
breakdown comment is plain (no bot marker) so, for `claude-queue` issues, the fix
stage reads it back as refined requirements.
## Components
| Piece | Where | Notes |
|---|---|---|
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files an issue with only the `portal` label |
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` (scope `write:issue`) |
| Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash; 24/7 via cron; config + logs under `~/issue-watcher/` |
| Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original; `PelagiaClaudeIssueWatcher` task disabled (one worker only) |
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
| Runner | pms1 `~/forgejo-runner`, pm2 `forgejo-runner` | Registered `pms1-host`, labels `host`, `docker` |
## Where the watcher runs
On **pms1** under cron (every 10 min), polling Forgejo over loopback
(`http://127.0.0.1:3001`):
- Script: `~/issue-watcher/claude-issue-watcher.sh`
- Config: `~/issue-watcher/watcher.config.json` (gitignored; token + `claudeExe` path)
- Work clone: `~/pelagia-autofix` (separate from the deployed `~/pms`)
- Logs: `~/issue-watcher/logs/` (`watcher-<date>.log`, per-issue `claude-*.log`, `cron.log`)
**Auth:** Claude Code must be signed in on pms1 (`~/.claude/.credentials.json`),
or an `ANTHROPIC_API_KEY` env var present. The watcher preflight no-ops until
credentials exist, so cron can be enabled before sign-in and activates
automatically once signed in. It runs Claude with
`--dangerously-skip-permissions` **inside the dedicated `pelagia-autofix`
clone** — never the main checkout.
## Issue label lifecycle
```
portal ──(triage)──▶ claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed
└────▶ interactive (stops here — handle interactively)
```
- A `portal` issue with no decision label is triaged once per run; triage adds
`claude-queue` or `interactive` and posts a breakdown.
- `claude-queue``claude-working``claude-pr` (PR opened) or `claude-failed`.
- Retry a failed issue by re-adding `claude-queue`. Queue a manual issue
(skipping triage) by adding `claude-queue` directly; force human handling with
`interactive`. Triage is skipped for issues that already carry a decision label.
## Autofix verification against the test DB
So the fix stage verifies against realistic data without touching production:
- The autofix clone's `~/pelagia-autofix/App/.env` points `DATABASE_URL` at
**`pelagia_test`** (the daily prod-mirror) and runs in **safe dev mode** (no
Resend/SSO secrets → console email, local storage). `NEXTAUTH_URL`/`PORT` are
**3100** (production is 3000).
- The fix prompt allows running integration tests against this DB
(`set -a; . ./.env; set +a; pnpm test:integration`) and starting a dev server
on **port 3100 only**, stopping it by port (`fuser -k 3100/tcp`) — never a
broad `pkill next` (would take down production).
- Schema-migration issues are routed to `interactive`, so the unattended fixer
should not be altering the schema.
See [Deployment and Operations](Deployment-and-Operations) for the deploy
workflow and staging, and `automation/README.md` for the authoritative runbook.

68
Notifications.md Normal file

@ -0,0 +1,68 @@
# Notifications
The portal notifies stakeholders at every PO state transition — by **email** and
via an **in-app notification bell**. Email dispatch is centralised in
`lib/notifier.ts` and called **only** from state-machine side-effects, never
directly from UI handlers. Every notification is also persisted to the
`Notification` table for audit, in both prod and dev.
## Email: prod vs dev
- **Production** — templates in `emails/` (React Email), rendered server-side
with `@react-email/render` and sent via the **Resend** SDK.
- **Development** — the recipient, subject, and body are **printed to the
terminal** (lines prefixed `📧 [DEV EMAIL]`); no Resend key required.
The switch is automatic on `NODE_ENV`. Preview templates live with
`pnpm email:preview` (http://localhost:3001).
## Email templates (`emails/`)
| Template | Sent on |
|---|---|
| `po-submitted.tsx` | PO submitted → Manager |
| `po-approved.tsx` | PO approved → Submitter + Accounts |
| `po-rejected.tsx` | PO rejected → Submitter (with reason) |
| `edits-requested.tsx` | Edits requested → Submitter (with note) |
| `vendor-id-needed.tsx` | Vendor ID requested → Submitter |
| `payment-processed.tsx` | Payment sent / paid → Submitter + Manager |
| `receipt-confirmed.tsx` | Receipt confirmed → Manager + Accounts |
| `layout.tsx` | Shared email shell |
## Event → recipient matrix
Driven by the side-effects declared per transition in the
[state machine](PO-Lifecycle#transition-table):
| Event | Side-effect | Notified |
|---|---|---|
| Submit / resubmit | `EMAIL_MANAGER` | Manager(s) |
| Vendor ID requested | `EMAIL_SUBMITTER` | Submitter |
| Vendor ID provided | `EMAIL_MANAGER` | Manager |
| Edits requested | `EMAIL_SUBMITTER` | Submitter (with note) |
| Approve / Approve+Note | `EMAIL_SUBMITTER`, `EMAIL_ACCOUNTS` | Submitter + Accounts |
| Reject | `EMAIL_SUBMITTER` | Submitter (with reason) |
| Payment sent | `EMAIL_SUBMITTER_AND_MANAGER` | Submitter + Manager |
| Marked paid | `EMAIL_SUBMITTER`, `EMAIL_MANAGER` | Submitter + Manager |
| Receipt confirmed (full) | `EMAIL_MANAGER`, `EMAIL_ACCOUNTS` | Manager + Accounts |
Partial-payment / partial-receipt transitions carry **no** email side-effects;
they update state silently until the full settlement event fires.
## In-app notification bell
Every signed-in user sees a **bell** in the header with an **unread count**
badge. The dropdown lists recent items; each may carry a `link` to the relevant
PO. Backed by the `Notification` model (`isRead`, `link` fields) and served by:
- `/api/notifications` (GET) — the current user's notifications
- `/api/notifications/read` (POST) — mark notifications read
## Report Issue
Separate from PO notifications: any signed-in user can file a bug from the header
via the **Report Issue** button (`components/layout/report-issue-button.tsx`
`report-issue-actions.ts``lib/forgejo.ts`), which creates a Forgejo issue
labelled `portal`. That kicks off the
[Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline). Requires `FORGEJO_URL`,
`FORGEJO_REPO`, `FORGEJO_TOKEN` (token scope `write:issue`).

21
Open-Questions.md Normal file

@ -0,0 +1,21 @@
# Open Questions
Decisions that needed sign-off before the corresponding feature was finalised
(from `Docs/03-open-questions.md`). Some have since been answered by the shipped
product — annotated below. Update as they resolve.
| # | Question | Status / shipped answer |
|---|---|---|
| 1 | Should a manager be able to directly edit a PO (bypass the submitter edit cycle)? | **Effectively yes** — managers edit line items inline before approving (`MANAGER_LINE_EDIT` audit); MANAGER can also create/submit. |
| 2 | Dual sign-off for POs above a value threshold? | **Open** — single approver today. |
| 3 | Is the vendor registry Admin-only, or can Managers also add/edit? | **Resolved**`manage_vendors` is held by Manager, Accounts, and Admin; submitters can add *unverified* vendors. |
| 4 | Is SSO required, or is internal credential management enough? | **Resolved** — both: Microsoft Entra SSO **and** a credentials provider; SSO users have nullable passwords. |
| 5 | What currency/currencies? Multi-currency with FX in scope? | **Partly**`currency` defaults to `INR`; multi-currency/FX not implemented. |
| 6 | Hard-delete vs permanent archive for rejected POs; retention window? | **Open**. |
| 7 | Public document URLs vs always-signed/authenticated downloads? | **Resolved** — downloads are auth-gated/presigned (dev route 404s in prod). See [File Storage](File-Storage). |
| 8 | Row-level vessel/account restrictions per submitter? | **Open** — any submitter can raise a PO against any cost centre. |
| 9 | Expected volume (POs/day, concurrent users) — for pool sizing / `pms1` resourcing? | **Open**. |
| 10 | Should manager analytics count only CLOSED POs, or all from MGR_APPROVED onwards? | **Resolved** — "Approved this month" counts by `approvedAt` (all POs approved in the period), not just those currently in `MGR_APPROVED`. See [Changelog](Changelog). |
For the design-era spec context, see `Docs/01-design-document.md` and
`Docs/DESIGN.md`.

101
PO-Lifecycle.md Normal file

@ -0,0 +1,101 @@
# PO Lifecycle (State Machine)
Every purchase-order status change is enforced by a single module,
`lib/po-state-machine.ts`. No transition happens outside it, so the graph is
guaranteed in one place, and each transition is recorded as a `POAction` audit
row. The state machine also declares the **roles** allowed to perform each
action, whether a **note** is required, and which **email side-effects** fire.
## Canonical flow
```
DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
↓↑ ↕ ↕
EDITS_REQUESTED / REJECTED PARTIALLY_PAID PARTIALLY_CLOSED
/ VENDOR_ID_PENDING
```
- `SUBMITTED` is transient — the server action auto-advances it to `MGR_REVIEW`
immediately.
- **Partial payments** (`PARTIALLY_PAID`) and **partial receipts**
(`PARTIALLY_CLOSED`) loop until the full amount/quantity is settled.
- **Imported POs** are created directly in `CLOSED` (historical record,
bypassing approval). See [Purchase Orders](Purchase-Orders#import-po--closed).
- Terminal states: **REJECTED**, **CLOSED**.
## Transition table
Exactly as encoded in `TRANSITIONS` in `lib/po-state-machine.ts`:
| From | Action | To | Allowed roles | Note? | Email side-effects |
|---|---|---|---|---|---|
| DRAFT | `submit` | SUBMITTED | TECHNICAL, MANNING, MANAGER, SUPERUSER | — | Manager |
| SUBMITTED | *(auto)* | MGR_REVIEW | system | — | — |
| MGR_REVIEW | `approve` | MGR_APPROVED | MANAGER, SUPERUSER | — | Submitter + Accounts |
| MGR_REVIEW | `approve_with_note` | MGR_APPROVED | MANAGER, SUPERUSER | ✓ | Submitter + Accounts |
| MGR_REVIEW | `reject` | REJECTED | MANAGER, SUPERUSER | ✓ | Submitter |
| MGR_REVIEW | `request_edits` | EDITS_REQUESTED | MANAGER, SUPERUSER | ✓ | Submitter |
| MGR_REVIEW | `request_vendor_id` | VENDOR_ID_PENDING | MANAGER, SUPERUSER | — | Submitter |
| VENDOR_ID_PENDING | `provide_vendor_id` | MGR_REVIEW | TECHNICAL, MANNING, ACCOUNTS, MANAGER, SUPERUSER | — | Manager |
| EDITS_REQUESTED | `submit` | SUBMITTED | TECHNICAL, MANNING, MANAGER, SUPERUSER | — | Manager |
| MGR_APPROVED | `process_payment` | SENT_FOR_PAYMENT | ACCOUNTS, SUPERUSER | — | Submitter + Manager |
| SENT_FOR_PAYMENT | `mark_paid` | PAID_DELIVERED | ACCOUNTS, SUPERUSER, MANAGER | — | Submitter + Manager |
| SENT_FOR_PAYMENT | `mark_partial_payment` | PARTIALLY_PAID | ACCOUNTS, SUPERUSER, MANAGER | — | — |
| PARTIALLY_PAID | `mark_paid` | PAID_DELIVERED | ACCOUNTS, SUPERUSER, MANAGER | — | — |
| PARTIALLY_PAID | `mark_partial_payment` | PARTIALLY_PAID | ACCOUNTS, SUPERUSER, MANAGER | — | — |
| PARTIALLY_PAID | `confirm_receipt` | CLOSED | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | — |
| PARTIALLY_PAID | `confirm_partial_receipt` | PARTIALLY_PAID | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | — |
| PAID_DELIVERED | `confirm_receipt` | CLOSED | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | Manager + Accounts |
| PAID_DELIVERED | `confirm_partial_receipt` | PARTIALLY_CLOSED | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | — |
| PARTIALLY_CLOSED | `confirm_receipt` | CLOSED | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | Manager + Accounts |
| PARTIALLY_CLOSED | `confirm_partial_receipt` | PARTIALLY_CLOSED | TECHNICAL, MANNING, SUPERUSER, MANAGER | — | — |
> Note the shipped product is broader than the original spec: SUPERUSER and
> MANAGER can also create/submit and process payments, and partial
> payment/receipt loops exist. The table above is the authoritative encoding.
## Module API
```ts
getTransition(from, action) // → Transition | null
canPerformAction(from, action, role) // → boolean (status + role gate)
getAvailableActions(status, role) // → POAction[] (drives which buttons render)
requiresNote(from, action) // → boolean
```
`getAvailableActions` is what the UI uses to decide which action buttons to show
for the current PO status and the signed-in user's role.
## Side-effects
Side-effects are declared per transition (`EMAIL_MANAGER`, `EMAIL_SUBMITTER`,
`EMAIL_ACCOUNTS`, `EMAIL_SUBMITTER_AND_MANAGER`) and dispatched via
`lib/notifier.ts` — never directly from UI handlers. See
[Notifications](Notifications) for the event→recipient matrix and templates.
Two non-email side-effects worth calling out, applied in the server actions:
- **Product price auto-update** — on payment confirmation, each line item with a
`productId` updates `Product.lastPrice`/`lastVendorId` and upserts the
per-vendor price; a `PRODUCT_PRICE_UPDATED` action is logged. See
[Inventory and Catalogue](Inventory-and-Catalogue).
- **Inventory increment** — at **approval**, ordered quantities are added to
`ItemInventory` when the PO has a `siteId`.
## Status badges
Each status renders a colour-coded pill (`components/po/po-status-badge.tsx`):
| Status | Colour intent |
|---|---|
| DRAFT | neutral grey |
| SUBMITTED / MGR_REVIEW | blue (in-progress) |
| VENDOR_ID_PENDING | orange/warning |
| EDITS_REQUESTED | yellow/warning |
| MGR_APPROVED | teal |
| SENT_FOR_PAYMENT | purple |
| PARTIALLY_PAID | purple-adjacent |
| PAID_DELIVERED | blue-green |
| PARTIALLY_CLOSED | green-adjacent |
| CLOSED | green/success |
| REJECTED | red/danger |

90
Pages-and-Navigation.md Normal file

@ -0,0 +1,90 @@
# Pages and Navigation
The left sidebar adapts to the signed-in user's role and is organised into
**Purchasing** and **Administration** sections (with an Inventory group when the
inventory flag is on). Routes below map to files under `app/(portal)/`.
## Sidebar (role-aware)
```
Dashboard ← all users
─── Purchasing ──────────────────────────
New PO /po/new ← TECH, MANNING, MANAGER, SUPERUSER
My Orders /my-orders ← TECH, MANNING, MANAGER, SUPERUSER
Approvals /approvals ← MANAGER, SUPERUSER
Import PO /po/import ← MANAGER, SUPERUSER, ADMIN
Payments /payments ← ACCOUNTS (+ MANAGER, SUPERUSER)
History / Export /history ← MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN
Vendors /inventory/vendors ← MANAGER, ACCOUNTS, ADMIN
Items /inventory/items ← all (read-only catalogue)
Cart /inventory/cart ← TECH, MANNING, MANAGER, SUPERUSER
─── Administration ──────────────────────
Users /admin/users ← ADMIN
Companies /admin/companies ← ADMIN
Accounting Codes /admin/accounts ← MANAGER, ADMIN
Cost Centres /admin/vessels ← MANAGER, ADMIN
Sites /admin/sites ← MANAGER, ADMIN
Products /admin/products ← MANAGER, ADMIN
Vendors /admin/vendors ← MANAGER, ACCOUNTS, ADMIN
SuperUser Requests /admin/superuser-requests ← ADMIN
```
## Authenticated routes
| Route | Page | Notes |
|---|---|---|
| `/login` | Login | Email + password; SSO button; "PPMS" branding |
| `/dashboard` | Dashboard | Role-specific stat cards + charts |
| `/my-orders` | My Purchase Orders | Submitter's open & past POs (Closed list scoped by role) |
| `/po/new` | New PO | Multi-section form (header, line items, T&C, documents) |
| `/po/[id]` | PO Detail | Read view; contextual action buttons by status/role; export |
| `/po/[id]/edit` | Edit PO | DRAFT or EDITS_REQUESTED, owner/SUPERUSER; "Update & Resubmit" |
| `/po/[id]/receipt` | Confirm Receipt | PAID_DELIVERED; upload receipt; full or partial |
| `/po/import` | Import PO | Upload Pelagia-format Excel → CLOSED |
| `/approvals` | Approval Queue | MGR_REVIEW POs; search + filters |
| `/approvals/[id]` | Approval Detail | Approve / Approve+Note / Reject / Request Edits / Request Vendor ID; inline line-item edit |
| `/payments` | Payment Queue | MGR_APPROVED & SENT_FOR_PAYMENT cards; send/mark paid; partial |
| `/payments/history` | Payment History | Completed payments (ACCOUNTS, MANAGER) |
| `/history` | History & Export | All POs; filter by date/cost centre/multiple statuses; CSV/PDF |
| `/inventory/items` | Items (read-only) | Catalogue; expand for per-vendor prices; Cheapest/★Closest tags |
| `/inventory/items/[id]` | Item Detail | Price comparison, site-distance sort, stock by site |
| `/inventory/vendors` | Vendors | List with verified/active badges |
| `/inventory/vendors/[id]` | Vendor Detail | Info, items supplied, recent POs |
| `/inventory/cart` | Cart | localStorage cart → Create PO |
| `/profile` | Profile | Every role; set password (SSO users); signature (approvers); request SuperUser |
| `/admin/users` | User Management | ADMIN — CRUD + roles |
| `/admin/companies` | Companies | ADMIN — multi-company invoicing |
| `/admin/accounts` | Accounting Codes | 3-level hierarchy |
| `/admin/vessels` | Cost Centre Management | Vessels surfaced as "Cost Centre" |
| `/admin/sites` | Sites | Ports/depots/offices; inventory |
| `/admin/sites/[id]` | Site Detail | Stock chart, consumption log, assigned vessels |
| `/admin/products` | Item Catalogue | Editable; add/toggle/delete |
| `/admin/products/[id]` | Item Detail | Vendor prices, stock |
| `/admin/vendors` | Vendor Registry | Add/edit; GSTIN lookup |
| `/admin/vendors/[id]` | Vendor Detail | GSTIN/address/contacts; items; recent POs |
| `/admin/superuser-requests` | SuperUser Requests | ADMIN — approve/deny |
## PO detail action buttons
Which buttons appear is computed from PO status × role (via
`getAvailableActions` in the state machine):
| Condition | Button |
|---|---|
| DRAFT or EDITS_REQUESTED + own submitter | Edit |
| DRAFT + owner / MANAGER / SUPERUSER | Discard |
| VENDOR_ID_PENDING + can provide vendor | Inline vendor selection |
| PAID_DELIVERED / PARTIALLY_* + own submitter or SUPERUSER/MANAGER | Confirm Receipt (full/partial) |
| MGR_APPROVED+ | Export PDF / XLSX |
## Mobile
- **Manager** and **Accounts** get a mobile layout: approval/payment **cards**
and a **bottom navigation** bar (Home / section / Profile).
- Other roles see a **"Desktop Required"** overlay on small viewports (375×812),
with a Sign-out button.
The design-system tokens (colours, typography, component conventions) are in
`Docs/01-design-document.md` §7.

124
Purchase-Orders.md Normal file

@ -0,0 +1,124 @@
# Purchase Orders
This page covers the mechanics specific to purchase orders: numbering, GST,
the create/edit forms, company invoicing, accounting codes, and Excel import.
For the status graph see [PO Lifecycle](PO-Lifecycle); for the schema see
[Data Model](Data-Model).
## PO numbering
`lib/po-number.ts` generates a **structured** number:
```
COMPANY_CODE / VESSEL_CODE / PO_ID / FY e.g. PMS/HNR1/9000/2024-25
```
- **COMPANY_CODE**`company.code` (fallback `PMS`).
- **VESSEL_CODE**`vessel.code` (fallback `GEN`).
- **PO_ID** — globally sequential integer. `nextPoId()` scans existing structured
numbers and floors at **8999**, so the first system-generated ID is **9000**
avoiding clashes with imported POs (which keep their original, typically low,
IDs).
- **FY** — Indian financial year (AprMar) rendered `YYYY-YY`
(e.g. Apr 2025Mar 2026 → `2025-26`).
`parsePoNumber()` splits a number back into its four parts (returns `null` for
old-format numbers). **Imported POs keep their original PO number verbatim.**
## GST calculation
GST is per line item. `gstRate` is a `Decimal(5,4)` on `POLineItem`, default
`0.18` (18%):
```
line totalPrice = quantity × unitPrice × (1 + gstRate)
PO totalAmount = Σ line totalPrice (GST-inclusive)
```
The PO form shows a live summary below the line-items table:
- **Taxable** = Σ (qty × unitPrice)
- **GST** = Σ (qty × unitPrice × gstRate)
- **Grand Total** = Taxable + GST
This is applied in the Server Actions that compute `totalPrice` per line and the
PO `totalAmount`.
## Creating / editing a PO
`/po/new` is a multi-section form (mirrored, pre-filled, by `/po/[id]/edit`):
1. **Header** — Title (required), description, **Cost Centre** (Vessel,
required), **Accounting Code** (leaf only, required), Company (optional),
Vendor (optional, added later), Date Required, Project Code.
2. **Line items** — dynamic rows: Name (searchable against the catalogue),
Description, Qty, Unit, Size, Unit Price, GST Rate. As-you-type name search
shows matching products with per-vendor price hints
(`/api/products/search`).
3. **Terms & Conditions** — Delivery, Dispatch, Inspection, Transit Insurance,
Payment Terms, Others (all optional text → `tc*` fields).
4. **Documents** — drag-and-drop / browse uploader (see [File Storage](File-Storage)).
Footer: **Save as Draft** / **Submit for Approval** (and **Update & Resubmit**
when editing an `EDITS_REQUESTED` PO, which returns it to `MGR_REVIEW`).
Validation lives in `lib/validations/po.ts` (Zod), which also exports
`TC_FIXED_LINE` and `TC_DEFAULTS`. URL pre-select is supported:
`/po/new?vesselId=<id>`.
> **Form selector gotcha** (for tests): the PO form labels are visual-only — no
> `htmlFor`/`id` binding. Use `name`-attribute selectors
> (`input[name="title"]`, `select[name="vesselId"]`). See [Testing](Testing).
## Companies (multi-company invoicing)
A PO is billed under a sister **Company** (`PurchaseOrder.companyId`, optional).
The selected company's `name`, `code`, `gstNumber`, `address`, phone/mobile,
contact + invoice email, and invoice address populate the **exported PO header /
invoice block** — falling back to hardcoded Pelagia defaults when no company is
linked. Managed at `/admin/companies`.
## Accounting codes
The PO **Accounting Code** is a leaf in the 3-level `Account` hierarchy
(Top → Sub → Leaf, 6-digit numeric). Only leaf codes are selectable; the form
groups leaf codes by sub-category in a searchable, portal-rendered combobox
(`components/ui/searchable-select.tsx`). Line items can carry their own per-line
`accountId`. Seed data: `prisma/accounting-codes-data.ts`.
> "Accounting Code" replaces the older "Account" label. The **Cost Centre** is a
> separate concept — it is the Vessel. See [Glossary](Glossary).
## Payments
When Accounts records a payment, a **compulsory payment date**
(`PurchaseOrder.paymentDate`) is captured: the input defaults to today and
**rejects future dates** (validated in `processPaymentSchema` / `markPaid`).
Partial payments accumulate into `paidAmount` and hold the PO in
`PARTIALLY_PAID` until fully settled. The editable **`poDate`** drives the
exported "Date": `poDate ?? approvedAt ?? createdAt` (i.e. approval date once
approved, not creation).
## Export (PDF / XLSX)
`/api/po/[id]/export?format=pdf|xlsx` returns the PO as a document. It is
**gated to `MGR_APPROVED` and later** (a DRAFT export returns HTTP 403). The
approver's name (and uploaded signature) appears as signatory; company details
populate the header; optional line-item descriptions are included.
## Import PO → CLOSED
`/po/import` parses a Pelagia-format Excel PO (`lib/po-import-parser.ts`,
`/api/po/import`) and saves it **directly as `CLOSED`** — a historical record
that 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,
- keeps the **original PO number** verbatim.
The parser stops at "INSTRUCTIONS TO VENDORS", excludes T&C rows from line items,
extracts vendor name / PI quotation / place of delivery, and normalises a GST
rate written as `18` to the fraction `0.18`. Import is restricted to
MANAGER / SUPERUSER / ADMIN (TECHNICAL/ACCOUNTS → 403).

77
Roles-and-Permissions.md Normal file

@ -0,0 +1,77 @@
# Roles and Permissions
Authorisation is centralised in `lib/permissions.ts`. Server Actions call
`requirePermission(role, permission)` at the top before any DB write;
`hasPermission(role, permission)` gates UI and page segments. The PO state
machine adds a second gate (status + role) on top of permissions — see
[PO Lifecycle](PO-Lifecycle).
## The seven roles
| Role | Who they are | Core capability |
|---|---|---|
| **TECHNICAL** | Deck / engine crew | Create, submit, track own POs; confirm receipt; add (unverified) vendors |
| **MANNING** | Crew-management staff | Same as Technical |
| **ACCOUNTS** | Finance / accounts | Process payments (records ref + date); manage/verify vendors; view all POs |
| **MANAGER** | Department manager | Review/approve/reject/request-edits; edit line items pre-approval; manage cost centres, items, vendors, sites; analytics; import |
| **SUPERUSER** | Power user / ops lead | Combined Technical + Manning + Manager authority across PO actions |
| **AUDITOR** | Internal auditor | Read-only access to all POs and reports |
| **ADMIN** | System administrator | Manage users, companies, accounting codes, cost centres, sites, items, vendors |
Accounts are provisioned by an Admin or via Microsoft Entra SSO. There is **no
self-registration**. SSO-only users have no password and may set one from their
profile (any role can reach the profile page; only approvers upload a signature).
## Permission → role matrix
The exact `ROLE_PERMISSIONS` map in `lib/permissions.ts`. ✓ = granted.
| Permission | TECH | MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|---|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
| `create_po` | ✓ | ✓ | | ✓ | ✓ | | |
| `submit_po` | ✓ | ✓ | | ✓ | ✓ | | |
| `edit_own_draft_po` | ✓ | ✓ | | ✓ | ✓ | | |
| `view_own_pos` | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
| `view_all_pos` | | | ✓ | ✓ | ✓ | ✓ | ✓ |
| `approve_po` | | | | ✓ | ✓ | | |
| `reject_po` | | | | ✓ | ✓ | | |
| `request_edits` | | | | ✓ | ✓ | | |
| `request_vendor_id` | | | | ✓ | ✓ | | |
| `process_payment` | | | ✓ | ✓ | ✓ | | |
| `confirm_receipt` | ✓ | ✓ | | ✓ | ✓ | | |
| `view_analytics` | | | | ✓ | ✓ | ✓ | ✓ |
| `export_reports` | | | | ✓ | ✓ | ✓ | ✓ |
| `create_vendor` | ✓ | ✓ | ✓ | ✓ | ✓ | | ✓ |
| `manage_vendors` | | | ✓ | ✓ | | | ✓ |
| `manage_products` | | | | ✓ | | | ✓ |
| `manage_sites` | | | | ✓ | | | ✓ |
| `manage_vessels_accounts` | | | | ✓ | | | ✓ |
| `manage_users` | | | | | | | ✓ |
### Notes on the shipped behaviour
- **`create_vendor` is held by submitters too** — vendors they add are created
**unverified** and become verified when a PO closes/pays with them, on import,
or via a Manager/Accounts/Admin (`manage_vendors`). Only `manage_vendors`
holders may assign the formal verified `vendorId`.
- **MANAGER is broad**: it holds `process_payment`, `confirm_receipt`, and the
`manage_*` permissions for vendors, products, sites, and vessels/accounts — in
addition to the approval permissions.
- **ADMIN does not create/approve POs** — it is an administration role
(`manage_users` plus the catalogue/`manage_*` permissions) and can view/export.
- **AUDITOR is strictly read-only** (`view_*`, `export_reports`).
## Business rules layered on top
Beyond the permission map, server actions and the state machine enforce:
- A **vendor must be assigned** before a manager can approve a PO.
- Only **verified vendors** (with a `vendorId`) may be assigned via
`provideVendorId`.
- **Discarding** is only possible on `DRAFT` POs; the owner, MANAGER, or
SUPERUSER may discard.
- **Receipt confirmation** is the submitter's own PO (or SUPERUSER/MANAGER).
- **Payment date** is compulsory and cannot be in the future.
See the [Testing](Testing) page for the permission test matrix that pins these
rules down with integration tests.

125
Testing.md Normal file

@ -0,0 +1,125 @@
# Testing
Three layers, all run on PRs. Commands run from `App/`.
| Layer | Tool | Env | Command |
|---|---|---|---|
| Unit | Vitest | jsdom | `pnpm test` |
| Integration | Vitest | Node + real DB | `pnpm test:integration` |
| E2E | Playwright | Chromium + dev server | `pnpm test:e2e` |
```bash
pnpm test # unit (fast, no DB)
pnpm test:watch # unit watch mode
pnpm test:integration # integration (needs seeded DB)
pnpm test:all # unit + integration (pre-merge)
pnpm test:e2e # E2E (needs running dev server)
pnpm test:e2e:ui # Playwright interactive UI
# single files
pnpm test -- tests/unit/po-line-items-editor.test.tsx
pnpm test:integration -- tests/integration/create-po.test.ts
```
Tests live in `tests/unit/`, `tests/integration/`, `tests/e2e/`.
## Unit tests (Vitest, jsdom)
Cover pure logic and components:
| Subject | Cases |
|---|---|
| `lib/permissions.ts` | All 7 roles × key permissions; `requirePermission` throws |
| `lib/po-state-machine.ts` | `canPerformAction`, `getTransition`, `requiresNote`, `getAvailableActions` |
| `lib/po-import-parser.ts` | `cellStr`/`cellNum`/`parseSheet`/`parseWorkbook` (real + synthetic) |
| `lib/validations/po.ts` | `lineItemSchema`, `createPoSchema`, TC defaults |
| `components/po/po-line-items-editor.tsx` | edit/read-only modes, totals, add/remove |
| `components/po/po-status-badge.tsx` | all status labels |
| `lib/utils.ts` | `formatCurrency`, `formatDate`, `generatePoNumber`, status maps |
## Integration tests (Vitest + real DB)
Exercise Server Actions against a real Postgres test DB. They run **serially in a
single fork** (`poolOptions.forks.singleFork = true`) to avoid DB conflicts; each
suite isolates with a `PREFIX` constant and cleans up via
`afterEach(() => deletePosByTitle(PREFIX))`.
- Auth is mocked: `vi.mock("@/auth")` + `makeSession(userId, role)` from
`tests/integration/helpers.ts` (also `makePoForm()`, `fd()`).
- Side-effects mocked: `@/lib/notifier` (no email) and `next/cache`
(`revalidatePath`).
| File | Feature |
|---|---|
| `create-po.test.ts` | Draft, submit, line items, totals, notifications |
| `approval-actions.test.ts` | Approve / reject / request edits / vendor ID / resubmit |
| `payment-actions.test.ts` | Payment queue, mark paid |
| `discard-po.test.ts` | Owner/MANAGER/SUPERUSER discard; status guard; cascade |
| `vendor-approval.test.ts` | Vendor gate before approval; provide-vendor rules |
| `manager-po-creation.test.ts` | MANAGER create/submit/discard; ACCOUNTS denied |
| `products-search.test.ts` | Search API: auth, min-length, fields, max 10, Decimal serialised |
| `import-api.test.ts` | Excel import: auth (403/401), bad file (400), correct parse |
**Prerequisites:** a running Postgres with `DATABASE_URL`, schema applied
(`prisma migrate deploy` / `db push`), data seeded (`tsx prisma/seed.ts`).
## E2E tests (Playwright)
Browser-level checks against a live dev server + Postgres. The full E2E
framework reference is in `Docs/e2e-test-framework.md`; the feature-coverage
matrix is in `Docs/e2e-test-plan.md`.
### Config (`playwright.config.ts`)
```ts
testDir: "./tests/e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : 2, // >2 floods NextAuth bcrypt on login
reporter: "html",
use: { baseURL: "http://localhost:3000", trace: "on-first-retry" },
webServer: { command: "pnpm dev", url: "http://localhost:3000",
reuseExistingServer: !process.env.CI },
```
`workers: 2` locally is deliberate — every login does a bcrypt hash + DB
round-trip, and higher parallelism overwhelms the dev server, timing out the
login redirect.
### Shared helpers (`tests/e2e/helpers/login.ts`)
- `USERS` — the seed credentials (see [Getting Started](Getting-Started#seed-credentials)).
- `login(page, creds)` — fills `/login`, waits up to **20 s** for the redirect
(bcrypt + DB can exceed Playwright's 5 s default).
- `createDraftPo(page, title)` / `submitPo(page, title)` — minimal PO setup.
### Selector conventions (gotchas)
| Situation | Symptom | Fix |
|---|---|---|
| `getByLabel(/title/i)` on PO form | times out — no `htmlFor`/`id` | use `locator('input[name="title"]')` |
| `getByText("Technical")` on profile | strict-mode violation (also in header) | scope to `page.locator("dd span").filter(...)` |
| Many workers logging in | login times out | keep `workers ≤ 2`; consider `storageState` |
| `router.push` soft nav | URL still old after `networkidle` | use `page.waitForURL(pattern)` concurrently |
| Re-clicking an expanded row | row collapses, selectors vanish | expand **before** soft navigation; React state persists |
Specs log each assertion with a `✓` prefix and **skip gracefully** when seed
preconditions are absent (rather than failing hard).
### Coverage highlights
Rebrand, dashboard status badges, submit button, notification bell, export gate
(incl. HTTP 403 on DRAFT, 200 on approved), payment history, partial receipt,
vendor auto-verify, admin bordered buttons, profile + signature, inventory
Cheapest/★Closest tags, cart icon, item/vendor detail pages, mobile
(Desktop-Required overlay, manager cards, accounts payment, bottom nav),
edit-highlight diff. See `Docs/e2e-test-plan.md` for the full matrix and known
flaky/needs-fix items.
## Out of scope / known gaps
File upload to R2 (live creds; manual in staging), email body content (notifier
mocked), PDF/XLSX **content** (endpoint status checked, content asserted
manually), GstService lookup (tested independently), load testing,
cross-browser, automated a11y. CI runs unit + integration + E2E on every PR
(`workers: 1`, `retries: 2`, `forbidOnly: true` in CI mode).

86
Vendors-and-GST-Lookup.md Normal file

@ -0,0 +1,86 @@
# Vendors and GST Lookup
Vendors are suppliers a PO can be raised against. The model and its evolution
are on [Data Model](Data-Model#vendor--vendorcontact); this page covers the
verification lifecycle, distance-based sourcing, and the GST microservice that
backs GSTIN lookup.
## Vendor verification lifecycle
```
created (unverified) ──▶ verified
▲ ▲
│ submitter adds │ PO closes/pays with vendor
│ (create_vendor) │ • on import
│ │ • Manager/Accounts/Admin verifies
```
- **Submitters can create vendors** (`create_vendor` is held by TECH/MANNING too)
but they are created **`isVerified = false`**.
- A vendor becomes **verified** when a PO is closed/paid with it, on import, or
when a `manage_vendors` holder (Manager/Accounts/Admin) verifies it.
- Only `manage_vendors` holders may assign the formal verified `vendorId` code.
- **A vendor must be assigned before a manager can approve a PO**, and only
**verified** vendors may be assigned via `provideVendorId`.
A vendor carries `gstin`, `address`, `pincode`, geocoded `latitude`/`longitude`,
and a `VendorContact[]` list (name, role, mobile, email, isPrimary). Managed at
`/admin/vendors`; read view at `/inventory/vendors/[id]`.
## Distance-based sourcing
`pincode` is geocoded to `latitude`/`longitude` (`lib/geo.ts`). On the item
detail / items pages, selecting a **Site** re-sorts vendors by proximity to that
site; the nearest vendor gets a **★ Closest** tag and the lowest price gets a
**Cheapest** tag — computed independently so both can show at once, regardless
of the active sort. See [Inventory and Catalogue](Inventory-and-Catalogue).
## GSTIN lookup (GstService)
GSTIN lookup auto-fills a vendor's legal name, address, and pincode from the
public GST portal. Because that portal is CAPTCHA-protected, a small sidecar
microservice drives it with a headless browser.
### The microservice
`GstService/` — an **Express + Playwright** service
(`GstService/src/index.ts`). It opens a Playwright session against
`services.gst.gov.in`, surfaces the portal CAPTCHA, and submits the
GSTIN + CAPTCHA answer to return taxpayer details.
| Endpoint | Method | Purpose |
|---|---|---|
| `/health` | GET | Liveness check |
| `/captcha` | GET | Start a session; return a fresh CAPTCHA image + `sessionId` |
| `/captcha/:sessionId` | GET | Refresh the CAPTCHA within an existing session (no reload) |
| `/search` | POST | Submit GSTIN + CAPTCHA answer; return taxpayer data |
- **Port**: `PORT` env, default **3003**.
- Run: `cd GstService && pnpm dev` (or `npm run dev`) — `tsx watch src/index.ts`.
### The portal proxy
The Next.js app proxies to it so the browser never talks to the GST portal
directly:
- `/api/gst/captcha` (GET) → CAPTCHA image / session
- `/api/gst` (POST) → taxpayer search
The base URL is `GST_SERVICE_URL` (default `http://localhost:3003`).
### Lookup flow (in the Add/Edit Vendor form)
1. User types a 15-char GSTIN and clicks **Look up**.
2. The CAPTCHA image loads inline from the microservice.
3. User types the 6-digit CAPTCHA answer and clicks **Verify**.
4. The microservice submits to the GST portal and returns name, trade name,
registered address, and pincode.
5. The form auto-fills; location is geocoded silently from the pincode for
distance calculations.
Error states handled: wrong CAPTCHA (shows error, resets), session expired
(auto-reset / refresh CAPTCHA), GST portal unavailable.
> The GstService is tested independently and is **out of scope for the portal's
> E2E suite**. It is optional in local dev unless you are exercising GSTIN
> lookup.

2
_Footer.md Normal file

@ -0,0 +1,2 @@
---
*Pelagia Portal (PPMS) — internal purchase-order management. Self-hosted on `pms1`, live at `pms.pelagiamarine.com`. This wiki tracks the shipped product; authoritative sources are the repo code, `App/CLAUDE.md`, `Docs/`, and `CHANGELOG.md`.*

33
_Sidebar.md Normal file

@ -0,0 +1,33 @@
### Pelagia Portal (PPMS)
**Overview**
- [Home](Home)
- [Glossary](Glossary)
- [Changelog](Changelog)
- [Open Questions](Open-Questions)
**Build & Run**
- [Getting Started](Getting-Started)
- [Environment Variables](Environment-Variables)
**System**
- [Architecture](Architecture)
- [Data Model](Data-Model)
- [PO Lifecycle](PO-Lifecycle)
- [Roles and Permissions](Roles-and-Permissions)
**Product**
- [Feature Catalogue](Feature-Catalogue)
- [Pages and Navigation](Pages-and-Navigation)
- [Purchase Orders](Purchase-Orders)
- [Vendors and GST Lookup](Vendors-and-GST-Lookup)
- [Inventory and Catalogue](Inventory-and-Catalogue)
- [Notifications](Notifications)
- [File Storage](File-Storage)
**Quality**
- [Testing](Testing)
**Ops**
- [Deployment and Operations](Deployment-and-Operations)
- [Issue-to-Deploy Pipeline](Issue-to-Deploy-Pipeline)