diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..907f557 --- /dev/null +++ b/Architecture.md @@ -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`. diff --git a/Changelog.md b/Changelog.md new file mode 100644 index 0000000..8e3f3df --- /dev/null +++ b/Changelog.md @@ -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. diff --git a/Data-Model.md b/Data-Model.md new file mode 100644 index 0000000..738b97e --- /dev/null +++ b/Data-Model.md @@ -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). diff --git a/Deployment-and-Operations.md b/Deployment-and-Operations.md new file mode 100644 index 0000000..43e3a7f --- /dev/null +++ b/Deployment-and-Operations.md @@ -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@` 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. diff --git a/Environment-Variables.md b/Environment-Variables.md new file mode 100644 index 0000000..31c66f8 --- /dev/null +++ b/Environment-Variables.md @@ -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). diff --git a/Feature-Catalogue.md b/Feature-Catalogue.md new file mode 100644 index 0000000..56a9d46 --- /dev/null +++ b/Feature-Catalogue.md @@ -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). diff --git a/File-Storage.md b/File-Storage.md new file mode 100644 index 0000000..05d2ec4 --- /dev/null +++ b/File-Storage.md @@ -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/` → disk | +| Download | R2 presigned GET | `GET /api/files/dev/` (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/ + │── PUT /api/files/dev/ ───▶│── write to disk ────────▶│ + │── Server Action: link ────────▶│── INSERT PODocument ─────▶ (DB) +``` + +Downloads mirror this: `generateDownloadUrl` returns a `/api/files/dev/` +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). diff --git a/Getting-Started.md b/Getting-Started.md new file mode 100644 index 0000000..1ebf1af --- /dev/null +++ b/Getting-Started.md @@ -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= +# 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. diff --git a/Glossary.md b/Glossary.md new file mode 100644 index 0000000..fd0ad25 --- /dev/null +++ b/Glossary.md @@ -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 (Apr–Mar), 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. | diff --git a/Home.md b/Home.md new file mode 100644 index 0000000..cd29bf0 --- /dev/null +++ b/Home.md @@ -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. diff --git a/Inventory-and-Catalogue.md b/Inventory-and-Catalogue.md new file mode 100644 index 0000000..3cb36c7 --- /dev/null +++ b/Inventory-and-Catalogue.md @@ -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 product–vendor 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. diff --git a/Issue-to-Deploy-Pipeline.md b/Issue-to-Deploy-Pipeline.md new file mode 100644 index 0000000..791c056 --- /dev/null +++ b/Issue-to-Deploy-Pipeline.md @@ -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-.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. diff --git a/Notifications.md b/Notifications.md new file mode 100644 index 0000000..5982b46 --- /dev/null +++ b/Notifications.md @@ -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`). diff --git a/Open-Questions.md b/Open-Questions.md new file mode 100644 index 0000000..8d3819d --- /dev/null +++ b/Open-Questions.md @@ -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`. diff --git a/PO-Lifecycle.md b/PO-Lifecycle.md new file mode 100644 index 0000000..3877e80 --- /dev/null +++ b/PO-Lifecycle.md @@ -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 | diff --git a/Pages-and-Navigation.md b/Pages-and-Navigation.md new file mode 100644 index 0000000..7c05606 --- /dev/null +++ b/Pages-and-Navigation.md @@ -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. diff --git a/Purchase-Orders.md b/Purchase-Orders.md new file mode 100644 index 0000000..aff4d81 --- /dev/null +++ b/Purchase-Orders.md @@ -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 (Apr–Mar) rendered `YYYY-YY` + (e.g. Apr 2025–Mar 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=`. + +> **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). diff --git a/Roles-and-Permissions.md b/Roles-and-Permissions.md new file mode 100644 index 0000000..760bbf1 --- /dev/null +++ b/Roles-and-Permissions.md @@ -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. diff --git a/Testing.md b/Testing.md new file mode 100644 index 0000000..744c48d --- /dev/null +++ b/Testing.md @@ -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). diff --git a/Vendors-and-GST-Lookup.md b/Vendors-and-GST-Lookup.md new file mode 100644 index 0000000..d9417af --- /dev/null +++ b/Vendors-and-GST-Lookup.md @@ -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. diff --git a/_Footer.md b/_Footer.md new file mode 100644 index 0000000..dfc36b9 --- /dev/null +++ b/_Footer.md @@ -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`.* diff --git a/_Sidebar.md b/_Sidebar.md new file mode 100644 index 0000000..44db420 --- /dev/null +++ b/_Sidebar.md @@ -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)