Compare commits
57 commits
claude/iss
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdc7b2235 | |||
| 8ee077e548 | |||
| d9394e6afb | |||
| 4712fafb4b | |||
| e388ec917e | |||
| 9d08ca1990 | |||
| 6137d11e5f | |||
|
|
defd6e7a18 | ||
| bad67f66c4 | |||
| 74d20cd452 | |||
| 467f0ddea4 | |||
| 64fefd15a8 | |||
| 1071cb226f | |||
| a0c6ccba3c | |||
| 9f8297aa7e | |||
| 783051933a | |||
|
|
e9e618fda8 | ||
| 5bb3549142 | |||
| 2d6681014d | |||
| 1feb43186d | |||
| 794bbf8e7e | |||
| 520b1527e0 | |||
| aec6d2971f | |||
| 064e4ebf66 | |||
| 56a947747c | |||
| 0fe043e833 | |||
| 8406397602 | |||
| 938ff6df89 | |||
| debac55a8a | |||
| 8a614878d2 | |||
| b0140cf7e1 | |||
| 58a5a00594 | |||
| 157b58daf5 | |||
| 791e99f3fd | |||
| 859be8c8d0 | |||
| b3e6f6181a | |||
| 7713601be7 | |||
| f17df1ec6b | |||
| e31014d45c | |||
|
|
fdc3ebdac9 | ||
| b472c149b4 | |||
| b592358db0 | |||
|
|
e94c7f99a3 | ||
| 4da39fe5d1 | |||
| 7daf3091bc | |||
| 12e6d16061 | |||
|
|
4e6175153d | ||
| 3e711a171c | |||
| 080dafb473 | |||
| 23e5243442 | |||
| 64634ccb5e | |||
| 69901ba079 | |||
|
|
9adc93e54a | ||
|
|
d7be141589 | ||
| 600f637de2 | |||
| 464475f62c | |||
|
|
5d3b45a3a4 |
66 changed files with 2675 additions and 3000 deletions
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
17
.forgejo/PULL_REQUEST_TEMPLATE.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!-- All changes land via PR — no direct pushes to master. -->
|
||||||
|
|
||||||
|
## What & why
|
||||||
|
|
||||||
|
<!-- Brief summary of the change and the motivation / linked issue (e.g. Closes #NN). -->
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] **Tests** added or updated for this change — or it is a docs/config/automation-only PR (tests not applicable). Model: the integration test on `claude/issue-12` (prod-mirror DB, raw-SQL inserts, prefix-isolated, cleans up after itself).
|
||||||
|
- [ ] **Docs** updated where relevant (App/README.md, App/CLAUDE.md, Docs/, automation/README.md, CHANGELOG.md).
|
||||||
|
- [ ] `pnpm type-check` is clean and `pnpm test` passes (the PR check enforces both).
|
||||||
|
- [ ] Verified the change (how: unit/integration tests, or a dev server on port 3100 against the test DB).
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The "PR checks" workflow runs on every PR and hard-fails on: a code change with no
|
||||||
|
test change, any type-check error, or any failing unit test.
|
||||||
|
-->
|
||||||
|
|
@ -31,7 +31,13 @@ jobs:
|
||||||
pnpm build # includes prisma generate
|
pnpm build # includes prisma generate
|
||||||
pnpm db:migrate:deploy
|
pnpm db:migrate:deploy
|
||||||
|
|
||||||
pm2 restart ppms --update-env
|
# NOT --update-env: this job runs inside the Forgejo Actions runner, whose
|
||||||
|
# environment includes an ephemeral FORGEJO_TOKEN (the per-job token, revoked
|
||||||
|
# when the job ends). --update-env would inject it into ppms, where it shadows
|
||||||
|
# the real PAT from .env (Next.js does not override an already-set process.env
|
||||||
|
# var) and breaks the Report Issue button once the job token expires. A plain
|
||||||
|
# restart re-execs ppms from the pm2 daemon's clean env, so .env wins.
|
||||||
|
pm2 restart ppms
|
||||||
echo "=== Deployed $TAG ==="
|
echo "=== Deployed $TAG ==="
|
||||||
|
|
||||||
- name: Verify portal responds
|
- name: Verify portal responds
|
||||||
|
|
|
||||||
58
.forgejo/workflows/pr-checks.yml
Normal file
58
.forgejo/workflows/pr-checks.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: PR checks
|
||||||
|
|
||||||
|
# Enforces the contribution policy on every PR into master (all gates hard):
|
||||||
|
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||||
|
# - type-check is clean across the whole project (tests included)
|
||||||
|
# - unit tests pass
|
||||||
|
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
checks:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Policy — code changes must include tests
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
base="${GITHUB_BASE_REF:-master}"
|
||||||
|
git fetch origin "$base" --depth=200 -q
|
||||||
|
changed=$(git diff --name-only "origin/$base...HEAD")
|
||||||
|
printf 'Changed files:\n%s\n\n' "$changed"
|
||||||
|
|
||||||
|
# "Code" = app source (pages, API routes, lib, components, hooks).
|
||||||
|
# Tests, prisma, config, docs, automation and .forgejo are exempt.
|
||||||
|
code=$(printf '%s\n' "$changed" | grep -E '^App/(app|lib|components|hooks)/' \
|
||||||
|
| grep -vE '(\.test\.|\.spec\.|/tests/)' || true)
|
||||||
|
tests=$(printf '%s\n' "$changed" | grep -E '(\.test\.|\.spec\.|/tests/)' || true)
|
||||||
|
|
||||||
|
if [ -n "$code" ] && [ -z "$tests" ]; then
|
||||||
|
echo "::error::Code changed but no test files changed."
|
||||||
|
echo "Every code PR must add or update tests (model: the claude/issue-12 integration test)."
|
||||||
|
echo "If a test is genuinely not applicable, say why in the PR description so a reviewer can override."
|
||||||
|
printf '\nCode files without accompanying tests:\n%s\n' "$code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK — test-presence policy satisfied."
|
||||||
|
|
||||||
|
- name: Type-check (no errors)
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
cd App
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm db:generate # prisma client types (no DB connection needed)
|
||||||
|
pnpm type-check # whole project, tests included — must be clean
|
||||||
|
|
||||||
|
- name: Unit tests
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
||||||
27
.forgejo/workflows/staging.yml
Normal file
27
.forgejo/workflows/staging.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Refresh staging
|
||||||
|
|
||||||
|
# Rebuilds the pms1 staging instance (pm2 `ppms-staging`, port 3200) to the latest
|
||||||
|
# master on every merge to master, so staging always mirrors the trunk for
|
||||||
|
# smoke-testing before a release tag. Also runnable on demand (workflow_dispatch).
|
||||||
|
# See automation/README.md > "Staging".
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
# Only one staging refresh at a time; a newer master push cancels an in-flight build
|
||||||
|
# (staging-up.sh always checks out the latest origin/master, so the newest wins).
|
||||||
|
concurrency:
|
||||||
|
group: refresh-staging
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Rebuild staging on latest master
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
"$HOME/issue-watcher/staging-up.sh"
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Shell scripts must keep LF endings — they run on Linux (pms1).
|
||||||
|
*.sh text eol=lf
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -38,4 +38,7 @@ Thumbs.db
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude
|
.claude
|
||||||
App/.claude
|
App/.claude
|
||||||
|
|
||||||
|
# Nested wiki working clone (separate repo: pelagia-portal.wiki.git)
|
||||||
|
pelagia-portal.wiki/
|
||||||
|
|
@ -54,3 +54,8 @@ GST_SERVICE_URL=http://localhost:3003
|
||||||
FORGEJO_URL=https://git.pelagiamarine.com
|
FORGEJO_URL=https://git.pelagiamarine.com
|
||||||
FORGEJO_REPO=shad0w/pelagia-portal
|
FORGEJO_REPO=shad0w/pelagia-portal
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
|
|
||||||
|
# ── Non-production banner ─────────────────────────────────────
|
||||||
|
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
|
||||||
|
# Leave UNSET in production. Staging sets this automatically.
|
||||||
|
# NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,20 @@ Internal purchase order management system for a maritime company. Full-stack Nex
|
||||||
|
|
||||||
```
|
```
|
||||||
DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
|
DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
|
||||||
↓↑
|
↓↑ ↕ ↕
|
||||||
EDITS_REQUESTED / REJECTED / VENDOR_ID_PENDING
|
EDITS_REQUESTED / REJECTED PARTIALLY_PAID PARTIALLY_CLOSED
|
||||||
|
/ VENDOR_ID_PENDING
|
||||||
```
|
```
|
||||||
|
|
||||||
Every status change is validated against the state machine and recorded as a `POAction` row (audit trail).
|
Partial payments (`PARTIALLY_PAID`) and partial receipts (`PARTIALLY_CLOSED`) loop until the full amount/quantity is settled. Imported POs are created directly in `CLOSED`. Every status change is validated against the state machine and recorded as a `POAction` row (audit trail).
|
||||||
|
|
||||||
### Role-Based Permissions
|
### Role-Based Permissions
|
||||||
|
|
||||||
`lib/permissions.ts` defines `hasPermission(role, permission)` and `requirePermission(role, permission)`. Roles: `TECHNICAL`, `MANNING`, `ACCOUNTS`, `MANAGER`, `SUPERUSER`, `AUDITOR`, `ADMIN`.
|
`lib/permissions.ts` defines `hasPermission(role, permission)` and `requirePermission(role, permission)`. Roles: `TECHNICAL`, `MANNING`, `ACCOUNTS`, `MANAGER`, `SUPERUSER`, `AUDITOR`, `ADMIN`. Permissions include (non-exhaustive): `create_po`, `approve_po`, `process_payment`, `confirm_receipt`, `create_vendor`, `manage_vendors`, `manage_products`, `manage_sites`, `manage_vessels_accounts`, `manage_users`. `create_vendor` is held by submitters too; `manage_*` by Manager/Admin.
|
||||||
|
|
||||||
**Pattern:** Server Actions call `requirePermission()` at the top before any DB write.
|
**Pattern:** Server Actions call `requirePermission()` (or `hasPermission()`) at the top before any DB write.
|
||||||
|
|
||||||
|
**Auth:** NextAuth v5 with a Microsoft Entra SSO provider **and** a credentials provider. SSO-only users have no `passwordHash` (it is nullable) — the profile page lets them optionally set one, and is reachable by every role. Only approvers (`approve_po`) can upload a signature.
|
||||||
|
|
||||||
### Key Directories
|
### Key Directories
|
||||||
|
|
||||||
|
|
@ -74,15 +77,46 @@ Every status change is validated against the state machine and recorded as a `PO
|
||||||
|
|
||||||
### Cost Centre Model
|
### Cost Centre Model
|
||||||
|
|
||||||
A PO's "cost centre" is either a **Vessel** or a **Site**. `PurchaseOrder` has both `vesselId String?` (nullable) and `siteId String?` — exactly one is set.
|
A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId` is **required**. POs no longer reference a Site as a cost centre — that earlier dual Vessel-or-Site design was removed.
|
||||||
|
|
||||||
**Form encoding:** All PO creation/edit forms use a `costCentreRef` field with values `v:<vesselId>` (vessel) or `s:<siteId>` (site). Server actions parse this to set the correct FK.
|
**Form field:** PO create/edit/import forms use a plain `vesselId` select (no more `costCentreRef` encoding).
|
||||||
|
|
||||||
**Display pattern:** `po.vessel?.name ?? po.site?.name ?? "—"` everywhere a cost centre name is shown.
|
**Display pattern:** `po.vessel?.name ?? "—"`.
|
||||||
|
|
||||||
**URL pre-select:** `/po/new?costCentreRef=v:<id>` or `?costCentreRef=s:<id>`.
|
**URL pre-select:** `/po/new?vesselId=<id>`.
|
||||||
|
|
||||||
**Terminology:** Admin pages use the real entity names (Vessel Management, Sites). PO-facing pages use "Cost Centre" for the combined concept. Budget heads are labelled "Accounting Code" (not "Account").
|
**Terminology:** "Vessel" is surfaced as **"Cost Centre"** everywhere in the UI, including the admin page (`/admin/vessels` → "Cost Centre Management"). `Site` still exists as a separate construct (used for vendor-distance and inventory), but is not a PO cost centre. Budget heads are labelled "Accounting Code" (not "Account").
|
||||||
|
|
||||||
|
### Accounting Code Hierarchy
|
||||||
|
|
||||||
|
`Account` is a self-referential 3-level tree via `parentId` (`AccountHierarchy` relation): **Top Category (6-digit, e.g. `100000`) → Sub-Category (`100100`) → Leaf Item (`100101`)**. Codes are 6-digit numeric strings. Seed data lives in `prisma/accounting-codes-data.ts`.
|
||||||
|
|
||||||
|
- **Only leaf items** (accounts with no children) are selectable on a PO.
|
||||||
|
- PO forms group leaf codes by their sub-category in a searchable dropdown (`components/ui/searchable-select.tsx`, a portal-rendered combobox used in the line-items editor and the main accounting-code field).
|
||||||
|
|
||||||
|
### Companies (multi-company invoicing)
|
||||||
|
|
||||||
|
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
|
||||||
|
|
||||||
|
### PO Numbering (`lib/po-number.ts`)
|
||||||
|
|
||||||
|
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
||||||
|
|
||||||
|
### Payments
|
||||||
|
|
||||||
|
When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation).
|
||||||
|
|
||||||
|
### Vendors
|
||||||
|
|
||||||
|
`Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code).
|
||||||
|
|
||||||
|
### Inventory (feature-flagged)
|
||||||
|
|
||||||
|
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
|
||||||
|
|
||||||
|
### Import → Closed
|
||||||
|
|
||||||
|
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
||||||
|
|
@ -95,7 +129,31 @@ NEXTAUTH_SECRET # Required always
|
||||||
NEXTAUTH_URL # Required always (e.g., http://localhost:3000)
|
NEXTAUTH_URL # Required always (e.g., http://localhost:3000)
|
||||||
DATABASE_URL # PostgreSQL connection string
|
DATABASE_URL # PostgreSQL connection string
|
||||||
|
|
||||||
|
AZURE_AD_CLIENT_ID, AZURE_AD_CLIENT_SECRET, AZURE_AD_TENANT_ID
|
||||||
|
# Microsoft Entra SSO (prod). auth.ts reads them at module
|
||||||
|
# load — set placeholders in non-SSO/dev envs so it boots.
|
||||||
|
|
||||||
# Optional in dev (defaults to local storage + console email):
|
# Optional in dev (defaults to local storage + console email):
|
||||||
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL
|
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL
|
||||||
RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
|
RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
|
||||||
|
|
||||||
|
# Report Issue button (lib/forgejo.ts); token needs write:issue:
|
||||||
|
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
||||||
|
|
||||||
|
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
||||||
|
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
|
||||||
|
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Operations & automation
|
||||||
|
|
||||||
|
This repo runs a self-hosted issue-to-deploy pipeline on the `pms1` server (Forgejo +
|
||||||
|
headless Claude Code). See [`../automation/README.md`](../automation/README.md). Relevant
|
||||||
|
when working in this codebase:
|
||||||
|
|
||||||
|
- The **Report Issue** button (portal header) files a Forgejo issue; a watcher triages it
|
||||||
|
and, for auto-fixable ones, implements a fix and opens a PR. Deploys are gated on a
|
||||||
|
human merging the PR and pushing a `vX.Y.Z` tag.
|
||||||
|
- Automated fixes and the **staging** instance run against `pelagia_test`, a **daily mirror
|
||||||
|
of the production database**, in dev mode (console email, local storage). Migrations are
|
||||||
|
applied to it, so its schema tracks `master`. Never assume an empty DB — it holds prod-like data.
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,15 @@ R2_PUBLIC_URL=https://<bucket>.<account>.r2.cloudflarestorage.com
|
||||||
RESEND_API_KEY=re_<your key>
|
RESEND_API_KEY=re_<your key>
|
||||||
EMAIL_FROM=noreply@yourdomain.com
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
EMAIL_FROM_NAME="Pelagia Portal"
|
EMAIL_FROM_NAME="Pelagia Portal"
|
||||||
|
|
||||||
|
# Report Issue button -> files a Forgejo issue (optional; token needs write:issue)
|
||||||
|
FORGEJO_URL=https://git.example.com
|
||||||
|
FORGEJO_REPO=owner/repo
|
||||||
|
FORGEJO_TOKEN=<forgejo access token>
|
||||||
|
|
||||||
|
# Non-prod banner (leave UNSET in production). When set, a fixed
|
||||||
|
# "INTERNAL DEV / STAGING - NOT PRODUCTION" banner is shown.
|
||||||
|
# NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run database migrations
|
### 2. Run database migrations
|
||||||
|
|
@ -124,6 +133,8 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
||||||
pnpm db:migrate:deploy # runs prisma migrate deploy (safe for production)
|
pnpm db:migrate:deploy # runs prisma migrate deploy (safe for production)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Always run migrations before the new build serves traffic.** `pnpm build` only runs `prisma generate` (which updates the TypeScript client) — it does **not** apply migrations. Deploying new code whose client expects a column the DB doesn't have yet produces `P2022 … column does not exist` errors at runtime. The release workflow (`.forgejo/workflows/deploy.yml`) runs `migrate deploy` as part of the deploy; for manual deploys, run it (and restart) before/with the swap.
|
||||||
|
|
||||||
### 3. Build and start
|
### 3. Build and start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -135,6 +146,27 @@ The app listens on port 3000 by default. Point your reverse proxy (nginx, Caddy,
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Operations & Automation
|
||||||
|
|
||||||
|
This repo carries its own self-hosted **issue-to-deploy pipeline** (Forgejo + Claude Code
|
||||||
|
on the `pms1` server). The full design and runbook live in
|
||||||
|
**[`../automation/README.md`](../automation/README.md)**. In short:
|
||||||
|
|
||||||
|
- **Report Issue button** (portal header) files a Forgejo issue tagged `portal`.
|
||||||
|
- A **watcher** triages each issue (Claude posts a requirements breakdown and routes it
|
||||||
|
to `claude-queue` or `interactive`), then for queued issues implements a fix and opens a PR.
|
||||||
|
- Merging a PR and pushing a **release tag (`vX.Y.Z`)** triggers a Forgejo Actions runner
|
||||||
|
that deploys to production.
|
||||||
|
- A **staging instance** (`automation/staging-up.sh`, pm2 `ppms-staging` on port 3200,
|
||||||
|
SSH-tunnel only) runs the latest `master` against a daily **prod-mirror test DB**
|
||||||
|
(`pelagia_test`) for smoke testing before tagging a release.
|
||||||
|
|
||||||
|
Operational scripts live under [`../automation/`](../automation/): `claude-issue-watcher.sh`
|
||||||
|
(watcher), `refresh-test-db.sh` (nightly test-DB refresh), `staging-up.sh` (staging),
|
||||||
|
and `staging-tunnel.cmd` (Windows tunnel launcher).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database Management
|
## Database Management
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|
|
@ -142,7 +174,8 @@ The app listens on port 3000 by default. Point your reverse proxy (nginx, Caddy,
|
||||||
| `pnpm db:migrate` | Create and run a new migration (dev only) |
|
| `pnpm db:migrate` | Create and run a new migration (dev only) |
|
||||||
| `pnpm db:migrate:deploy` | Apply pending migrations without prompting (CI/production) |
|
| `pnpm db:migrate:deploy` | Apply pending migrations without prompting (CI/production) |
|
||||||
| `pnpm db:push` | Push schema changes without a migration file (prototyping only) |
|
| `pnpm db:push` | Push schema changes without a migration file (prototyping only) |
|
||||||
| `pnpm db:seed` | Seed sample data |
|
| `pnpm db:seed` | Seed sample/demo data (dev) |
|
||||||
|
| `pnpm db:seed:prod` | Seed real production reference data — users, companies, cost centres, sites, and the full accounting-code hierarchy (idempotent) |
|
||||||
| `pnpm db:studio` | Open Prisma Studio GUI |
|
| `pnpm db:studio` | Open Prisma Studio GUI |
|
||||||
| `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) |
|
| `pnpm db:reset` | Drop and recreate the database, then re-seed (dev only) |
|
||||||
|
|
||||||
|
|
@ -205,12 +238,21 @@ pelagia-portal/
|
||||||
|
|
||||||
| Role | Description |
|
| Role | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Technical | Deck/engine crew — create and submit POs, confirm receipt |
|
| Technical | Deck/engine crew — create and submit POs, confirm receipt, add (unverified) vendors |
|
||||||
| Manning | Crew-management staff — same as Technical |
|
| Manning | Crew-management staff — same as Technical |
|
||||||
| Manager | Review, approve, reject, request edits |
|
| Manager | Review, approve, reject, request edits; manage cost centres, items, vendors |
|
||||||
| Accounts | Process payment for approved POs |
|
| Accounts | Process payment for approved POs (records payment reference + date); manage vendors |
|
||||||
| SuperUser | Combined Technical + Manning + Manager authority |
|
| SuperUser | Combined Technical + Manning + Manager authority |
|
||||||
| Auditor | Read-only access to all records and reports |
|
| Auditor | Read-only access to all records and reports |
|
||||||
| Admin | Manage users, vessels, accounts, and vendors |
|
| Admin | Manage users, companies, accounting codes, cost centres, sites, items, and vendors |
|
||||||
|
|
||||||
User accounts are provisioned by an Admin; there is no self-registration.
|
User accounts are provisioned by an Admin (or via Microsoft Entra SSO); there is no self-registration. SSO-only users have no password and may optionally set one from their profile.
|
||||||
|
|
||||||
|
## Domain Concepts
|
||||||
|
|
||||||
|
- **Cost Centre** — a PO is raised against a **Vessel** (surfaced as "Cost Centre" in the UI). Required on every PO.
|
||||||
|
- **Company** — the sister company a PO is billed under (e.g. PMS, HNR, DEI). Its GST/address details appear on the exported PO.
|
||||||
|
- **Accounting Code** — a 3-level hierarchy of 6-digit codes (Top Category → Sub-Category → Leaf). Only leaf codes are selectable on a PO.
|
||||||
|
- **PO Number** — auto-formatted `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); imported POs keep their original number.
|
||||||
|
- **Vendors** — submitters can add vendors; they stay *unverified* until a PO closes with them or a Manager/Accounts/Admin verifies them.
|
||||||
|
- **Import PO** (Manager/SuperUser) — uploads a Pelagia-format Excel PO straight into `CLOSED`, auto-creating the vendor and any new items.
|
||||||
|
|
|
||||||
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal file
39
App/app/(portal)/admin/companies/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CompanyForm } from "../../company-form";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Edit Company" };
|
||||||
|
|
||||||
|
export default async function EditCompanyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const c = await db.company.findUnique({ where: { id } });
|
||||||
|
if (!c) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CompanyForm
|
||||||
|
company={{
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
code: c.code,
|
||||||
|
gstNumber: c.gstNumber,
|
||||||
|
address: c.address,
|
||||||
|
telephone: c.telephone,
|
||||||
|
mobile: c.mobile,
|
||||||
|
email: c.email,
|
||||||
|
invoiceEmail: c.invoiceEmail,
|
||||||
|
invoiceAddress: c.invoiceAddress,
|
||||||
|
logoUrl: c.logoKey ? await generateDownloadUrl(c.logoKey) : null,
|
||||||
|
stampUrl: c.stampKey ? await generateDownloadUrl(c.stampKey) : null,
|
||||||
|
isActive: c.isActive,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,21 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { buildCompanyAssetKey, uploadBuffer } from "@/lib/storage";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
type ActionResult = { ok: true } | { error: string };
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
// Branding assets (logo + stamp) shown on exported POs.
|
||||||
|
const ASSET_MIME: Record<string, string> = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/jpg": "jpg",
|
||||||
|
"image/webp": "webp",
|
||||||
|
};
|
||||||
|
const ASSET_MAX_BYTES = 4 * 1024 * 1024; // 4 MB — banners/seals can be larger than signatures
|
||||||
|
|
||||||
const companySchema = z.object({
|
const companySchema = z.object({
|
||||||
name: z.string().min(1, "Company name is required"),
|
name: z.string().min(1, "Company name is required"),
|
||||||
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
code: z.string().min(1, "Company code is required").max(10, "Code must be ≤ 10 characters").regex(/^[A-Z0-9]+$/i, "Code must be letters/numbers only").optional(),
|
||||||
|
|
@ -20,7 +30,7 @@ const companySchema = z.object({
|
||||||
invoiceAddress: z.string().optional(),
|
invoiceAddress: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createCompany(formData: FormData): Promise<ActionResult> {
|
export async function createCompany(formData: FormData): Promise<{ ok: true; id: string } | { error: string }> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
return { error: "Unauthorized" };
|
return { error: "Unauthorized" };
|
||||||
|
|
@ -44,11 +54,11 @@ export async function createCompany(formData: FormData): Promise<ActionResult> {
|
||||||
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
|
const conflict = await db.company.findFirst({ where: { code: { equals: code, mode: "insensitive" } } });
|
||||||
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
|
if (conflict) return { error: `Code "${code.toUpperCase()}" is already used by another company.` };
|
||||||
}
|
}
|
||||||
await db.company.create({
|
const created = await db.company.create({
|
||||||
data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
|
data: { name, code: code?.toUpperCase() ?? null, gstNumber: gstNumber ?? null, address: address ?? null, telephone: telephone ?? null, mobile: mobile ?? null, email: email || null, invoiceEmail: invoiceEmail || null, invoiceAddress: invoiceAddress ?? null },
|
||||||
});
|
});
|
||||||
revalidatePath("/admin/companies");
|
revalidatePath("/admin/companies");
|
||||||
return { ok: true };
|
return { ok: true, id: created.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
export async function updateCompany(formData: FormData): Promise<ActionResult> {
|
||||||
|
|
@ -98,6 +108,58 @@ export async function deleteCompany(id: string): Promise<ActionResult> {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Branding assets (logo + stamp) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function uploadCompanyAsset(formData: FormData): Promise<ActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyId = formData.get("companyId") as string | null;
|
||||||
|
const type = formData.get("type") as string | null;
|
||||||
|
if (!companyId) return { error: "Company ID is required" };
|
||||||
|
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||||
|
|
||||||
|
const company = await db.company.findUnique({ where: { id: companyId }, select: { id: true } });
|
||||||
|
if (!company) return { error: "Company not found" };
|
||||||
|
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
if (!file || file.size === 0) return { error: "No file provided" };
|
||||||
|
if (file.size > ASSET_MAX_BYTES) return { error: "Image must be under 4 MB" };
|
||||||
|
|
||||||
|
const ext = ASSET_MIME[file.type];
|
||||||
|
if (!ext) return { error: "Image must be a PNG, JPG, or WebP" };
|
||||||
|
|
||||||
|
const key = buildCompanyAssetKey(companyId, type, ext);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await uploadBuffer(key, buffer, file.type);
|
||||||
|
|
||||||
|
await db.company.update({
|
||||||
|
where: { id: companyId },
|
||||||
|
data: type === "logo" ? { logoKey: key } : { stampKey: key },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/companies");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCompanyAsset(companyId: string, type: "logo" | "stamp"): Promise<ActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (type !== "logo" && type !== "stamp") return { error: "Invalid asset type" };
|
||||||
|
|
||||||
|
await db.company.update({
|
||||||
|
where: { id: companyId },
|
||||||
|
data: type === "logo" ? { logoKey: null } : { stampKey: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/companies");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
export async function toggleCompanyActive(id: string): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
if (!session?.user || !hasPermission(session.user.role, "manage_vessels_accounts")) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { AddCompanyButton, EditCompanyButton } from "./company-form";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
|
@ -22,21 +23,20 @@ export type CompanyRow = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
|
function CompanyActionsMenu({ company }: { company: CompanyRow }) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const router = useRouter();
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [toggleOpen, setToggleOpen] = useState(false);
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RowActionsMenu>
|
<RowActionsMenu>
|
||||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
<RowActionsItem onClick={() => router.push(`/admin/companies/${company.id}/edit`)}>Edit</RowActionsItem>
|
||||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
{company.isActive ? "Deactivate" : "Activate"}
|
{company.isActive ? "Deactivate" : "Activate"}
|
||||||
</RowActionsItem>
|
</RowActionsItem>
|
||||||
<RowActionsSeparator />
|
<RowActionsSeparator />
|
||||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
</RowActionsMenu>
|
</RowActionsMenu>
|
||||||
<EditCompanyButton company={company} open={editOpen} onOpenChange={setEditOpen} />
|
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleteOpen} onOpenChange={setDeleteOpen}
|
open={deleteOpen} onOpenChange={setDeleteOpen}
|
||||||
label={company.name} onConfirm={() => deleteCompany(company.id)}
|
label={company.name} onConfirm={() => deleteCompany(company.id)}
|
||||||
|
|
@ -60,7 +60,10 @@ export function CompaniesTable({ companies }: { companies: CompanyRow[] }) {
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Company Management</h1>
|
||||||
<p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
|
<p className="text-sm text-neutral-500 mt-0.5">Sister companies used for invoicing and purchase orders</p>
|
||||||
</div>
|
</div>
|
||||||
<AddCompanyButton />
|
<Link href="/admin/companies/new"
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||||
|
+ Add Company
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
|
||||||
120
App/app/(portal)/admin/companies/company-branding-uploader.tsx
Normal file
120
App/app/(portal)/admin/companies/company-branding-uploader.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Upload, X } from "lucide-react";
|
||||||
|
import { uploadCompanyAsset, removeCompanyAsset } from "./actions";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
companyId: string;
|
||||||
|
type: "logo" | "stamp";
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
currentUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompanyBrandingUploader({ companyId, type, label, hint, currentUrl }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [removing, setRemoving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setError("");
|
||||||
|
setPreview(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpload() {
|
||||||
|
const file = inputRef.current?.files?.[0];
|
||||||
|
if (!file) { setError("Please select a file first"); return; }
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("companyId", companyId);
|
||||||
|
fd.append("type", type);
|
||||||
|
fd.append("file", file);
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await uploadCompanyAsset(fd);
|
||||||
|
setPending(false);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setPreview(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove() {
|
||||||
|
setRemoving(true);
|
||||||
|
setError("");
|
||||||
|
const result = await removeCompanyAsset(companyId, type);
|
||||||
|
setRemoving(false);
|
||||||
|
if ("error" in result) setError(result.error);
|
||||||
|
else { setPreview(null); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayUrl = preview ?? currentUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs font-medium text-neutral-700">{label}</p>
|
||||||
|
{currentUrl && !preview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={removing}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-danger-700 hover:text-danger-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
{removing ? "Removing…" : "Remove"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayUrl && (
|
||||||
|
<div className="rounded border border-neutral-200 bg-white p-2 inline-block">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={displayUrl} alt={label} className="max-h-16 max-w-full object-contain" />
|
||||||
|
{preview && <p className="text-[10px] text-neutral-400 mt-1">Preview — not yet saved</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative rounded-lg border-2 border-dashed border-neutral-300 bg-neutral-50 px-4 py-3 text-center cursor-pointer hover:border-primary-400 hover:bg-primary-50 transition-colors"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="mx-auto h-5 w-5 text-neutral-400 mb-1" />
|
||||||
|
<p className="text-xs text-neutral-600">Click to select image</p>
|
||||||
|
<p className="text-[10px] text-neutral-400 mt-0.5">{hint}</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/jpg,image/webp"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-danger-700 bg-danger-50 rounded px-2 py-1">{error}</p>}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Uploading…" : "Upload"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { createCompany, updateCompany } from "./actions";
|
import { createCompany, updateCompany } from "./actions";
|
||||||
|
import { CompanyBrandingUploader } from "./company-branding-uploader";
|
||||||
|
|
||||||
type CompanyRow = {
|
export type CompanyFormData = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string | null;
|
code: string | null;
|
||||||
|
|
@ -16,13 +18,15 @@ type CompanyRow = {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
invoiceEmail: string | null;
|
invoiceEmail: string | null;
|
||||||
invoiceAddress: string | null;
|
invoiceAddress: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
stampUrl: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
|
const LABEL = "block text-xs font-medium text-neutral-700 mb-1";
|
||||||
|
|
||||||
function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
function CompanyFormFields({ company }: { company?: CompanyFormData }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
|
@ -71,92 +75,79 @@ function CompanyFormFields({ company }: { company?: CompanyRow }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddCompanyButton() {
|
export function CompanyForm({ company }: { company?: CompanyFormData }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const isEdit = !!company?.id;
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault(); setPending(true); setError("");
|
e.preventDefault();
|
||||||
const result = await createCompany(new FormData(e.currentTarget));
|
setPending(true);
|
||||||
if ("error" in result) { setError(result.error); setPending(false); }
|
setError("");
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => setOpen(true)}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
|
||||||
+ Add Company
|
|
||||||
</button>
|
|
||||||
<AdminDialog title="Add Company" open={open} onClose={() => setOpen(false)}>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<CompanyFormFields />
|
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
|
||||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
|
||||||
<button type="submit" disabled={pending}
|
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
|
||||||
{pending ? "Creating…" : "Create Company"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</AdminDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditCompanyButton({
|
|
||||||
company,
|
|
||||||
open: controlledOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
company: CompanyRow;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const isControlled = controlledOpen !== undefined;
|
|
||||||
const open = isControlled ? controlledOpen : internalOpen;
|
|
||||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
||||||
e.preventDefault(); setPending(true); setError("");
|
|
||||||
const fd = new FormData(e.currentTarget);
|
const fd = new FormData(e.currentTarget);
|
||||||
fd.set("id", company.id);
|
|
||||||
const result = await updateCompany(fd);
|
if (isEdit) {
|
||||||
if ("error" in result) { setError(result.error); setPending(false); }
|
fd.set("id", company!.id);
|
||||||
else { setPending(false); setOpen(false); router.refresh(); }
|
const result = await updateCompany(fd);
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); return; }
|
||||||
|
router.push("/admin/companies");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const result = await createCompany(fd);
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); return; }
|
||||||
|
// Land on the edit page so the logo/stamp can be uploaded against the new company.
|
||||||
|
router.push(`/admin/companies/${result.id}/edit`);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="max-w-3xl">
|
||||||
{!isControlled && (
|
<Link href="/admin/companies" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-700 mb-3">
|
||||||
<button onClick={() => setOpen(true)}
|
<ArrowLeft className="h-3.5 w-3.5" /> Back to Companies
|
||||||
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
</Link>
|
||||||
Edit
|
<h1 className="text-2xl font-semibold text-neutral-900">{isEdit ? `Edit — ${company!.name}` : "Add Company"}</h1>
|
||||||
</button>
|
<p className="text-sm text-neutral-500 mt-0.5 mb-6">Sister company used for invoicing and purchase orders</p>
|
||||||
)}
|
|
||||||
<AdminDialog title={`Edit — ${company.name}`} open={open} onClose={() => setOpen(false)}>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||||
<CompanyFormFields company={company} />
|
<CompanyFormFields company={company} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
</div>
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
<div className="flex justify-end gap-3">
|
||||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
<Link href="/admin/companies"
|
||||||
<button type="submit" disabled={pending}
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
Cancel
|
||||||
{pending ? "Saving…" : "Save Changes"}
|
</Link>
|
||||||
</button>
|
<button type="submit" disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? (isEdit ? "Saving…" : "Creating…") : (isEdit ? "Save Changes" : "Create Company")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Branding (independent uploads; available once the company exists) ── */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-5 mt-6">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-800">Branding</h2>
|
||||||
|
<p className="text-xs text-neutral-400 mb-3">Logo and stamp shown on exported POs</p>
|
||||||
|
{isEdit ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<CompanyBrandingUploader
|
||||||
|
companyId={company!.id} type="logo" label="Logo"
|
||||||
|
hint="PNG, JPG or WebP — shown top-left. Max 4 MB"
|
||||||
|
currentUrl={company!.logoUrl}
|
||||||
|
/>
|
||||||
|
<CompanyBrandingUploader
|
||||||
|
companyId={company!.id} type="stamp" label="Stamp / Seal"
|
||||||
|
hint="PNG, JPG or WebP — shown in signatory block. Max 4 MB"
|
||||||
|
currentUrl={company!.stampUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
) : (
|
||||||
</AdminDialog>
|
<p className="text-xs text-neutral-400">Create the company first — you'll be taken to the edit page where you can upload a logo and stamp.</p>
|
||||||
</>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
App/app/(portal)/admin/companies/new/page.tsx
Normal file
15
App/app/(portal)/admin/companies/new/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { CompanyForm } from "../company-form";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Add Company" };
|
||||||
|
|
||||||
|
export default async function NewCompanyPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||||
|
|
||||||
|
return <CompanyForm />;
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
||||||
import { StatCard } from "@/components/dashboard/stat-card";
|
import { StatCard } from "@/components/dashboard/stat-card";
|
||||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -110,11 +110,14 @@ async function ManagerDashboard() {
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
|
||||||
|
|
||||||
const approvedStatuses = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "CLOSED"] as const;
|
const approvedStatuses = POST_APPROVAL_STATUSES;
|
||||||
|
|
||||||
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
const [awaitingCount, approvedThisMonth, totalSpendResult, recentApproved, vesselBreakdown, monthlyPos] = await Promise.all([
|
||||||
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
db.purchaseOrder.count({ where: { status: "MGR_REVIEW" } }),
|
||||||
db.purchaseOrder.count({ where: { status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } } }),
|
// POs approved this month — including those that have since moved past
|
||||||
|
// MGR_APPROVED into payment/delivery/closure. `approvedAt` is set once at
|
||||||
|
// approval and persists, so filter on it across all post-approval statuses.
|
||||||
|
db.purchaseOrder.count({ where: { status: { in: [...approvedStatuses] }, approvedAt: { gte: startOfMonth } } }),
|
||||||
db.purchaseOrder.aggregate({
|
db.purchaseOrder.aggregate({
|
||||||
_sum: { totalAmount: true },
|
_sum: { totalAmount: true },
|
||||||
where: { status: { in: [...approvedStatuses] } },
|
where: { status: { in: [...approvedStatuses] } },
|
||||||
|
|
@ -144,6 +147,10 @@ async function ManagerDashboard() {
|
||||||
|
|
||||||
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
|
const totalSpend = Number(totalSpendResult._sum.totalAmount ?? 0);
|
||||||
|
|
||||||
|
// Local YYYY-MM-DD for the first of this month, used to deep-link the
|
||||||
|
// "Approved This Month" card into the history page filtered by approval date.
|
||||||
|
const startOfMonthParam = `${startOfMonth.getFullYear()}-${String(startOfMonth.getMonth() + 1).padStart(2, "0")}-01`;
|
||||||
|
|
||||||
// Build monthly series for last 12 months
|
// Build monthly series for last 12 months
|
||||||
const monthlyMap: Record<string, number> = {};
|
const monthlyMap: Record<string, number> = {};
|
||||||
for (let i = 11; i >= 0; i--) {
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
|
@ -174,8 +181,8 @@ async function ManagerDashboard() {
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900 mb-6">Dashboard</h1>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" />
|
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent approved POs */}
|
{/* Recent approved POs */}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "", label: "All statuses" },
|
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
{ value: "SUBMITTED", label: "Submitted" },
|
{ value: "SUBMITTED", label: "Submitted" },
|
||||||
{ value: "MGR_REVIEW", label: "Pending Approval" },
|
{ value: "MGR_REVIEW", label: "Pending Approval" },
|
||||||
|
|
@ -27,24 +26,53 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
|
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||||
|
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
const [status, setStatus] = useState(sp.get("status") ?? "");
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
if (statusRef.current && !statusRef.current.contains(e.target as Node)) {
|
||||||
|
setStatusOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function toggleStatus(value: string) {
|
||||||
|
setStatuses((prev) =>
|
||||||
|
prev.includes(value) ? prev.filter((s) => s !== value) : [...prev, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
if (status) params.set("status", status);
|
for (const s of statuses) params.append("status", s);
|
||||||
router.push(`/history?${params.toString()}`);
|
router.push(`/history?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||||
router.push("/history");
|
router.push("/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || vesselId || status;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
statuses.length === 0
|
||||||
|
? "All statuses"
|
||||||
|
: statuses.length === 1
|
||||||
|
? (STATUSES.find((s) => s.value === statuses[0])?.label ?? statuses[0])
|
||||||
|
: `${statuses.length} statuses`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
|
@ -59,6 +87,16 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
<input type="date" value={dateTo} onChange={(e) => setDateTo(e.target.value)}
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved From</label>
|
||||||
|
<input type="date" value={approvedFrom} onChange={(e) => setApprovedFrom(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Approved To</label>
|
||||||
|
<input type="date" value={approvedTo} onChange={(e) => setApprovedTo(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
||||||
|
|
@ -67,12 +105,26 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="relative" ref={statusRef}>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||||
<select value={status} onChange={(e) => setStatus(e.target.value)}
|
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||||
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
className="flex w-full items-center justify-between rounded-lg border border-neutral-300 px-3 py-2 text-left text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
|
||||||
{STATUSES.map((s) => <option key={s.value} value={s.value}>{s.label}</option>)}
|
<span className={statuses.length === 0 ? "text-neutral-500" : "text-neutral-900"}>{statusLabel}</span>
|
||||||
</select>
|
<svg className="ml-2 h-4 w-4 shrink-0 text-neutral-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fillRule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.17l3.71-3.94a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{statusOpen && (
|
||||||
|
<div className="absolute z-10 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-neutral-200 bg-white py-1 shadow-lg">
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<label key={s.value} className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-sm hover:bg-neutral-50">
|
||||||
|
<input type="checkbox" checked={statuses.includes(s.value)} onChange={() => toggleStatus(s.value)}
|
||||||
|
className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/20" />
|
||||||
|
<span className="text-neutral-700">{s.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
approvedFrom?: string;
|
||||||
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string;
|
status?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,7 +29,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
||||||
|
|
||||||
const { dateFrom, dateTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -40,8 +42,19 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
|
|
@ -56,8 +69,10 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const exportParams = new URLSearchParams({ format: "csv" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
if (status) exportParams.set("status", status);
|
for (const s of statuses) exportParams.append("status", s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,12 @@ export async function confirmReceipt({
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
||||||
receipt: notes
|
receipt: notes
|
||||||
? { create: { storageKey: "", fileName: "no-file", notes } }
|
? {
|
||||||
|
upsert: {
|
||||||
|
create: { storageKey: "", fileName: "no-file", notes },
|
||||||
|
update: { notes },
|
||||||
|
},
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,22 @@ function fmtNum(n: number, dec = 2): string {
|
||||||
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
return n.toLocaleString("en-IN", { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed brand bar colour shown at the bottom of every exported PO (matches the sample PO).
|
||||||
|
const BRAND_BAR_COLOR = "#92D050";
|
||||||
|
|
||||||
|
function mimeForKey(key: string): string {
|
||||||
|
const ext = key.split(".").pop()?.toLowerCase();
|
||||||
|
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download a stored image and return it base64-encoded (or null if missing).
|
||||||
|
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
|
||||||
|
if (!key) return null;
|
||||||
|
const buf = await downloadBuffer(key);
|
||||||
|
if (!buf) return null;
|
||||||
|
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface Props { params: Promise<{ id: string }> }
|
interface Props { params: Promise<{ id: string }> }
|
||||||
|
|
@ -91,7 +107,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const gstAmt = taxable * gstRate;
|
const gstAmt = taxable * gstRate;
|
||||||
const li_ = li as typeof li & { name?: string };
|
const li_ = li as typeof li & { name?: string };
|
||||||
const desc = li_.name ?? li.description ?? "";
|
const desc = li_.name ?? li.description ?? "";
|
||||||
return { sn: i + 1, desc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
// When both name and description exist, include the optional description separately
|
||||||
|
const optionalDesc = li_.name && li.description ? li.description : "";
|
||||||
|
return { sn: i + 1, desc, optionalDesc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
|
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
|
||||||
|
|
@ -123,6 +141,10 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Company branding (logo top-left, stamp/seal in the signatory block)
|
||||||
|
const logoImg = await fetchImage(co?.logoKey);
|
||||||
|
const stampImg = await fetchImage(co?.stampKey);
|
||||||
|
|
||||||
const ext = po as {
|
const ext = po as {
|
||||||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||||||
requisitionNo?: string | null; requisitionDate?: Date | null;
|
requisitionNo?: string | null; requisitionDate?: Date | null;
|
||||||
|
|
@ -253,6 +275,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.mergeCells("A4:I4");
|
ws.mergeCells("A4:I4");
|
||||||
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
||||||
|
|
||||||
|
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
|
||||||
|
if (logoImg) {
|
||||||
|
const logoId = wb.addImage({
|
||||||
|
base64: logoImg.base64,
|
||||||
|
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||||
|
});
|
||||||
|
ws.addImage(logoId, {
|
||||||
|
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
|
||||||
|
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
|
||||||
|
editAs: "oneCell",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
// ══ ROW 5: PO Number & Date ══════════════════════════════════════════════
|
||||||
ws.getRow(5).height = 18;
|
ws.getRow(5).height = 18;
|
||||||
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
sc(5, 1, "Purchase Order No:", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
||||||
|
|
@ -349,15 +384,18 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
for (let idx = 0; idx < BODY_ROWS; idx++) {
|
for (let idx = 0; idx < BODY_ROWS; idx++) {
|
||||||
const r = HDR_ROW + 1 + idx;
|
const r = HDR_ROW + 1 + idx;
|
||||||
// Taller rows: long item names + potential description sub-line need room
|
// Taller rows: long item names + potential description sub-line need room
|
||||||
const descLen = (items[idx]?.desc ?? "").length;
|
const descLen = (items[idx]?.desc ?? "").length + (items[idx]?.optionalDesc ?? "").length;
|
||||||
ws.getRow(r).height = descLen > 40 ? 28 : 20;
|
ws.getRow(r).height = descLen > 40 || items[idx]?.optionalDesc ? 32 : 20;
|
||||||
const item = items[idx];
|
const item = items[idx];
|
||||||
const fillAlt = idx % 2 === 1
|
const fillAlt = idx % 2 === 1
|
||||||
? { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFFAFAFA" } }
|
? { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: "FFFAFAFA" } }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
sc(r, 1, item?.sn ?? null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
sc(r, 1, item?.sn ?? null, { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
||||||
sc(r, 2, item?.desc ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignL });
|
const xlsxDesc = item
|
||||||
|
? (item.optionalDesc ? `${item.desc}\n${item.optionalDesc}` : item.desc)
|
||||||
|
: "";
|
||||||
|
sc(r, 2, xlsxDesc, { font: fBase, fill: fillAlt, border: bordAll, align: alignL });
|
||||||
ws.mergeCells(`B${r}:C${r}`);
|
ws.mergeCells(`B${r}:C${r}`);
|
||||||
sc(r, 4, item?.unit ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
sc(r, 4, item?.unit ?? "", { font: fBase, fill: fillAlt, border: bordAll, align: alignC });
|
||||||
sc(r, 5, item ? item.qty : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
sc(r, 5, item ? item.qty : null, { font: fBase, fill: fillAlt, border: bordAll, align: alignR, numFmt: NUM_FMT });
|
||||||
|
|
@ -440,6 +478,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
ws.getRow(SIG_ROW + 2).height = 14;
|
ws.getRow(SIG_ROW + 2).height = 14;
|
||||||
ws.getRow(SIG_ROW + 3).height = 14;
|
ws.getRow(SIG_ROW + 3).height = 14;
|
||||||
|
|
||||||
|
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
|
||||||
|
if (stampImg) {
|
||||||
|
const stampId = wb.addImage({
|
||||||
|
base64: stampImg.base64,
|
||||||
|
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||||
|
});
|
||||||
|
ws.addImage(stampId, {
|
||||||
|
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
|
||||||
|
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
|
||||||
|
editAs: "oneCell",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Right sig block (vendor)
|
// Right sig block (vendor)
|
||||||
const vName = po.vendor?.name ?? "";
|
const vName = po.vendor?.name ?? "";
|
||||||
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
||||||
|
|
@ -449,6 +500,14 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
sc(SIG_ROW + 2, 6, `For, ${vName}`, { font: fSmall, border: { left: thin(), bottom: thin(), right: thin() }, align: alignC });
|
||||||
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
ws.mergeCells(`F${SIG_ROW + 2}:I${SIG_ROW + 2}`);
|
||||||
|
|
||||||
|
// ══ Brand bar (full-width colour strip at the very bottom) ═══════════════
|
||||||
|
const BAR_ROW = SIG_ROW + 4;
|
||||||
|
const barArgb = "FF" + BRAND_BAR_COLOR.replace("#", "").toUpperCase();
|
||||||
|
const barFill = { type: "pattern" as const, pattern: "solid" as const, fgColor: { argb: barArgb } };
|
||||||
|
ws.getRow(BAR_ROW).height = 16;
|
||||||
|
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
||||||
|
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
||||||
|
|
||||||
// ── Serialise ─────────────────────────────────────────────────────────
|
// ── Serialise ─────────────────────────────────────────────────────────
|
||||||
const buf = await wb.xlsx.writeBuffer();
|
const buf = await wb.xlsx.writeBuffer();
|
||||||
const slug = po.poNumber.replace(/\//g, "-");
|
const slug = po.poNumber.replace(/\//g, "-");
|
||||||
|
|
@ -467,7 +526,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const itemRows = items.map((item, i) => `
|
const itemRows = items.map((item, i) => `
|
||||||
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
|
<tr style="background:${i % 2 === 0 ? "#fff" : "#fafafa"}">
|
||||||
<td style="text-align:center">${item.sn}</td>
|
<td style="text-align:center">${item.sn}</td>
|
||||||
<td>${item.desc}</td>
|
<td>${item.desc}${item.optionalDesc ? `<br/><span style="font-size:7.5pt;color:#666;font-style:italic">${item.optionalDesc}</span>` : ""}</td>
|
||||||
<td style="text-align:center">${item.unit}</td>
|
<td style="text-align:center">${item.unit}</td>
|
||||||
<td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td>
|
<td style="text-align:right">${fmtNum(item.qty, item.qty % 1 === 0 ? 0 : 3)}</td>
|
||||||
<td style="text-align:right">${fmtNum(item.unitPrice)}</td>
|
<td style="text-align:right">${fmtNum(item.unitPrice)}</td>
|
||||||
|
|
@ -501,9 +560,20 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
color: #111;
|
color: #111;
|
||||||
margin: 10mm 12mm;
|
margin: 10mm 12mm;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
|
.header-band { position: relative; }
|
||||||
|
.co-logo {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
max-height: 52px;
|
||||||
|
max-width: 92px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.co-name {
|
.co-name {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13pt;
|
font-size: 13pt;
|
||||||
|
|
@ -563,6 +633,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
/* ── Signatures ── */
|
/* ── Signatures ── */
|
||||||
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
.sig { display: flex; justify-content: space-between; margin-top: 14px; }
|
||||||
.sig-box {
|
.sig-box {
|
||||||
|
position: relative;
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
width: 44%;
|
width: 44%;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
|
|
@ -574,9 +645,26 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
}
|
}
|
||||||
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
.sig-name { font-weight: bold; font-size: 9pt; min-height: 32px; }
|
||||||
.sig-sub { font-size: 7.5pt; }
|
.sig-sub { font-size: 7.5pt; }
|
||||||
|
.sig-stamp {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
top: 4px;
|
||||||
|
max-height: 66px;
|
||||||
|
max-width: 88px;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.spacer { margin: 4px 0; }
|
.spacer { margin: 4px 0; }
|
||||||
|
|
||||||
|
/* ── Brand bar (bottom) ── */
|
||||||
|
.brand-bar {
|
||||||
|
height: 14px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 12px;
|
||||||
|
background: ${BRAND_BAR_COLOR};
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.no-print { display: none; }
|
.no-print { display: none; }
|
||||||
body { margin: 8mm 10mm; }
|
body { margin: 8mm 10mm; }
|
||||||
|
|
@ -593,9 +681,12 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||||
<div class="co-name">${CO_NAME}</div>
|
<div class="header-band">
|
||||||
<div class="co-addr">${CO_ADDR}</div>
|
${logoImg ? `<img class="co-logo" src="data:${logoImg.mime};base64,${logoImg.base64}" alt="Logo" />` : ""}
|
||||||
<div class="co-tel">${CO_TEL}</div>
|
<div class="co-name">${CO_NAME}</div>
|
||||||
|
<div class="co-addr">${CO_ADDR}</div>
|
||||||
|
<div class="co-tel">${CO_TEL}</div>
|
||||||
|
</div>
|
||||||
<div class="po-title">PURCHASE ORDER</div>
|
<div class="po-title">PURCHASE ORDER</div>
|
||||||
|
|
||||||
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
<!-- ── PO Meta & Quotation ──────────────────────────────────── -->
|
||||||
|
|
@ -713,6 +804,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
<!-- ── Signatures ────────────────────────────────────────────── -->
|
<!-- ── Signatures ────────────────────────────────────────────── -->
|
||||||
<div class="sig">
|
<div class="sig">
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
|
${stampImg ? `<img class="sig-stamp" src="data:${stampImg.mime};base64,${stampImg.base64}" alt="Stamp" />` : ""}
|
||||||
${signatureBase64
|
${signatureBase64
|
||||||
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
? `<img src="data:${signatureMime};base64,${signatureBase64}" alt="Signature" style="max-height:48px;max-width:180px;object-fit:contain;display:block;margin:0 auto 4px;" />`
|
||||||
: `<div class="sig-name">${approvedBy}</div>`
|
: `<div class="sig-name">${approvedBy}</div>`
|
||||||
|
|
@ -720,7 +812,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
<div>
|
<div>
|
||||||
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
<div class="sig-sub" style="font-weight:bold">${approvedBy}</div>
|
||||||
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
<div class="sig-sub">Authorized Signatory & Stamp</div>
|
||||||
<div class="sig-sub">For, Pelagia Marine Services Pvt. Ltd.</div>
|
<div class="sig-sub">For, ${CO_NAME}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sig-box">
|
<div class="sig-box">
|
||||||
|
|
@ -732,6 +824,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||||
|
<div class="brand-bar"></div>
|
||||||
|
|
||||||
<script>window.onload = function() { window.print(); };</script>
|
<script>window.onload = function() { window.print(); };</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,10 @@ export async function GET(request: NextRequest) {
|
||||||
const format = sp.get("format") ?? "csv";
|
const format = sp.get("format") ?? "csv";
|
||||||
const dateFrom = sp.get("dateFrom");
|
const dateFrom = sp.get("dateFrom");
|
||||||
const dateTo = sp.get("dateTo");
|
const dateTo = sp.get("dateTo");
|
||||||
|
const approvedFrom = sp.get("approvedFrom");
|
||||||
|
const approvedTo = sp.get("approvedTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const vesselId = sp.get("vesselId");
|
||||||
const status = sp.get("status");
|
const statuses = sp.getAll("status").filter(Boolean);
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -38,8 +40,18 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (vesselId) where.vesselId = vesselId;
|
||||||
if (status) where.status = status as POStatus;
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { EnvBanner } from "@/components/env-banner";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
|
|
@ -29,7 +30,10 @@ export default function RootLayout({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
<EnvBanner />
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
App/components/env-banner.tsx
Normal file
30
App/components/env-banner.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Thin fixed banner shown only when NEXT_PUBLIC_ENV_LABEL is set (e.g. staging).
|
||||||
|
// Production never sets the var, so it renders nothing there.
|
||||||
|
export function EnvBanner() {
|
||||||
|
const label = process.env.NEXT_PUBLIC_ENV_LABEL;
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 9999,
|
||||||
|
height: 18,
|
||||||
|
lineHeight: "18px",
|
||||||
|
textAlign: "center",
|
||||||
|
background: "#b45309",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
pointerEvents: "none",
|
||||||
|
fontFamily: "var(--font-sans), system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -39,10 +39,13 @@ export async function reportIssue(formData: FormData): Promise<Result> {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// File with only `portal`. The watcher triages portal issues — Claude reads
|
||||||
|
// the issue, posts a requirements breakdown, and routes it to `claude-queue`
|
||||||
|
// (auto-fixable) or `interactive` (needs human steering).
|
||||||
const issue = await createForgejoIssue({
|
const issue = await createForgejoIssue({
|
||||||
title: `[Issue]: ${title}`,
|
title: `[Issue]: ${title}`,
|
||||||
body,
|
body,
|
||||||
labels: ["portal", "claude-queue"],
|
labels: ["portal"],
|
||||||
});
|
});
|
||||||
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
|
return { ok: true, issueNumber: issue.number, issueUrl: issue.html_url };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
|
import { groupAttachments } from "@/lib/attachments";
|
||||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
@ -149,9 +150,13 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
|
? "Submitter updated these line items after edits were requested. Previous values shown with strikethrough."
|
||||||
: "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
|
: "Line items were amended by manager. Current values shown; original values shown with strikethrough.";
|
||||||
|
|
||||||
const downloadUrls = await Promise.all(
|
const docsWithUrls = await Promise.all(
|
||||||
po.documents.map((doc) => generateDownloadUrl(doc.storageKey))
|
po.documents.map(async (doc) => ({
|
||||||
|
...doc,
|
||||||
|
url: await generateDownloadUrl(doc.storageKey),
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
const attachmentGroups = groupAttachments(docsWithUrls);
|
||||||
|
|
||||||
const canConfirmReceipt =
|
const canConfirmReceipt =
|
||||||
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
|
(po.status === "PAID_DELIVERED" || po.status === "PARTIALLY_CLOSED" || po.status === "PARTIALLY_PAID") &&
|
||||||
|
|
@ -163,6 +168,9 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
|
.find((a) => a.actionType === "APPROVED" || a.actionType === "APPROVED_WITH_NOTE");
|
||||||
|
|
||||||
|
// PO date: submitter-set date → approved date → creation date
|
||||||
|
const poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -302,7 +310,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
{approvalAction && (
|
{approvalAction && (
|
||||||
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
|
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
|
||||||
)}
|
)}
|
||||||
{po.poDate && <div><dt className="text-neutral-500">PO Date</dt><dd className="font-medium text-neutral-900">{formatDate(po.poDate)}</dd></div>}
|
<div><dt className="text-neutral-500">PO Date</dt><dd className="font-medium text-neutral-900">{formatDate(poDisplayDate)}</dd></div>
|
||||||
{po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>}
|
{po.projectCode && <div><dt className="text-neutral-500">Project Code</dt><dd className="font-medium text-neutral-900">{po.projectCode}</dd></div>}
|
||||||
{po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>}
|
{po.dateRequired && <div><dt className="text-neutral-500">Delivery Date Required</dt><dd className="font-medium text-neutral-900">{formatDate(po.dateRequired)}</dd></div>}
|
||||||
{po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>}
|
{po.piQuotationNo && <div><dt className="text-neutral-500">PI / Quotation No.</dt><dd className="font-medium text-neutral-900">{po.piQuotationNo}</dd></div>}
|
||||||
|
|
@ -396,27 +404,40 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Documents */}
|
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||||||
{po.documents.length > 0 && (
|
{attachmentGroups.length > 0 && (
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Attachments</h3>
|
||||||
<ul className="space-y-2">
|
<div className="space-y-5">
|
||||||
{po.documents.map((doc, i) => (
|
{attachmentGroups.map((group) => (
|
||||||
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
<div key={group.meta.key}>
|
||||||
<a
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
href={downloadUrls[i]}
|
{group.meta.label}
|
||||||
target="_blank"
|
<span className="ml-1.5 font-normal text-neutral-400">({group.items.length})</span>
|
||||||
rel="noopener noreferrer"
|
</h4>
|
||||||
className="font-medium text-primary-600 hover:underline"
|
{group.meta.description && (
|
||||||
>
|
<p className="mt-0.5 text-xs text-neutral-400">{group.meta.description}</p>
|
||||||
{doc.fileName}
|
)}
|
||||||
</a>
|
<ul className="mt-2 space-y-2">
|
||||||
<span className="text-neutral-400 text-xs">
|
{group.items.map((doc) => (
|
||||||
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
<li key={doc.id} className="flex items-center gap-3 text-sm">
|
||||||
</span>
|
<a
|
||||||
</li>
|
href={doc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.fileName}
|
||||||
|
</a>
|
||||||
|
<span className="text-neutral-400 text-xs">
|
||||||
|
{(doc.fileSize / 1024).toFixed(0)} KB · {formatDate(doc.uploadedAt)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,11 @@ const UOM_OPTIONS = [
|
||||||
{ value: "mL", label: "mL — Millilitre" },
|
{ value: "mL", label: "mL — Millilitre" },
|
||||||
{ value: "m", label: "m — Metre" },
|
{ value: "m", label: "m — Metre" },
|
||||||
{ value: "m2", label: "m² — Sq. Metre" },
|
{ value: "m2", label: "m² — Sq. Metre" },
|
||||||
{ value: "hr", label: "hr — Hour" },
|
{ value: "hr", label: "hr — Hour" },
|
||||||
{ value: "day", label: "day — Day" },
|
{ value: "day", label: "day — Day" },
|
||||||
|
{ value: "week", label: "week — Week" },
|
||||||
|
{ value: "month", label: "month — Month" },
|
||||||
|
{ value: "year", label: "year — Year" },
|
||||||
{ value: "lump", label: "lump — Lump Sum" },
|
{ value: "lump", label: "lump — Lump Sum" },
|
||||||
{ value: "Ltr", label: "Ltr — Litre (alt)" },
|
{ value: "Ltr", label: "Ltr — Litre (alt)" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
96
App/lib/attachments.ts
Normal file
96
App/lib/attachments.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Attachment grouping.
|
||||||
|
*
|
||||||
|
* All PO attachments are stored as `PODocument` rows. The lifecycle stage an
|
||||||
|
* attachment belongs to is encoded in the leading segment of its `storageKey`
|
||||||
|
* (see `buildStorageKey` in `lib/storage.ts`), e.g. `po-document/<poId>/...`
|
||||||
|
* or `receipt/<poId>/...`. This module derives a user-facing grouping from
|
||||||
|
* that prefix so the PO details screen can show every attachment grouped by
|
||||||
|
* type (submission, payment, delivery).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AttachmentGroupKey = "submission" | "payment" | "delivery" | "other";
|
||||||
|
|
||||||
|
export interface AttachmentGroupMeta {
|
||||||
|
key: AttachmentGroupKey;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display order for attachment groups (lifecycle order). */
|
||||||
|
export const ATTACHMENT_GROUP_ORDER: AttachmentGroupKey[] = [
|
||||||
|
"submission",
|
||||||
|
"payment",
|
||||||
|
"delivery",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ATTACHMENT_GROUP_META: Record<AttachmentGroupKey, AttachmentGroupMeta> = {
|
||||||
|
submission: {
|
||||||
|
key: "submission",
|
||||||
|
label: "Submission documents",
|
||||||
|
description: "Uploaded with the purchase order (e.g. invoice, quotation).",
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
key: "payment",
|
||||||
|
label: "Payment documents",
|
||||||
|
description: "Uploaded at payment (e.g. payment proof).",
|
||||||
|
},
|
||||||
|
delivery: {
|
||||||
|
key: "delivery",
|
||||||
|
label: "Delivery receipts",
|
||||||
|
description: "Uploaded at delivery confirmation (e.g. delivery receipt).",
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
key: "other",
|
||||||
|
label: "Other attachments",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the lifecycle group of an attachment from its storage key prefix.
|
||||||
|
* Unknown prefixes fall back to "other" so nothing is ever hidden.
|
||||||
|
*/
|
||||||
|
export function categorizeAttachment(storageKey: string): AttachmentGroupKey {
|
||||||
|
const prefix = storageKey.split("/")[0];
|
||||||
|
switch (prefix) {
|
||||||
|
case "po-document":
|
||||||
|
return "submission";
|
||||||
|
case "payment-document":
|
||||||
|
case "payment":
|
||||||
|
return "payment";
|
||||||
|
case "receipt":
|
||||||
|
return "delivery";
|
||||||
|
default:
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentGroup<T> {
|
||||||
|
meta: AttachmentGroupMeta;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group attachments by lifecycle stage, returning only non-empty groups in
|
||||||
|
* canonical lifecycle order. Item order within each group is preserved.
|
||||||
|
*/
|
||||||
|
export function groupAttachments<T extends { storageKey: string }>(
|
||||||
|
documents: T[]
|
||||||
|
): AttachmentGroup<T>[] {
|
||||||
|
const buckets = new Map<AttachmentGroupKey, T[]>();
|
||||||
|
for (const doc of documents) {
|
||||||
|
const key = categorizeAttachment(doc.storageKey);
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (bucket) bucket.push(doc);
|
||||||
|
else buckets.set(key, [doc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ATTACHMENT_GROUP_ORDER.flatMap((key) => {
|
||||||
|
const items = buckets.get(key);
|
||||||
|
return items && items.length > 0
|
||||||
|
? [{ meta: ATTACHMENT_GROUP_META[key], items }]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,18 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
||||||
return `signatures/${userId}.${ext}`;
|
return `signatures/${userId}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage key for a company branding asset (logo or stamp/seal).
|
||||||
|
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||||
|
*/
|
||||||
|
export function buildCompanyAssetKey(
|
||||||
|
companyId: string,
|
||||||
|
type: "logo" | "stamp",
|
||||||
|
ext: string
|
||||||
|
): string {
|
||||||
|
return `company-assets/${companyId}/${type}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a file buffer directly to storage (server-side).
|
* Upload a file buffer directly to storage (server-side).
|
||||||
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
* In dev: writes to .dev-uploads/. In prod: PUTs to R2.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,30 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
|
||||||
|
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
|
||||||
|
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
|
||||||
|
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
|
||||||
|
export function formatCompactINR(amount: number | string): string {
|
||||||
|
const n = Number(amount);
|
||||||
|
if (!Number.isFinite(n)) return "₹0";
|
||||||
|
|
||||||
|
const sign = n < 0 ? "-" : "";
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
|
||||||
|
const format = (value: number, suffix: string) => {
|
||||||
|
const rounded = Math.round(value * 100) / 100;
|
||||||
|
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
|
||||||
|
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
|
||||||
|
return `${sign}₹${text}${suffix}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (abs >= 1e7) return format(abs / 1e7, " Cr");
|
||||||
|
if (abs >= 1e5) return format(abs / 1e5, " L");
|
||||||
|
if (abs >= 1e3) return format(abs / 1e3, " K");
|
||||||
|
return format(abs, "");
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string): string {
|
export function formatDate(date: Date | string): string {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
@ -53,6 +77,18 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
||||||
CLOSED: "Closed",
|
CLOSED: "Closed",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
||||||
|
// `approvedAt` timestamp as it moves through these states, so "approved this month"
|
||||||
|
// aggregations must match against all of them — not just MGR_APPROVED.
|
||||||
|
export const POST_APPROVAL_STATUSES = [
|
||||||
|
"MGR_APPROVED",
|
||||||
|
"SENT_FOR_PAYMENT",
|
||||||
|
"PARTIALLY_PAID",
|
||||||
|
"PAID_DELIVERED",
|
||||||
|
"PARTIALLY_CLOSED",
|
||||||
|
"CLOSED",
|
||||||
|
] as const satisfies readonly POStatus[];
|
||||||
|
|
||||||
export type BadgeVariant =
|
export type BadgeVariant =
|
||||||
| "default"
|
| "default"
|
||||||
| "secondary"
|
| "secondary"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Add branding to Company: logo + stamp images, shown on exported POs
|
||||||
|
ALTER TABLE "Company" ADD COLUMN "logoKey" TEXT;
|
||||||
|
ALTER TABLE "Company" ADD COLUMN "stampKey" TEXT;
|
||||||
|
|
@ -125,6 +125,8 @@ model Company {
|
||||||
email String?
|
email String?
|
||||||
invoiceEmail String?
|
invoiceEmail String?
|
||||||
invoiceAddress String?
|
invoiceAddress String?
|
||||||
|
logoKey String? // storage key for uploaded logo image (top of exported POs)
|
||||||
|
stampKey String? // storage key for uploaded company stamp/seal (signatory block of exported POs)
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ afterEach(async () => {
|
||||||
|
|
||||||
// Helper: create a PO in MGR_REVIEW state
|
// Helper: create a PO in MGR_REVIEW state
|
||||||
async function createSubmittedPo(title: string): Promise<string> {
|
async function createSubmittedPo(title: string): Promise<string> {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
return (result as { id: string }).id;
|
return (result as { id: string }).id;
|
||||||
|
|
@ -60,7 +60,7 @@ async function createSubmittedPo(title: string): Promise<string> {
|
||||||
describe("M-02 — approve PO", () => {
|
describe("M-02 — approve PO", () => {
|
||||||
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
|
it("transitions PO from MGR_REVIEW to MGR_APPROVED", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}Approve`);
|
const poId = await createSubmittedPo(`${PREFIX}Approve`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await approvePo({ poId });
|
const result = await approvePo({ poId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -72,7 +72,7 @@ describe("M-02 — approve PO", () => {
|
||||||
|
|
||||||
it("stores managerNote when approving with note", async () => {
|
it("stores managerNote when approving with note", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
|
const poId = await createSubmittedPo(`${PREFIX}ApproveNote`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
|
await approvePo({ poId, note: "Approved — expedite delivery", withNote: true });
|
||||||
|
|
||||||
|
|
@ -88,7 +88,7 @@ describe("M-02 — approve PO", () => {
|
||||||
const { notify } = await import("@/lib/notifier");
|
const { notify } = await import("@/lib/notifier");
|
||||||
vi.mocked(notify).mockClear();
|
vi.mocked(notify).mockClear();
|
||||||
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
|
const poId = await createSubmittedPo(`${PREFIX}ApproveNotify`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
await approvePo({ poId });
|
await approvePo({ poId });
|
||||||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||||||
|
|
@ -98,18 +98,18 @@ describe("M-02 — approve PO", () => {
|
||||||
|
|
||||||
it("returns error when TECHNICAL role tries to approve", async () => {
|
it("returns error when TECHNICAL role tries to approve", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
|
const poId = await createSubmittedPo(`${PREFIX}ApproveForbidden`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await approvePo({ poId });
|
const result = await approvePo({ poId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when PO is not in MGR_REVIEW state", async () => {
|
it("returns error when PO is not in MGR_REVIEW state", async () => {
|
||||||
// Create a DRAFT PO, don't submit
|
// Create a DRAFT PO, don't submit
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title: `${PREFIX}ApproveDraft`, vesselId, accountId, intent: "draft" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const result = await approvePo({ poId });
|
const result = await approvePo({ poId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
@ -120,7 +120,7 @@ describe("M-02 — approve PO", () => {
|
||||||
describe("M-03 — reject PO", () => {
|
describe("M-03 — reject PO", () => {
|
||||||
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
|
it("transitions PO from MGR_REVIEW to REJECTED with note", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}Reject`);
|
const poId = await createSubmittedPo(`${PREFIX}Reject`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
|
const result = await rejectPo({ poId, note: "Budget exceeded for this quarter" });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -132,7 +132,7 @@ describe("M-03 — reject PO", () => {
|
||||||
|
|
||||||
it("creates a REJECTED action entry in the audit trail", async () => {
|
it("creates a REJECTED action entry in the audit trail", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
|
const poId = await createSubmittedPo(`${PREFIX}RejectAudit`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await rejectPo({ poId, note: "Not needed" });
|
await rejectPo({ poId, note: "Not needed" });
|
||||||
|
|
||||||
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
|
const action = await db.pOAction.findFirst({ where: { poId, actionType: "REJECTED" } });
|
||||||
|
|
@ -143,7 +143,7 @@ describe("M-03 — reject PO", () => {
|
||||||
const { notify } = await import("@/lib/notifier");
|
const { notify } = await import("@/lib/notifier");
|
||||||
vi.mocked(notify).mockClear();
|
vi.mocked(notify).mockClear();
|
||||||
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
|
const poId = await createSubmittedPo(`${PREFIX}RejectNotify`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
await rejectPo({ poId, note: "See notes" });
|
await rejectPo({ poId, note: "See notes" });
|
||||||
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
expect(vi.mocked(notify)).toHaveBeenCalledWith(
|
||||||
|
|
@ -157,7 +157,7 @@ describe("M-03 — reject PO", () => {
|
||||||
describe("M-04 — request edits", () => {
|
describe("M-04 — request edits", () => {
|
||||||
it("transitions PO to EDITS_REQUESTED with manager note", async () => {
|
it("transitions PO to EDITS_REQUESTED with manager note", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}Edits`);
|
const poId = await createSubmittedPo(`${PREFIX}Edits`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await requestEdits({ poId, note: "Please add vendor ID" });
|
const result = await requestEdits({ poId, note: "Please add vendor ID" });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -173,7 +173,7 @@ describe("M-04 — request edits", () => {
|
||||||
describe("M-04 — request vendor ID", () => {
|
describe("M-04 — request vendor ID", () => {
|
||||||
it("transitions PO to VENDOR_ID_PENDING", async () => {
|
it("transitions PO to VENDOR_ID_PENDING", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
|
const poId = await createSubmittedPo(`${PREFIX}VendorIdReq`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await requestVendorId({ poId });
|
const result = await requestVendorId({ poId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -188,10 +188,10 @@ describe("M-04 — request vendor ID", () => {
|
||||||
describe("S-06 — provide vendor ID", () => {
|
describe("S-06 — provide vendor ID", () => {
|
||||||
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
|
it("transitions VENDOR_ID_PENDING back to MGR_REVIEW", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
|
const poId = await createSubmittedPo(`${PREFIX}ProvideVendor`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await requestVendorId({ poId });
|
await requestVendorId({ poId });
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await provideVendorId({ poId, vendorId });
|
const result = await provideVendorId({ poId, vendorId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
|
@ -242,7 +242,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const result = await approvePo({ poId: po.id });
|
const result = await approvePo({ poId: po.id });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
|
@ -285,7 +285,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await approvePo({ poId: po.id });
|
await approvePo({ poId: po.id });
|
||||||
|
|
||||||
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
|
const countAfter = await db.itemInventory.count({ where: { siteId: site.id } });
|
||||||
|
|
@ -322,7 +322,7 @@ describe("inventory — updated at MGR_APPROVED, not at closure", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await approvePo({ poId: po.id });
|
await approvePo({ poId: po.id });
|
||||||
|
|
||||||
const totalAfter = await db.itemInventory.count();
|
const totalAfter = await db.itemInventory.count();
|
||||||
|
|
@ -336,11 +336,11 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
||||||
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
|
it("resubmitting from EDITS_REQUESTED transitions to MGR_REVIEW", async () => {
|
||||||
const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
|
const poId = await createSubmittedPo(`${PREFIX}Resubmit`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await requestEdits({ poId, note: "Update line items" });
|
await requestEdits({ poId, note: "Update line items" });
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
|
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
|
||||||
const result = await updatePo(poId, form);
|
const result = await updatePo(poId, form);
|
||||||
expect(result).toEqual({ id: poId });
|
expect(result).toEqual({ id: poId });
|
||||||
|
|
||||||
|
|
@ -350,11 +350,11 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
||||||
|
|
||||||
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
|
it("saving edits without resubmitting stays as DRAFT (save intent)", async () => {
|
||||||
// Create a DRAFT PO
|
// Create a DRAFT PO
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "save" });
|
const editForm = makePoForm({ title: `${PREFIX}SaveDraft`, vesselId, accountId, intent: "draft" });
|
||||||
const result = await updatePo(poId, editForm);
|
const result = await updatePo(poId, editForm);
|
||||||
expect(result).toEqual({ id: poId });
|
expect(result).toEqual({ id: poId });
|
||||||
|
|
||||||
|
|
|
||||||
105
App/tests/integration/approved-this-month.test.ts
Normal file
105
App/tests/integration/approved-this-month.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
/**
|
||||||
|
* Integration test for the manager dashboard "Approved This Month" card.
|
||||||
|
*
|
||||||
|
* Regression: the card previously counted only POs *currently* in MGR_APPROVED,
|
||||||
|
* so POs approved this month that had moved on to payment/delivery/closure were
|
||||||
|
* dropped from the count. The card must count every PO approved this month
|
||||||
|
* regardless of its current (post-approval) status, and the same approval-date
|
||||||
|
* window must be reproducible on the /history page (where the card links to).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
|
import { deletePosByTitle } from "./helpers";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const PREFIX = "INTTEST_APPROVED_MONTH_";
|
||||||
|
|
||||||
|
let submitterId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
let accountId: string;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const midThisMonth = new Date(now.getFullYear(), now.getMonth(), 15, 12, 0, 0);
|
||||||
|
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 15, 12, 0, 0);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Resolve any existing cost-centre / account / user from the test DB rather
|
||||||
|
// than relying on dev-seed fixtures (the test DB is a production mirror).
|
||||||
|
const [user, vessel, account] = await Promise.all([
|
||||||
|
db.user.findFirstOrThrow({ where: { role: "MANAGER" } }),
|
||||||
|
db.vessel.findFirstOrThrow(),
|
||||||
|
db.account.findFirstOrThrow(),
|
||||||
|
]);
|
||||||
|
submitterId = user.id;
|
||||||
|
vesselId = vessel.id;
|
||||||
|
accountId = account.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deletePosByTitle(PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
async function makePo(opts: { title: string; status: POStatus; approvedAt: Date | null }) {
|
||||||
|
seq += 1;
|
||||||
|
return db.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
poNumber: `${PREFIX}${Date.now()}_${seq}`,
|
||||||
|
title: opts.title,
|
||||||
|
status: opts.status,
|
||||||
|
totalAmount: 1000,
|
||||||
|
approvedAt: opts.approvedAt,
|
||||||
|
submitterId,
|
||||||
|
vesselId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the dashboard "Approved This Month" query. */
|
||||||
|
function approvedThisMonthWhere() {
|
||||||
|
return {
|
||||||
|
title: { startsWith: PREFIX },
|
||||||
|
status: { in: [...POST_APPROVAL_STATUSES] },
|
||||||
|
approvedAt: { gte: startOfMonth },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Approved This Month count", () => {
|
||||||
|
it("counts POs approved this month across every post-approval status", async () => {
|
||||||
|
await makePo({ title: `${PREFIX}approved`, status: "MGR_APPROVED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}sent`, status: "SENT_FOR_PAYMENT", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}partpaid`, status: "PARTIALLY_PAID", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}paid`, status: "PAID_DELIVERED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}partclosed`, status: "PARTIALLY_CLOSED", approvedAt: midThisMonth });
|
||||||
|
await makePo({ title: `${PREFIX}closed`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
expect(count).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes POs approved in a previous month and POs never approved", async () => {
|
||||||
|
await makePo({ title: `${PREFIX}closed_lastmonth`, status: "CLOSED", approvedAt: lastMonth });
|
||||||
|
await makePo({ title: `${PREFIX}awaiting`, status: "MGR_REVIEW", approvedAt: null });
|
||||||
|
await makePo({ title: `${PREFIX}closed_thismonth`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const count = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
expect(count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("would have missed moved-on POs under the old MGR_APPROVED-only filter", async () => {
|
||||||
|
// A PO approved this month that has since closed — the case from issue #32.
|
||||||
|
await makePo({ title: `${PREFIX}moved_on`, status: "CLOSED", approvedAt: midThisMonth });
|
||||||
|
|
||||||
|
const oldCount = await db.purchaseOrder.count({
|
||||||
|
where: { title: { startsWith: PREFIX }, status: "MGR_APPROVED", approvedAt: { gte: startOfMonth } },
|
||||||
|
});
|
||||||
|
const newCount = await db.purchaseOrder.count({ where: approvedThisMonthWhere() });
|
||||||
|
|
||||||
|
expect(oldCount).toBe(0);
|
||||||
|
expect(newCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
App/tests/integration/company-branding.test.ts
Normal file
114
App/tests/integration/company-branding.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for company branding actions (logo + stamp uploads).
|
||||||
|
* Covers:
|
||||||
|
* - Manager can upload a logo / stamp; the key is stored on the company
|
||||||
|
* - Re-upload overwrites in place (deterministic key)
|
||||||
|
* - Invalid asset type, bad mime, and oversize files are rejected
|
||||||
|
* - removeCompanyAsset clears the key
|
||||||
|
* - Permission gating (TECHNICAL cannot manage branding)
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/storage", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/storage")>()),
|
||||||
|
uploadBuffer: vi.fn(), // don't touch the filesystem in tests
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { uploadBuffer } from "@/lib/storage";
|
||||||
|
import { uploadCompanyAsset, removeCompanyAsset } from "@/app/(portal)/admin/companies/actions";
|
||||||
|
import { makeSession } from "./helpers";
|
||||||
|
|
||||||
|
const mockedAuth = vi.mocked(auth);
|
||||||
|
const mockedUpload = vi.mocked(uploadBuffer);
|
||||||
|
|
||||||
|
let companyId: string;
|
||||||
|
|
||||||
|
function pngFile(name: string, bytes = 1024): File {
|
||||||
|
return new File([new Uint8Array(bytes)], name, { type: "image/png" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetForm(id: string, type: string, file: File): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("companyId", id);
|
||||||
|
form.set("type", type);
|
||||||
|
form.set("file", file);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const company = await db.company.create({
|
||||||
|
data: { name: "INTTEST_BRANDING_CO", code: "ZZBRAND" },
|
||||||
|
});
|
||||||
|
companyId = company.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.company.delete({ where: { id: companyId } }).catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadCompanyAsset", () => {
|
||||||
|
it("stores a logo key on the company", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||||
|
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
||||||
|
expect(mockedUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a stamp key independently of the logo", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "stamp", pngFile("stamp.png")));
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||||
|
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`);
|
||||||
|
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown asset type", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "header", pngFile("x.png")));
|
||||||
|
expect(res).toEqual({ error: "Invalid asset type" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-image mime type", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const pdf = new File([new Uint8Array(10)], "x.pdf", { type: "application/pdf" });
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pdf));
|
||||||
|
expect(res).toEqual({ error: "Image must be a PNG, JPG, or WebP" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a file over 4 MB", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const big = pngFile("big.png", 5 * 1024 * 1024);
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", big));
|
||||||
|
expect(res).toEqual({ error: "Image must be under 4 MB" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses callers without manage_vessels_accounts", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
||||||
|
expect(res).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeCompanyAsset", () => {
|
||||||
|
it("clears the stored key", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const res = await removeCompanyAsset(companyId, "logo");
|
||||||
|
expect(res).toEqual({ ok: true });
|
||||||
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
||||||
|
expect(c.logoKey).toBeNull();
|
||||||
|
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`); // stamp untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses unauthorized callers", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
const res = await removeCompanyAsset(companyId, "stamp");
|
||||||
|
expect(res).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
84
App/tests/integration/company-crud.test.ts
Normal file
84
App/tests/integration/company-crud.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for company create/update actions.
|
||||||
|
* Focus on the behaviour the dedicated add/edit pages rely on:
|
||||||
|
* - createCompany returns the new id (so the create flow can redirect to the edit page)
|
||||||
|
* - fields persist, code is upper-cased, duplicate codes are rejected
|
||||||
|
* - updateCompany edits in place
|
||||||
|
* - both actions are gated by manage_vessels_accounts
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, afterAll } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createCompany, updateCompany } from "@/app/(portal)/admin/companies/actions";
|
||||||
|
import { makeSession, fd } from "./helpers";
|
||||||
|
|
||||||
|
const mockedAuth = vi.mocked(auth);
|
||||||
|
const NAME_PREFIX = "INTTEST_CRUD_";
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.company.deleteMany({ where: { name: { startsWith: NAME_PREFIX } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCompany", () => {
|
||||||
|
it("returns the new id and persists the company (code upper-cased)", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const result = await createCompany(fd({
|
||||||
|
name: `${NAME_PREFIX}Alpha`,
|
||||||
|
code: "zzcrudA",
|
||||||
|
gstNumber: "27AAHCP5787B1Z6",
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect("id" in result && result.ok).toBe(true);
|
||||||
|
if (!("id" in result)) throw new Error(result.error);
|
||||||
|
|
||||||
|
const c = await db.company.findUniqueOrThrow({ where: { id: result.id } });
|
||||||
|
expect(c.name).toBe(`${NAME_PREFIX}Alpha`);
|
||||||
|
expect(c.code).toBe("ZZCRUDA");
|
||||||
|
expect(c.gstNumber).toBe("27AAHCP5787B1Z6");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate code (case-insensitive)", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const first = await createCompany(fd({ name: `${NAME_PREFIX}Dup1`, code: "zzcrudd" }));
|
||||||
|
expect("id" in first).toBe(true);
|
||||||
|
|
||||||
|
const second = await createCompany(fd({ name: `${NAME_PREFIX}Dup2`, code: "ZZCRUDD" }));
|
||||||
|
expect("error" in second).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses callers without manage_vessels_accounts", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
const result = await createCompany(fd({ name: `${NAME_PREFIX}Nope`, code: "zzcrudN" }));
|
||||||
|
expect(result).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateCompany", () => {
|
||||||
|
it("edits an existing company in place", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||||
|
const created = await createCompany(fd({ name: `${NAME_PREFIX}Edit`, code: "zzcrudE" }));
|
||||||
|
if (!("id" in created)) throw new Error(created.error);
|
||||||
|
|
||||||
|
const result = await updateCompany(fd({
|
||||||
|
id: created.id,
|
||||||
|
name: `${NAME_PREFIX}Edited`,
|
||||||
|
code: "zzcrudE",
|
||||||
|
mobile: "+91 99999 00000",
|
||||||
|
}));
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const c = await db.company.findUniqueOrThrow({ where: { id: created.id } });
|
||||||
|
expect(c.name).toBe(`${NAME_PREFIX}Edited`);
|
||||||
|
expect(c.mobile).toBe("+91 99999 00000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses callers without manage_vessels_accounts", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||||
|
const result = await updateCompany(fd({ id: "whatever", name: "x", code: "ZZX" }));
|
||||||
|
expect(result).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
197
App/tests/integration/confirm-receipt.test.ts
Normal file
197
App/tests/integration/confirm-receipt.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
/**
|
||||||
|
* Integration tests for the confirmReceipt server action.
|
||||||
|
* Covers: full receipt, partial receipt, upsert notes on repeated confirmation,
|
||||||
|
* and permission guards.
|
||||||
|
*/
|
||||||
|
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||||
|
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||||
|
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
||||||
|
import { confirmReceipt } from "@/app/(portal)/po/[id]/receipt/actions";
|
||||||
|
import {
|
||||||
|
makeSession,
|
||||||
|
getSeedUser,
|
||||||
|
getSeedVessel,
|
||||||
|
getSeedAccount,
|
||||||
|
makePoForm,
|
||||||
|
deletePosByTitle,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
const PREFIX = "INTTEST_RECEIPT_";
|
||||||
|
const TODAY = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
let techId: string;
|
||||||
|
let managerId: string;
|
||||||
|
let accountsId: string;
|
||||||
|
let vesselId: string;
|
||||||
|
let accountId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||||
|
getSeedUser("tech@pelagia.local"),
|
||||||
|
getSeedUser("manager@pelagia.local"),
|
||||||
|
getSeedUser("accounts@pelagia.local"),
|
||||||
|
getSeedVessel("MV Sea Breeze"),
|
||||||
|
getSeedAccount("700202"),
|
||||||
|
]);
|
||||||
|
techId = tech.id;
|
||||||
|
managerId = mgr.id;
|
||||||
|
accountsId = acct.id;
|
||||||
|
vesselId = vessel.id;
|
||||||
|
accountId = account.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await deletePosByTitle(PREFIX);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
|
||||||
|
async function createPaidPo(title: string): Promise<string> {
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
await approvePo({ poId });
|
||||||
|
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
|
await processPayment({ poId });
|
||||||
|
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
|
||||||
|
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
return poId;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("confirmReceipt — full delivery", () => {
|
||||||
|
it("transitions PAID_DELIVERED to CLOSED when all items delivered", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}Full`);
|
||||||
|
const result = await confirmReceipt({ poId });
|
||||||
|
expect(result).toEqual({ ok: true, partial: false });
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
expect(po?.status).toBe("CLOSED");
|
||||||
|
expect(po?.closedAt).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records RECEIPT_CONFIRMED in audit log", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}Audit`);
|
||||||
|
await confirmReceipt({ poId });
|
||||||
|
|
||||||
|
const action = await db.pOAction.findFirst({
|
||||||
|
where: { poId, actionType: "RECEIPT_CONFIRMED" },
|
||||||
|
});
|
||||||
|
expect(action).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves delivery notes on the Receipt record", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}Notes`);
|
||||||
|
await confirmReceipt({ poId, notes: "All items received in good condition." });
|
||||||
|
|
||||||
|
const receipt = await db.receipt.findUnique({ where: { poId } });
|
||||||
|
expect(receipt?.notes).toBe("All items received in good condition.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirmReceipt — partial delivery", () => {
|
||||||
|
it("transitions PAID_DELIVERED to PARTIALLY_CLOSED when some items remain", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}Partial`);
|
||||||
|
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||||
|
const deliveries: Record<string, number> = {};
|
||||||
|
for (const li of lineItems) deliveries[li.id] = 0; // deliver nothing
|
||||||
|
|
||||||
|
const result = await confirmReceipt({ poId, deliveries });
|
||||||
|
// delivering 0 of everything → nothingDelivered guard is in the UI, not the action
|
||||||
|
// action still proceeds and computes PARTIALLY_CLOSED (paid but 0 delivered)
|
||||||
|
expect(result).toEqual({ ok: true, partial: true });
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
// fully paid but nothing delivered → PARTIALLY_CLOSED
|
||||||
|
expect(po?.status).toBe("PARTIALLY_CLOSED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns partial:true for a partial delivery", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}PartialQty`);
|
||||||
|
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||||
|
const half = Math.floor(Number(lineItems[0].quantity) / 2);
|
||||||
|
const deliveries = { [lineItems[0].id]: half };
|
||||||
|
|
||||||
|
const result = await confirmReceipt({ poId, deliveries });
|
||||||
|
expect(result).toEqual({ ok: true, partial: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirmReceipt — repeated notes upsert (regression for partial → full flow)", () => {
|
||||||
|
it("succeeds on second call with notes after first partial confirmation also had notes", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}Upsert`);
|
||||||
|
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||||
|
const half = Math.floor(Number(lineItems[0].quantity) / 2);
|
||||||
|
const remaining = Number(lineItems[0].quantity) - half;
|
||||||
|
|
||||||
|
// First confirmation: partial delivery with notes — creates Receipt row
|
||||||
|
const first = await confirmReceipt({
|
||||||
|
poId,
|
||||||
|
notes: "First batch received.",
|
||||||
|
deliveries: { [lineItems[0].id]: half },
|
||||||
|
});
|
||||||
|
expect(first).toEqual({ ok: true, partial: true });
|
||||||
|
|
||||||
|
// Second confirmation: deliver the rest, also with notes — must not throw
|
||||||
|
// (previously crashed due to unique constraint on Receipt.poId when using `create`)
|
||||||
|
const second = await confirmReceipt({
|
||||||
|
poId,
|
||||||
|
notes: "Remaining items received.",
|
||||||
|
deliveries: { [lineItems[0].id]: remaining },
|
||||||
|
});
|
||||||
|
expect(second).toEqual({ ok: true, partial: false });
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
expect(po?.status).toBe("CLOSED");
|
||||||
|
|
||||||
|
// Notes should reflect the latest confirmation
|
||||||
|
const receipt = await db.receipt.findUnique({ where: { poId } });
|
||||||
|
expect(receipt?.notes).toBe("Remaining items received.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirmReceipt — permission guards", () => {
|
||||||
|
it("rejects non-submitter who is not SUPERUSER", async () => {
|
||||||
|
const poId = await createPaidPo(`${PREFIX}PermFail`);
|
||||||
|
|
||||||
|
const otherTech = await getSeedUser("tech@pelagia.local");
|
||||||
|
// Use a different user id to simulate a different submitter
|
||||||
|
const fakeSession = makeSession(managerId, "TECHNICAL");
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(fakeSession);
|
||||||
|
|
||||||
|
const result = await confirmReceipt({ poId });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
void otherTech; // suppress unused warning
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects confirmation on a PO in wrong status", async () => {
|
||||||
|
// Create a PO that is still DRAFT (no payment yet)
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
|
||||||
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
|
const result = await confirmReceipt({ poId });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when PO does not exist", async () => {
|
||||||
|
const result = await confirmReceipt({ poId: "nonexistent-po-id" });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when not authenticated", async () => {
|
||||||
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
|
const result = await confirmReceipt({ poId: "any-id" });
|
||||||
|
expect(result).toHaveProperty("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -43,7 +43,7 @@ afterEach(async () => {
|
||||||
|
|
||||||
describe("S-02 — save as draft", () => {
|
describe("S-02 — save as draft", () => {
|
||||||
it("creates a PO in DRAFT status", async () => {
|
it("creates a PO in DRAFT status", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
title: `${PREFIX}Draft`,
|
title: `${PREFIX}Draft`,
|
||||||
|
|
@ -59,7 +59,7 @@ describe("S-02 — save as draft", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for unauthenticated request", async () => {
|
it("returns error for unauthenticated request", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
expect(result).toEqual({ error: "Unauthorized" });
|
expect(result).toEqual({ error: "Unauthorized" });
|
||||||
|
|
@ -67,14 +67,14 @@ describe("S-02 — save as draft", () => {
|
||||||
|
|
||||||
it("returns error when ACCOUNTS role tries to create a PO", async () => {
|
it("returns error when ACCOUNTS role tries to create a PO", async () => {
|
||||||
const acct = await getSeedUser("accounts@pelagia.local");
|
const acct = await getSeedUser("accounts@pelagia.local");
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(acct.id, "ACCOUNTS"));
|
||||||
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}ForbiddenAccts`, vesselId, accountId });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when a required field (vesselId) is missing", async () => {
|
it("returns error when a required field (vesselId) is missing", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set("title", `${PREFIX}NoVessel`);
|
form.set("title", `${PREFIX}NoVessel`);
|
||||||
form.set("accountId", accountId);
|
form.set("accountId", accountId);
|
||||||
|
|
@ -93,7 +93,7 @@ describe("S-02 — save as draft", () => {
|
||||||
|
|
||||||
describe("S-01 — create PO with line items", () => {
|
describe("S-01 — create PO with line items", () => {
|
||||||
it("stores line items with correct quantity, unit price, and GST rate", async () => {
|
it("stores line items with correct quantity, unit price, and GST rate", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
title: `${PREFIX}LineItems`,
|
title: `${PREFIX}LineItems`,
|
||||||
|
|
@ -120,7 +120,7 @@ describe("S-01 — create PO with line items", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets totalAmount to grand total including GST", async () => {
|
it("sets totalAmount to grand total including GST", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
// 10 × 100 × 1.18 = 1180
|
// 10 × 100 × 1.18 = 1180
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
|
|
@ -135,7 +135,7 @@ describe("S-01 — create PO with line items", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
|
it("stores optional fields (PI quotation no, place of delivery, TC fields)", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title: `${PREFIX}Optional`, vesselId, accountId, intent: "draft" });
|
||||||
form.set("piQuotationNo", "Verbal");
|
form.set("piQuotationNo", "Verbal");
|
||||||
|
|
@ -154,7 +154,7 @@ describe("S-01 — create PO with line items", () => {
|
||||||
|
|
||||||
it("allows MANNING role to create a PO", async () => {
|
it("allows MANNING role to create a PO", async () => {
|
||||||
const manning = await getSeedUser("manning@pelagia.local");
|
const manning = await getSeedUser("manning@pelagia.local");
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(manning.id, "MANNING"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(manning.id, "MANNING"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}Manning`, vesselId, accountId });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
expect(result).not.toHaveProperty("error");
|
expect(result).not.toHaveProperty("error");
|
||||||
|
|
@ -165,7 +165,7 @@ describe("S-01 — create PO with line items", () => {
|
||||||
|
|
||||||
describe("S-03 — submit for approval", () => {
|
describe("S-03 — submit for approval", () => {
|
||||||
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
|
it("creates PO with status MGR_REVIEW and sets submittedAt", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
|
|
@ -180,7 +180,7 @@ describe("S-03 — submit for approval", () => {
|
||||||
it("sends notification to managers on submit", async () => {
|
it("sends notification to managers on submit", async () => {
|
||||||
const { notify } = await import("@/lib/notifier");
|
const { notify } = await import("@/lib/notifier");
|
||||||
vi.mocked(notify).mockClear();
|
vi.mocked(notify).mockClear();
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title: `${PREFIX}Notify`, vesselId, accountId, intent: "submit" });
|
||||||
await createPo(form);
|
await createPo(form);
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ afterEach(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") {
|
async function createDraft(title: string, asUserId = techId, asRole: Parameters<typeof makeSession>[1] = "TECHNICAL") {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(asUserId, asRole));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(asUserId, asRole));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "draft" });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
return (result as { id: string }).id;
|
return (result as { id: string }).id;
|
||||||
|
|
@ -55,7 +55,7 @@ async function createDraft(title: string, asUserId = techId, asRole: Parameters<
|
||||||
describe("discard — happy path", () => {
|
describe("discard — happy path", () => {
|
||||||
it("owner (TECHNICAL) can discard their own DRAFT", async () => {
|
it("owner (TECHNICAL) can discard their own DRAFT", async () => {
|
||||||
const poId = await createDraft(`${PREFIX}OwnerDiscard`);
|
const poId = await createDraft(`${PREFIX}OwnerDiscard`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
|
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -64,7 +64,7 @@ describe("discard — happy path", () => {
|
||||||
|
|
||||||
it("MANAGER can discard any DRAFT PO (not their own)", async () => {
|
it("MANAGER can discard any DRAFT PO (not their own)", async () => {
|
||||||
const poId = await createDraft(`${PREFIX}MgrDiscard`);
|
const poId = await createDraft(`${PREFIX}MgrDiscard`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -74,7 +74,7 @@ describe("discard — happy path", () => {
|
||||||
it("SUPERUSER can discard any DRAFT PO", async () => {
|
it("SUPERUSER can discard any DRAFT PO", async () => {
|
||||||
const superuser = await getSeedUser("admin@pelagia.local");
|
const superuser = await getSeedUser("admin@pelagia.local");
|
||||||
const poId = await createDraft(`${PREFIX}SuperDiscard`);
|
const poId = await createDraft(`${PREFIX}SuperDiscard`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(superuser.id, "SUPERUSER"));
|
||||||
|
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -87,7 +87,7 @@ describe("discard — happy path", () => {
|
||||||
const before = await db.pOAction.findMany({ where: { poId } });
|
const before = await db.pOAction.findMany({ where: { poId } });
|
||||||
expect(before.length).toBeGreaterThan(0);
|
expect(before.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
await discardDraftPo(poId);
|
await discardDraftPo(poId);
|
||||||
|
|
||||||
const after = await db.pOAction.findMany({ where: { poId } });
|
const after = await db.pOAction.findMany({ where: { poId } });
|
||||||
|
|
@ -99,7 +99,7 @@ describe("discard — happy path", () => {
|
||||||
const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
|
const linesBefore = await db.pOLineItem.findMany({ where: { poId } });
|
||||||
expect(linesBefore.length).toBeGreaterThan(0);
|
expect(linesBefore.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
await discardDraftPo(poId);
|
await discardDraftPo(poId);
|
||||||
|
|
||||||
const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
|
const linesAfter = await db.pOLineItem.findMany({ where: { poId } });
|
||||||
|
|
@ -112,7 +112,7 @@ describe("discard — happy path", () => {
|
||||||
describe("discard — negative / permission tests", () => {
|
describe("discard — negative / permission tests", () => {
|
||||||
it("returns error for unauthenticated request", async () => {
|
it("returns error for unauthenticated request", async () => {
|
||||||
const poId = await createDraft(`${PREFIX}Unauth`);
|
const poId = await createDraft(`${PREFIX}Unauth`);
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
expect(await discardDraftPo(poId)).toHaveProperty("error");
|
expect(await discardDraftPo(poId)).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ describe("discard — negative / permission tests", () => {
|
||||||
// Create PO as manager, try to discard as tech
|
// Create PO as manager, try to discard as tech
|
||||||
const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER");
|
const poId = await createDraft(`${PREFIX}WrongOwner`, managerId, "MANAGER");
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
// PO must still exist
|
// PO must still exist
|
||||||
|
|
@ -129,14 +129,14 @@ describe("discard — negative / permission tests", () => {
|
||||||
|
|
||||||
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
|
it("ACCOUNTS cannot discard any PO (not in allowed roles)", async () => {
|
||||||
const poId = await createDraft(`${PREFIX}AccountsForbidden`);
|
const poId = await createDraft(`${PREFIX}AccountsForbidden`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
|
expect(await db.purchaseOrder.findUnique({ where: { id: poId } })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error for non-existent PO", async () => {
|
it("returns error for non-existent PO", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await discardDraftPo("non-existent-id");
|
const result = await discardDraftPo("non-existent-id");
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
@ -146,11 +146,11 @@ describe("discard — negative / permission tests", () => {
|
||||||
|
|
||||||
describe("discard — status guard", () => {
|
describe("discard — status guard", () => {
|
||||||
it("cannot discard a submitted (MGR_REVIEW) PO", async () => {
|
it("cannot discard a submitted (MGR_REVIEW) PO", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title: `${PREFIX}Submitted`, vesselId, accountId, intent: "submit" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const result = await discardDraftPo(poId);
|
const result = await discardDraftPo(poId);
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx
|
* Tests authorization guards and end-to-end parsing of the Sample_PO.xlsx
|
||||||
* fixture using the real route handler.
|
* fixture using the real route handler.
|
||||||
*/
|
*/
|
||||||
import { vi, describe, it, expect, beforeAll } from "vitest";
|
import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -50,13 +50,13 @@ function makeFileRequest(filePath?: string) {
|
||||||
|
|
||||||
describe("POST /api/po/import — authorization", () => {
|
describe("POST /api/po/import — authorization", () => {
|
||||||
it("returns 401 for unauthenticated requests", async () => {
|
it("returns 401 for unauthenticated requests", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 for TECHNICAL role", async () => {
|
it("returns 403 for TECHNICAL role", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -64,13 +64,13 @@ describe("POST /api/po/import — authorization", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 for ACCOUNTS role", async () => {
|
it("returns 403 for ACCOUNTS role", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 200 for MANAGER role with valid file", async () => {
|
it("returns 200 for MANAGER role with valid file", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
@ -80,7 +80,7 @@ describe("POST /api/po/import — authorization", () => {
|
||||||
|
|
||||||
describe("POST /api/po/import — input validation", () => {
|
describe("POST /api/po/import — input validation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when no file is provided", async () => {
|
it("returns 400 when no file is provided", async () => {
|
||||||
|
|
@ -106,7 +106,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
||||||
let results: ParsedImport[];
|
let results: ParsedImport[];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
const res = await POST(makeFileRequest(SAMPLE_XLSX));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
results = data.results;
|
results = data.results;
|
||||||
|
|
@ -120,9 +120,9 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
||||||
const items = results[0].lineItems;
|
const items = results[0].lineItems;
|
||||||
const hasTcText = items.some(
|
const hasTcText = items.some(
|
||||||
(li) =>
|
(li) =>
|
||||||
li.description.toLowerCase().includes("please quote") ||
|
li.name.toLowerCase().includes("please quote") ||
|
||||||
li.description.toLowerCase().includes("delivery :") ||
|
li.name.toLowerCase().includes("delivery :") ||
|
||||||
li.description.toLowerCase().includes("payment terms")
|
li.name.toLowerCase().includes("payment terms")
|
||||||
);
|
);
|
||||||
expect(hasTcText).toBe(false);
|
expect(hasTcText).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -132,7 +132,7 @@ describe("POST /api/po/import — parsing Sample_PO.xlsx", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line item has correct description", () => {
|
it("line item has correct description", () => {
|
||||||
expect(results[0].lineItems[0].description).toBe("Eni EP 80W90 GEAR OIL");
|
expect(results[0].lineItems[0].name).toBe("Eni EP 80W90 GEAR OIL");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("line item has correct quantity (1050)", () => {
|
it("line item has correct quantity (1050)", () => {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { createPo } from "@/app/(portal)/po/new/actions";
|
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||||
import { approvepo } from "@/app/(portal)/approvals/[id]/actions";
|
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||||
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
|
import { discardDraftPo } from "@/app/(portal)/po/[id]/actions";
|
||||||
import {
|
import {
|
||||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||||
|
|
@ -48,7 +48,7 @@ afterEach(async () => {
|
||||||
|
|
||||||
describe("MANAGER — create PO", () => {
|
describe("MANAGER — create PO", () => {
|
||||||
it("MANAGER can save a PO as DRAFT", async () => {
|
it("MANAGER can save a PO as DRAFT", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" });
|
||||||
|
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
|
|
@ -59,7 +59,7 @@ describe("MANAGER — create PO", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("MANAGER can submit a PO directly", async () => {
|
it("MANAGER can submit a PO directly", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title: `${PREFIX}Submit`, vesselId, accountId, intent: "submit" });
|
||||||
|
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
|
|
@ -70,7 +70,7 @@ describe("MANAGER — create PO", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("MANAGER can discard their own DRAFT", async () => {
|
it("MANAGER can discard their own DRAFT", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" });
|
const form = makePoForm({ title: `${PREFIX}Discard`, vesselId, accountId, intent: "draft" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ describe("MANAGER — create PO", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stores correct submitterId on MANAGER-created PO", async () => {
|
it("stores correct submitterId on MANAGER-created PO", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}SubmitterId`, vesselId, accountId });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
|
@ -92,14 +92,14 @@ describe("MANAGER — create PO", () => {
|
||||||
|
|
||||||
describe("role — negative permission tests for PO creation", () => {
|
describe("role — negative permission tests for PO creation", () => {
|
||||||
it("ACCOUNTS cannot create a PO", async () => {
|
it("ACCOUNTS cannot create a PO", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}AcctsForbidden`, vesselId, accountId });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unauthenticated request returns Unauthorized", async () => {
|
it("unauthenticated request returns Unauthorized", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
const form = makePoForm({ title: `${PREFIX}Unauth`, vesselId, accountId });
|
||||||
const result = await createPo(form);
|
const result = await createPo(form);
|
||||||
expect(result).toEqual({ error: "Unauthorized" });
|
expect(result).toEqual({ error: "Unauthorized" });
|
||||||
|
|
@ -107,7 +107,7 @@ describe("role — negative permission tests for PO creation", () => {
|
||||||
|
|
||||||
it("MANAGER cannot approve their own submitted PO (same user)", async () => {
|
it("MANAGER cannot approve their own submitted PO (same user)", async () => {
|
||||||
// Manager creates and submits
|
// Manager creates and submits
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
title: `${PREFIX}SelfApprove`,
|
title: `${PREFIX}SelfApprove`,
|
||||||
vesselId,
|
vesselId,
|
||||||
|
|
@ -120,7 +120,7 @@ describe("role — negative permission tests for PO creation", () => {
|
||||||
// Approving as the same manager — the action itself doesn't block same-user approval
|
// Approving as the same manager — the action itself doesn't block same-user approval
|
||||||
// because approval authority is role-based, not submitter-based.
|
// because approval authority is role-based, not submitter-based.
|
||||||
// This test documents the current behaviour.
|
// This test documents the current behaviour.
|
||||||
const result = await approvepo({ poId });
|
const result = await approvePo({ poId });
|
||||||
// Should succeed because MANAGER has approve_po permission and the PO has a vendor
|
// Should succeed because MANAGER has approve_po permission and the PO has a vendor
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,11 @@ afterEach(async () => {
|
||||||
|
|
||||||
// Helper: create PO → submit → approve (reaches MGR_APPROVED)
|
// Helper: create PO → submit → approve (reaches MGR_APPROVED)
|
||||||
async function createApprovedPo(title: string): Promise<string> {
|
async function createApprovedPo(title: string): Promise<string> {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||||
const { id: poId } = (await createPo(form)) as { id: string };
|
const { id: poId } = (await createPo(form)) as { id: string };
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await approvePo({ poId });
|
await approvePo({ poId });
|
||||||
return poId;
|
return poId;
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +67,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
|
||||||
|
|
||||||
it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => {
|
it("processPayment transitions MGR_APPROVED to SENT_FOR_PAYMENT", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}ProcessPayment`);
|
const poId = await createApprovedPo(`${PREFIX}ProcessPayment`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
|
|
||||||
const result = await processPayment({ poId });
|
const result = await processPayment({ poId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -78,7 +78,7 @@ describe("A-01 — approved PO appears in payment queue", () => {
|
||||||
|
|
||||||
it("TECHNICAL role cannot process payment", async () => {
|
it("TECHNICAL role cannot process payment", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`);
|
const poId = await createApprovedPo(`${PREFIX}PaymentForbidden`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const result = await processPayment({ poId });
|
const result = await processPayment({ poId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
@ -90,7 +90,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => {
|
it("transitions SENT_FOR_PAYMENT to PAID_DELIVERED and stores paymentRef", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}MarkPaid`);
|
const poId = await createApprovedPo(`${PREFIX}MarkPaid`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
|
const result = await markPaid({ poId, paymentRef: "NEFT/2026/001234", paymentDate: TODAY });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -105,7 +105,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
it("creates a PAYMENT_SENT action in the audit trail", async () => {
|
it("creates a PAYMENT_SENT action in the audit trail", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidAudit`);
|
const poId = await createApprovedPo(`${PREFIX}PaidAudit`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
|
await markPaid({ poId, paymentRef: "TXN-9999", paymentDate: TODAY });
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
it("returns error when paymentRef is missing", async () => {
|
it("returns error when paymentRef is missing", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidNoRef`);
|
const poId = await createApprovedPo(`${PREFIX}PaidNoRef`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
|
const result = await markPaid({ poId, paymentRef: "", paymentDate: TODAY });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
|
|
@ -126,7 +126,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
it("returns error when payment date is in the future", async () => {
|
it("returns error when payment date is in the future", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
|
const poId = await createApprovedPo(`${PREFIX}PaidFutureDate`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
const future = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
|
const result = await markPaid({ poId, paymentRef: "FUTURE-REF", paymentDate: future });
|
||||||
|
|
@ -137,7 +137,7 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
const { notify } = await import("@/lib/notifier");
|
const { notify } = await import("@/lib/notifier");
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidNotify`);
|
const poId = await createApprovedPo(`${PREFIX}PaidNotify`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
vi.mocked(notify).mockClear();
|
vi.mocked(notify).mockClear();
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
|
await markPaid({ poId, paymentRef: "REF-42", paymentDate: TODAY });
|
||||||
|
|
@ -149,10 +149,10 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
||||||
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
|
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
|
||||||
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
|
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
await processPayment({ poId });
|
await processPayment({ poId });
|
||||||
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
|
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Integration tests for GET /api/products/search.
|
* Integration tests for GET /api/products/search.
|
||||||
* Tests authorization, query validation, filtering, and Decimal serialisation.
|
* Tests authorization, query validation, filtering, and Decimal serialisation.
|
||||||
*/
|
*/
|
||||||
import { vi, describe, it, expect, beforeAll } from "vitest";
|
import { vi, describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
|
@ -31,19 +31,19 @@ function makeRequest(query: string) {
|
||||||
|
|
||||||
describe("GET /api/products/search — authorization", () => {
|
describe("GET /api/products/search — authorization", () => {
|
||||||
it("returns 401 for unauthenticated requests", async () => {
|
it("returns 401 for unauthenticated requests", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(null);
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
||||||
const res = await GET(makeRequest("oil"));
|
const res = await GET(makeRequest("oil"));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("TECHNICAL can search products", async () => {
|
it("TECHNICAL can search products", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const res = await GET(makeRequest("oil"));
|
const res = await GET(makeRequest("oil"));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ACCOUNTS can search products", async () => {
|
it("ACCOUNTS can search products", async () => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
const res = await GET(makeRequest("oil"));
|
const res = await GET(makeRequest("oil"));
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
@ -53,7 +53,7 @@ describe("GET /api/products/search — authorization", () => {
|
||||||
|
|
||||||
describe("GET /api/products/search — query validation", () => {
|
describe("GET /api/products/search — query validation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty array for query shorter than 2 chars", async () => {
|
it("returns empty array for query shorter than 2 chars", async () => {
|
||||||
|
|
@ -79,7 +79,7 @@ describe("GET /api/products/search — query validation", () => {
|
||||||
|
|
||||||
describe("GET /api/products/search — search behaviour", () => {
|
describe("GET /api/products/search — search behaviour", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("finds products by name substring", async () => {
|
it("finds products by name substring", async () => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { createPo } from "@/app/(portal)/po/new/actions";
|
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||||
import { approvepo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions";
|
import { approvePo, requestVendorId } from "@/app/(portal)/approvals/[id]/actions";
|
||||||
import { provideVendorId } from "@/app/(portal)/po/[id]/actions";
|
import { provideVendorId } from "@/app/(portal)/po/[id]/actions";
|
||||||
import {
|
import {
|
||||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||||
|
|
@ -76,7 +76,7 @@ afterEach(async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function makeReviewPo(title: string, withVendor = false) {
|
async function makeReviewPo(title: string, withVendor = false) {
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||||
const form = makePoForm({
|
const form = makePoForm({
|
||||||
title,
|
title,
|
||||||
vesselId,
|
vesselId,
|
||||||
|
|
@ -93,9 +93,9 @@ async function makeReviewPo(title: string, withVendor = false) {
|
||||||
describe("approval — vendor required", () => {
|
describe("approval — vendor required", () => {
|
||||||
it("blocks approval when PO has no vendor assigned", async () => {
|
it("blocks approval when PO has no vendor assigned", async () => {
|
||||||
const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`);
|
const poId = await makeReviewPo(`${PREFIX}NoVendorBlock`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await approvepo({ poId });
|
const result = await approvePo({ poId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
expect((result as { error: string }).error).toMatch(/vendor/i);
|
expect((result as { error: string }).error).toMatch(/vendor/i);
|
||||||
|
|
||||||
|
|
@ -105,9 +105,9 @@ describe("approval — vendor required", () => {
|
||||||
|
|
||||||
it("allows approval when PO has a vendor assigned", async () => {
|
it("allows approval when PO has a vendor assigned", async () => {
|
||||||
const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true);
|
const poId = await makeReviewPo(`${PREFIX}VendorPresent`, true);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
|
|
||||||
const result = await approvepo({ poId });
|
const result = await approvePo({ poId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
||||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||||
|
|
@ -120,14 +120,14 @@ describe("approval — vendor required", () => {
|
||||||
describe("provideVendorId — role expansion", () => {
|
describe("provideVendorId — role expansion", () => {
|
||||||
async function makePendingPo(title: string) {
|
async function makePendingPo(title: string) {
|
||||||
const poId = await makeReviewPo(title);
|
const poId = await makeReviewPo(title);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||||
await requestVendorId({ poId });
|
await requestVendorId({ poId });
|
||||||
return poId;
|
return poId;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("ACCOUNTS can provide a verified vendor ID", async () => {
|
it("ACCOUNTS can provide a verified vendor ID", async () => {
|
||||||
const poId = await makePendingPo(`${PREFIX}AccountsProvide`);
|
const poId = await makePendingPo(`${PREFIX}AccountsProvide`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
|
|
||||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
|
|
@ -139,7 +139,7 @@ describe("provideVendorId — role expansion", () => {
|
||||||
|
|
||||||
it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => {
|
it("rejects an unverified vendor (no vendorId field on Vendor record)", async () => {
|
||||||
const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`);
|
const poId = await makePendingPo(`${PREFIX}UnverifiedVendor`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
|
|
||||||
const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId });
|
const result = await provideVendorId({ poId, vendorId: unverifiedVendorDbId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
|
|
@ -150,7 +150,7 @@ describe("provideVendorId — role expansion", () => {
|
||||||
|
|
||||||
it("AUDITOR cannot provide vendor ID", async () => {
|
it("AUDITOR cannot provide vendor ID", async () => {
|
||||||
const poId = await makePendingPo(`${PREFIX}AuditorDenied`);
|
const poId = await makePendingPo(`${PREFIX}AuditorDenied`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(auditorId, "AUDITOR"));
|
||||||
|
|
||||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
|
|
@ -159,7 +159,7 @@ describe("provideVendorId — role expansion", () => {
|
||||||
it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => {
|
it("returns error when called on a PO not in VENDOR_ID_PENDING state", async () => {
|
||||||
// PO still in MGR_REVIEW — no requestVendorId called
|
// PO still in MGR_REVIEW — no requestVendorId called
|
||||||
const poId = await makeReviewPo(`${PREFIX}WrongState`);
|
const poId = await makeReviewPo(`${PREFIX}WrongState`);
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||||
|
|
||||||
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
const result = await provideVendorId({ poId, vendorId: verifiedVendorId });
|
||||||
expect(result).toHaveProperty("error");
|
expect(result).toHaveProperty("error");
|
||||||
|
|
|
||||||
67
App/tests/unit/attachments.test.ts
Normal file
67
App/tests/unit/attachments.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
categorizeAttachment,
|
||||||
|
groupAttachments,
|
||||||
|
} from "@/lib/attachments";
|
||||||
|
|
||||||
|
describe("categorizeAttachment", () => {
|
||||||
|
it("maps po-document keys to the submission group", () => {
|
||||||
|
expect(categorizeAttachment("po-document/po123/1700-invoice.pdf")).toBe("submission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps receipt keys to the delivery group", () => {
|
||||||
|
expect(categorizeAttachment("receipt/po123/1700-delivery.pdf")).toBe("delivery");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps payment keys to the payment group", () => {
|
||||||
|
expect(categorizeAttachment("payment-document/po123/proof.pdf")).toBe("payment");
|
||||||
|
expect(categorizeAttachment("payment/po123/proof.pdf")).toBe("payment");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to other for unknown prefixes", () => {
|
||||||
|
expect(categorizeAttachment("something-else/x.pdf")).toBe("other");
|
||||||
|
expect(categorizeAttachment("no-slash")).toBe("other");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groupAttachments", () => {
|
||||||
|
const doc = (id: string, storageKey: string) => ({ id, storageKey });
|
||||||
|
|
||||||
|
it("groups documents by lifecycle stage in canonical order", () => {
|
||||||
|
const groups = groupAttachments([
|
||||||
|
doc("a", "receipt/po1/delivery.pdf"),
|
||||||
|
doc("b", "po-document/po1/invoice.pdf"),
|
||||||
|
doc("c", "po-document/po1/quote.pdf"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "delivery"]);
|
||||||
|
expect(groups[0].items.map((d) => d.id)).toEqual(["b", "c"]);
|
||||||
|
expect(groups[1].items.map((d) => d.id)).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits empty groups", () => {
|
||||||
|
const groups = groupAttachments([doc("a", "po-document/po1/invoice.pdf")]);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].meta.key).toBe("submission");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when there are no documents", () => {
|
||||||
|
expect(groupAttachments([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves input order within a group", () => {
|
||||||
|
const groups = groupAttachments([
|
||||||
|
doc("first", "receipt/po1/a.pdf"),
|
||||||
|
doc("second", "receipt/po1/b.pdf"),
|
||||||
|
]);
|
||||||
|
expect(groups[0].items.map((d) => d.id)).toEqual(["first", "second"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects unknown prefixes into the other group last", () => {
|
||||||
|
const groups = groupAttachments([
|
||||||
|
doc("x", "mystery/po1/file.pdf"),
|
||||||
|
doc("y", "po-document/po1/invoice.pdf"),
|
||||||
|
]);
|
||||||
|
expect(groups.map((g) => g.meta.key)).toEqual(["submission", "other"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,8 +17,10 @@ describe("Permissions", () => {
|
||||||
expect(hasPermission("MANAGER", "approve_po")).toBe(true);
|
expect(hasPermission("MANAGER", "approve_po")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("MANAGER cannot process payment", () => {
|
// MANAGER was intentionally granted process_payment in commit e1340b9
|
||||||
expect(hasPermission("MANAGER", "process_payment")).toBe(false);
|
// ("chore(perm): manager permissions fix 2").
|
||||||
|
it("MANAGER can process payment", () => {
|
||||||
|
expect(hasPermission("MANAGER", "process_payment")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ACCOUNTS can process payment", () => {
|
it("ACCOUNTS can process payment", () => {
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,17 @@
|
||||||
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
|
* Tests parseSheet() against the real Sample_PO.xlsx fixture and synthetic
|
||||||
* workbooks built in-memory, without any HTTP or database layer.
|
* workbooks built in-memory, without any HTTP or database layer.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, beforeAll } from "vitest";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync, existsSync } from "fs";
|
||||||
import { resolve } from "path";
|
import { resolve } from "path";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
|
import { parseSheet, parseWorkbook, cellStr, cellNum } from "@/lib/po-import-parser";
|
||||||
|
|
||||||
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
const SAMPLE_PATH = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
||||||
|
// The original Sample_PO.xlsx lives outside the repo, so these fixture-backed
|
||||||
|
// tests skip wherever the file is absent (CI, other machines). The synthetic
|
||||||
|
// workbook tests below exercise the parser everywhere.
|
||||||
|
const HAS_SAMPLE = existsSync(SAMPLE_PATH);
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -77,7 +81,7 @@ describe("cellNum", () => {
|
||||||
|
|
||||||
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
|
// ── parseSheet against real Sample_PO.xlsx ───────────────────────────────────
|
||||||
|
|
||||||
describe("parseSheet — Sample_PO.xlsx", () => {
|
describe.skipIf(!HAS_SAMPLE)("parseSheet — Sample_PO.xlsx", () => {
|
||||||
let parsed: ReturnType<typeof parseSheet>;
|
let parsed: ReturnType<typeof parseSheet>;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|
@ -248,7 +252,7 @@ describe("parseSheet — synthetic edge cases", () => {
|
||||||
// ── parseWorkbook ─────────────────────────────────────────────────────────────
|
// ── parseWorkbook ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("parseWorkbook", () => {
|
describe("parseWorkbook", () => {
|
||||||
it("parses the real Sample_PO.xlsx and returns one result", () => {
|
it.skipIf(!HAS_SAMPLE)("parses the real Sample_PO.xlsx and returns one result", () => {
|
||||||
const buffer = readFileSync(SAMPLE_PATH);
|
const buffer = readFileSync(SAMPLE_PATH);
|
||||||
const results = parseWorkbook(buffer);
|
const results = parseWorkbook(buffer);
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,25 @@ describe("LineItemsEditor — edit mode", () => {
|
||||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
||||||
expect(lastCall[0].gstRate).toBeCloseTo(0.05);
|
expect(lastCall[0].gstRate).toBeCloseTo(0.05);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers month and year as unit-of-measure options", () => {
|
||||||
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={vi.fn()} />);
|
||||||
|
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||||
|
const unitSelect = selects.find((s) => s.value === "pc")!;
|
||||||
|
const values = Array.from(unitSelect.options).map((o) => o.value);
|
||||||
|
expect(values).toContain("month");
|
||||||
|
expect(values).toContain("year");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onChange with the selected duration unit", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<LineItemsEditor items={[DEFAULT_ITEM]} onChange={onChange} />);
|
||||||
|
const selects = screen.getAllByRole("combobox") as HTMLSelectElement[];
|
||||||
|
const unitSelect = selects.find((s) => s.value === "pc")!;
|
||||||
|
fireEvent.change(unitSelect, { target: { value: "year" } });
|
||||||
|
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0] as LineItemInput[];
|
||||||
|
expect(lastCall[0].unit).toBe("year");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Totals calculation (edit mode) ────────────────────────────────────────────
|
// ── Totals calculation (edit mode) ────────────────────────────────────────────
|
||||||
|
|
|
||||||
28
App/tests/unit/storage-keys.test.ts
Normal file
28
App/tests/unit/storage-keys.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCompanyAssetKey, buildSignatureKey } from "@/lib/storage";
|
||||||
|
|
||||||
|
describe("buildCompanyAssetKey", () => {
|
||||||
|
it("builds a deterministic logo key under the company namespace", () => {
|
||||||
|
expect(buildCompanyAssetKey("cmp123", "logo", "png")).toBe("company-assets/cmp123/logo.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a deterministic stamp key", () => {
|
||||||
|
expect(buildCompanyAssetKey("cmp123", "stamp", "webp")).toBe("company-assets/cmp123/stamp.webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable across re-uploads of the same type (overwrites in place)", () => {
|
||||||
|
const a = buildCompanyAssetKey("c1", "logo", "png");
|
||||||
|
const b = buildCompanyAssetKey("c1", "logo", "png");
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates logo and stamp into distinct keys", () => {
|
||||||
|
expect(buildCompanyAssetKey("c1", "logo", "png")).not.toBe(buildCompanyAssetKey("c1", "stamp", "png"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSignatureKey", () => {
|
||||||
|
it("keeps signatures in their own namespace", () => {
|
||||||
|
expect(buildSignatureKey("u1", "png")).toBe("signatures/u1.png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
formatCurrency, formatDate, formatDateTime,
|
formatCurrency, formatCompactINR, formatDate, formatDateTime,
|
||||||
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
||||||
} from "@/lib/utils";
|
} from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -32,6 +32,55 @@ describe("formatCurrency", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("formatCompactINR", () => {
|
||||||
|
it("abbreviates crore amounts with Cr", () => {
|
||||||
|
expect(formatCompactINR(20000000)).toBe("₹2 Cr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abbreviates lakh amounts with L", () => {
|
||||||
|
expect(formatCompactINR(4900000)).toBe("₹49 L");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abbreviates thousand amounts with K", () => {
|
||||||
|
expect(formatCompactINR(75000)).toBe("₹75 K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders sub-thousand amounts without a suffix", () => {
|
||||||
|
expect(formatCompactINR(500)).toBe("₹500");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats zero as ₹0", () => {
|
||||||
|
expect(formatCompactINR(0)).toBe("₹0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims trailing zeros but keeps significant decimals", () => {
|
||||||
|
expect(formatCompactINR(25000000)).toBe("₹2.5 Cr");
|
||||||
|
expect(formatCompactINR(4950000)).toBe("₹49.5 L");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds to at most two decimals", () => {
|
||||||
|
expect(formatCompactINR(12345678)).toBe("₹1.23 Cr");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the right unit at boundaries", () => {
|
||||||
|
expect(formatCompactINR(100000)).toBe("₹1 L");
|
||||||
|
expect(formatCompactINR(10000000)).toBe("₹1 Cr");
|
||||||
|
expect(formatCompactINR(1000)).toBe("₹1 K");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts string input", () => {
|
||||||
|
expect(formatCompactINR("4900000")).toBe("₹49 L");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the sign for negative amounts", () => {
|
||||||
|
expect(formatCompactINR(-4900000)).toBe("-₹49 L");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles non-finite input gracefully", () => {
|
||||||
|
expect(formatCompactINR(NaN)).toBe("₹0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatDate", () => {
|
describe("formatDate", () => {
|
||||||
it("returns a readable date string", () => {
|
it("returns a readable date string", () => {
|
||||||
const result = formatDate(new Date("2026-04-29"));
|
const result = formatDate(new Date("2026-04-29"));
|
||||||
|
|
|
||||||
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -4,7 +4,29 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Companies (multi-company invoicing)** — new `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). The company's details populate the exported PO header / invoice block.
|
||||||
|
- **Structured PO numbers** (`lib/po-number.ts`) — `COMPANY/VESSEL/ID/FY` (e.g. `PMS/HNR1/9000/2024-25`); Indian financial year; system-generated IDs start at 9000. Imported POs keep their original number.
|
||||||
|
- **3-level accounting-code hierarchy** — `Account.parentId` self-relation (Top Category → Sub-Category → Leaf), 6-digit numeric codes seeded from `prisma/accounting-codes-data.ts`. Only leaf codes are PO-selectable, via a searchable, portal-rendered combobox.
|
||||||
|
- **Compulsory payment date** — `PurchaseOrder.paymentDate` captured when Accounts records a payment; defaults to today, rejects future dates. Backfilled for existing POs from `paidAt` / the first payment action.
|
||||||
|
- **Editable PO date (`poDate`)** — the exported PO "Date" now shows `poDate ?? approvedAt ?? createdAt` (approval date once approved, not creation).
|
||||||
|
- **Submitter vendor creation** — `create_vendor` permission lets Technical/Manning add vendors; they are created **unverified** and become verified when a PO closes/pays with them, on import, or via Manager/Accounts/Admin (`verifyVendor`).
|
||||||
|
- **Import PO → Closed** — `/po/import` saves a parsed Excel PO directly as `CLOSED`, auto-detecting the company, matching the vessel by code, and auto-creating the vendor, products, and per-vendor prices.
|
||||||
|
- **Inventory feature flag** (`NEXT_PUBLIC_INVENTORY_ENABLED`) — site stock/consumption surfaces are gated; the vendor/item catalogue for PO creation stays available. Inventory is incremented at **PO approval** (not on close).
|
||||||
|
- **Dashboards** — Accounts gains a "Payments Completed This Month" card.
|
||||||
- **Automated issue-to-deploy pipeline** — end-to-end flow from a user-reported bug to a production fix without manual intervention on the developer's part:
|
- **Automated issue-to-deploy pipeline** — end-to-end flow from a user-reported bug to a production fix without manual intervention on the developer's part:
|
||||||
- **Report Issue button** (`App/components/layout/report-issue-button.tsx`) — any signed-in user can file a bug from the portal header; the server action (`report-issue-actions.ts`) calls the Forgejo API and attaches `portal` + `claude-queue` labels.
|
- **Report Issue button** (`App/components/layout/report-issue-button.tsx`) — any signed-in user can file a bug from the portal header; the server action (`report-issue-actions.ts`) calls the Forgejo API and attaches `portal` + `claude-queue` labels.
|
||||||
- **Claude issue watcher** (`automation/claude-issue-watcher.ps1`) — a Windows Scheduled Task (`PelagiaClaudeIssueWatcher`) polls Forgejo every 10 minutes, picks up `claude-queue` issues, and runs Claude Code headlessly to implement and verify a fix. On success the watcher pushes a `claude/issue-N` branch and opens a PR; on failure it posts a comment and labels the issue `claude-failed`.
|
- **Claude issue watcher** (`automation/claude-issue-watcher.ps1`) — a Windows Scheduled Task (`PelagiaClaudeIssueWatcher`) polls Forgejo every 10 minutes, picks up `claude-queue` issues, and runs Claude Code headlessly to implement and verify a fix. On success the watcher pushes a `claude/issue-N` branch and opens a PR; on failure it posts a comment and labels the issue `claude-failed`.
|
||||||
- **Tag-triggered deploy workflow** (`.forgejo/workflows/deploy.yml`) — pushing a `v*` semver tag triggers the `host` Forgejo runner on pms1, which checks out the tag, runs `pnpm install`, builds the app, applies Prisma migrations, and restarts the pm2 process `ppms`.
|
- **Tag-triggered deploy workflow** (`.forgejo/workflows/deploy.yml`) — pushing a `v*` semver tag triggers the `host` Forgejo runner on pms1, which checks out the tag, runs `pnpm install`, builds the app, applies Prisma migrations, and restarts the pm2 process `ppms`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Cost centre is now a Vessel only.** The earlier Vessel-or-Site cost-centre model was removed: `PurchaseOrder.vesselId` is required, the `costCentreRef` encoding is gone, and `Vessel` no longer links to a `Site`. Vessels are surfaced as **"Cost Centre"** throughout the UI (`/admin/vessels` → "Cost Centre Management").
|
||||||
|
- **Closed Purchase Orders** list: submitters see only their own `CLOSED` POs; Managers/SuperUsers see all `CLOSED` POs.
|
||||||
|
- **Sidebar** reorganised into **Purchasing** and **Administration** sections (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** page is reachable by every role (incl. SSO-only / no-password 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` had run. Migrations must be applied before the new build serves traffic (now documented in the README).
|
||||||
|
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
# Pelagia Portal — Design Document
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
Pelagia Portal is an internal purchase order (PO) management web application for a maritime / vessel-operations company. It digitises the entire PO lifecycle — from a crew member raising a requisition, through manager approval and vendor validation, to accounts payment processing and final receipt confirmation — replacing ad-hoc email chains and spreadsheets with a single, auditable system.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Goals & Non-Goals
|
|
||||||
|
|
||||||
### Goals
|
|
||||||
- Provide role-specific dashboards and workflows so every actor only sees what is relevant to their job.
|
|
||||||
- Enforce a structured, auditable approval chain for every purchase order.
|
|
||||||
- Notify all stakeholders at each state transition via email without manual action.
|
|
||||||
- Give management real-time spend visibility by vessel, project, and time period.
|
|
||||||
- Surface vendor information deficiencies before payment is blocked.
|
|
||||||
|
|
||||||
### Non-Goals
|
|
||||||
- Direct integration with external accounting or ERP software (out of scope for v1).
|
|
||||||
- Mobile-native apps (the web app is expected to be accessed on desktop/tablet).
|
|
||||||
- Supplier-facing self-service portal.
|
|
||||||
- Automated payment processing (Accounts team confirms payment manually).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Actors & Roles
|
|
||||||
|
|
||||||
| Role | Description | Key Permissions |
|
|
||||||
|---|---|---|
|
|
||||||
| **Technical** | Deck / engine crew raising POs for technical vessel needs | Create, edit draft, submit, confirm receipt |
|
|
||||||
| **Manning** | Crew-management staff raising POs for manning / crew needs | Same as Technical |
|
|
||||||
| **Manager** | Approves or rejects POs; can request edits, add vendor IDs, or directly amend line items during review | Review, approve, reject, request edits, edit line items (versioned), view all POs, history reports |
|
|
||||||
| **Accounts** | Processes payment for approved POs | View payment queue, mark as paid, view all POs |
|
|
||||||
| **SuperUser** | Elevated user with cross-team operational authority | All Technical + Manning + Manager permissions |
|
|
||||||
| **Auditor** | Read-only audit access across all records | View all POs, download audit trail, export reports |
|
|
||||||
| **Admin** | System administrator | Manage users, vessels, accounts, vendors; full CRUD on all entities |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. PO Lifecycle & State Machine
|
|
||||||
|
|
||||||
```
|
|
||||||
DRAFT ──(submit)──► SUBMITTED ──(system auto-move)──► MGR_REVIEW
|
|
||||||
│
|
|
||||||
┌──────────────────────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
(no vendor ID) (request edits) (reject)
|
|
||||||
▼ ▼ ▼
|
|
||||||
VENDOR_ID_PENDING EDITS_REQUESTED REJECTED
|
|
||||||
│ │
|
|
||||||
(ID provided) (resubmit)
|
|
||||||
└────────────────────┘
|
|
||||||
│
|
|
||||||
(approve / approve+note)
|
|
||||||
▼
|
|
||||||
MGR_APPROVED
|
|
||||||
│
|
|
||||||
(accounts picks up)
|
|
||||||
▼
|
|
||||||
SENT_FOR_PAYMENT
|
|
||||||
│
|
|
||||||
(payment confirmed)
|
|
||||||
▼
|
|
||||||
PAID_DELIVERED
|
|
||||||
│
|
|
||||||
(submitter confirms receipt)
|
|
||||||
▼
|
|
||||||
CLOSED
|
|
||||||
```
|
|
||||||
|
|
||||||
### Allowed State Transitions
|
|
||||||
|
|
||||||
| From | To | Actor | Trigger |
|
|
||||||
|---|---|---|---|
|
|
||||||
| DRAFT | SUBMITTED | Technical / Manning / SuperUser | Submit button |
|
|
||||||
| SUBMITTED | MGR_REVIEW | System | Auto on submit |
|
|
||||||
| MGR_REVIEW | VENDOR_ID_PENDING | Manager | Missing vendor ID |
|
|
||||||
| VENDOR_ID_PENDING | MGR_REVIEW | Submitter / Manager | Vendor ID supplied |
|
|
||||||
| MGR_REVIEW | EDITS_REQUESTED | Manager | Request edits action |
|
|
||||||
| EDITS_REQUESTED | SUBMITTED | Technical / Manning / SuperUser | Resubmit after edits |
|
|
||||||
| MGR_REVIEW | REJECTED | Manager | Reject action |
|
|
||||||
| MGR_REVIEW | MGR_APPROVED | Manager / SuperUser | Approve or Approve+Note |
|
|
||||||
| MGR_APPROVED | SENT_FOR_PAYMENT | Accounts | Pick up payment |
|
|
||||||
| SENT_FOR_PAYMENT | PAID_DELIVERED | Accounts | Confirm payment |
|
|
||||||
| PAID_DELIVERED | CLOSED | Technical / Manning / SuperUser | Confirm receipt |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Email Notification Matrix
|
|
||||||
|
|
||||||
| Event | Notified Parties |
|
|
||||||
|---|---|
|
|
||||||
| PO submitted | Manager(s), Submitter (confirmation) |
|
|
||||||
| Vendor ID requested | Submitter |
|
|
||||||
| Vendor ID supplied | Manager |
|
|
||||||
| Edits requested | Submitter (includes manager note) |
|
|
||||||
| PO resubmitted after edits | Manager |
|
|
||||||
| PO approved | Submitter, Accounts (with PO attachment) |
|
|
||||||
| PO approved with note | Submitter (with note), Accounts |
|
|
||||||
| PO rejected | Submitter (with rejection reason) |
|
|
||||||
| Payment sent | Submitter, Manager |
|
|
||||||
| Receipt confirmed | Manager, Accounts |
|
|
||||||
| PO closed | Submitter, Manager, Accounts |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Screen Inventory
|
|
||||||
|
|
||||||
### 6.1 Authentication
|
|
||||||
- **Login** — Employee ID / email + password. Role badge hints displayed. No self-registration; accounts provisioned by Admin.
|
|
||||||
|
|
||||||
### 6.2 Dashboards (role-specific landing pages)
|
|
||||||
- **Technical / Manning Dashboard** — My open POs count, pending approvals, completed orders, quick-access "New PO" CTA. Full list of all POs (open and historical) is accessible and each PO is openable from the dashboard.
|
|
||||||
- **Manager Dashboard** — Approvals awaiting count, approved POs listing with per-PO expense breakdown (line items + totals), spend by vessel (bar chart), spend by month (bar chart), recent activity feed.
|
|
||||||
- **Accounts Dashboard** — Payment queue total value, ready-for-payment item count, recently processed items.
|
|
||||||
|
|
||||||
### 6.3 PO Creation & Editing
|
|
||||||
- **New PO Form** — Multi-section form:
|
|
||||||
- Order Info: title, vessel, account, project code, date required
|
|
||||||
- Line Items: add / remove rows (description, qty, unit, unit price, total)
|
|
||||||
- Vendor: vendor name, vendor ID (optional at creation), contact
|
|
||||||
- Documents: drag-and-drop upload, file list with remove
|
|
||||||
- Approval Flow: read-only visual showing who will review
|
|
||||||
- **Edit PO** — Same form, pre-populated; only available when PO is in DRAFT or EDITS_REQUESTED.
|
|
||||||
|
|
||||||
### 6.4 Manager Approval
|
|
||||||
- **Approval Queue** — Paginated list with search (PO number, vessel, submitter) and filters (date range, vessel). Each row shows PO number, submitter, vessel, amount, days waiting.
|
|
||||||
- **PO Detail / Decision View** — Full PO summary, line items, attached documents, vendor info with verification callout (NEW if no ID). 4-action bar: Reject | Request Edits | Approve | Approve + Note.
|
|
||||||
|
|
||||||
### 6.5 Accounts Payment Queue
|
|
||||||
- **Payment Queue** — Approved POs ready for payment. Shows PO summary, total amount, bank / payment ref fields. "Mark as Paid" action.
|
|
||||||
|
|
||||||
### 6.6 Order Tracking (Submitter)
|
|
||||||
- **My Orders** — Card list with live status indicator, progress step-bar, latest manager note, and "Confirm Receipt" CTA when in PAID_DELIVERED.
|
|
||||||
|
|
||||||
### 6.7 Receipt Confirmation
|
|
||||||
- **Receipt Screen** — Upload receipt / invoice image, delivery confirmation checklist, optional notes. Submits to close the PO.
|
|
||||||
|
|
||||||
### 6.8 Manager History / Reports
|
|
||||||
- **History** — Full PO audit list with date, submitter, vessel, status, amount. Export to CSV / PDF. Filter by date range, vessel, status.
|
|
||||||
|
|
||||||
### 6.9 Administration (Admin role)
|
|
||||||
- **User Management** — CRUD for user accounts, role assignment.
|
|
||||||
- **Vessel Management** — CRUD for vessels.
|
|
||||||
- **Account Management** — CRUD for accounts / cost centres.
|
|
||||||
- **Vendor Management** — CRUD for approved vendor registry.
|
|
||||||
- **Product Catalogue** — CRUD for products: product code, name, description. Last known unit price and associated vendor are read-only in this view — they are auto-populated when a PO containing that product is marked as paid.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Design System
|
|
||||||
|
|
||||||
### 7.1 Colour Palette
|
|
||||||
|
|
||||||
| Token | Hex | Usage |
|
|
||||||
|---|---|---|
|
|
||||||
| `primary` | `#2563EB` | Primary actions, active states, links |
|
|
||||||
| `primary-dark` | `#1D4ED8` | Hover on primary |
|
|
||||||
| `success` | `#16A34A` | Approved, paid, closed states |
|
|
||||||
| `warning` | `#D97706` | Pending review, edits requested |
|
|
||||||
| `danger` | `#DC2626` | Rejected, destructive actions |
|
|
||||||
| `neutral-50` | `#F9FAFB` | Page background |
|
|
||||||
| `neutral-100` | `#F3F4F6` | Card / panel background |
|
|
||||||
| `neutral-700` | `#374151` | Body text |
|
|
||||||
| `neutral-900` | `#111827` | Headings |
|
|
||||||
|
|
||||||
### 7.2 Typography
|
|
||||||
|
|
||||||
| Element | Font | Weight | Size |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Headings (H1–H3) | Inter | 600–700 | 24 / 20 / 16 px |
|
|
||||||
| Body | Inter | 400 | 14 px |
|
|
||||||
| Labels / captions | Inter | 500 | 12 px |
|
|
||||||
| Data / mono values | JetBrains Mono | 400 | 13 px |
|
|
||||||
|
|
||||||
### 7.3 Component Conventions
|
|
||||||
- Cards use `rounded-lg`, `shadow-sm`, 16 px padding.
|
|
||||||
- Status badges use pill shape with colour-coded background matching state machine colours.
|
|
||||||
- Tables use alternating row shading, sticky header on scroll.
|
|
||||||
- Forms use floating labels; validation errors appear below the field in `danger` colour.
|
|
||||||
- Action buttons: primary = blue fill, secondary = white with border, danger = red fill.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. User Stories (Priority P0 = must-have, P1 = should-have, P2 = nice-to-have)
|
|
||||||
|
|
||||||
### Submitter (Technical / Manning)
|
|
||||||
| ID | Story | Priority |
|
|
||||||
|---|---|---|
|
|
||||||
| S-01 | As a submitter, I can create a PO with line items and attach documents. | P0 |
|
|
||||||
| S-02 | As a submitter, I can save a PO as draft before submitting. | P0 |
|
|
||||||
| S-03 | As a submitter, I can submit a draft PO for manager approval. | P0 |
|
|
||||||
| S-04 | As a submitter, I receive an email when my PO is approved or rejected. | P0 |
|
|
||||||
| S-05 | As a submitter, I can view the current status and history of all my POs. | P0 |
|
|
||||||
| S-06 | As a submitter, I can provide a vendor ID when requested by a manager. | P0 |
|
|
||||||
| S-07 | As a submitter, I can edit and resubmit a PO when edits are requested. | P0 |
|
|
||||||
| S-08 | As a submitter, I can confirm receipt and upload a receipt document to close a PO. | P0 |
|
|
||||||
|
|
||||||
### Manager
|
|
||||||
| ID | Story | Priority |
|
|
||||||
|---|---|---|
|
|
||||||
| M-01 | As a manager, I see all POs awaiting my approval in a queue. | P0 |
|
|
||||||
| M-02 | As a manager, I can approve, reject, or request edits on a PO. | P0 |
|
|
||||||
| M-03 | As a manager, I can add a note when approving or rejecting. | P0 |
|
|
||||||
| M-04 | As a manager, I can flag a PO for vendor ID verification. | P0 |
|
|
||||||
| M-05 | As a manager, I can view spend analytics by vessel and month. | P1 |
|
|
||||||
| M-06 | As a manager, I can export a full PO history report as CSV or PDF. | P1 |
|
|
||||||
|
|
||||||
### Accounts
|
|
||||||
| ID | Story | Priority |
|
|
||||||
|---|---|---|
|
|
||||||
| A-01 | As an accounts user, I see all manager-approved POs ready for payment. | P0 |
|
|
||||||
| A-02 | As an accounts user, I can mark a PO as paid with a reference number. | P0 |
|
|
||||||
| A-03 | As an accounts user, I receive email when a new PO enters my payment queue. | P0 |
|
|
||||||
|
|
||||||
### Admin
|
|
||||||
| ID | Story | Priority |
|
|
||||||
|---|---|---|
|
|
||||||
| AD-01 | As an admin, I can create, edit, and deactivate user accounts. | P0 |
|
|
||||||
| AD-02 | As an admin, I can manage vessels, accounts, and vendors. | P0 |
|
|
||||||
| AD-03 | As an admin, I can manage the product catalogue (codes, names, descriptions). Last known prices and vendors are automatically updated when a PO is paid. | P1 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Accessibility & Internationalisation
|
|
||||||
- WCAG 2.1 AA compliance target.
|
|
||||||
- All interactive elements keyboard-navigable with visible focus ring.
|
|
||||||
- Colour is never the sole conveyor of meaning (icons + labels accompany status colours).
|
|
||||||
- English only for v1; i18n architecture (react-i18next) to be wired up but not populated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Open Questions
|
|
||||||
- Should managers be able to directly edit a PO (bypass submitter) in exceptional circumstances?
|
|
||||||
- What is the approval chain for high-value POs — single manager or dual sign-off?
|
|
||||||
- Should the vendor registry be editable by managers, or Admin-only?
|
|
||||||
- Is SSO (e.g., Azure AD) required for login, or internal credential management is sufficient?
|
|
||||||
|
|
@ -1,566 +0,0 @@
|
||||||
# Pelagia Portal — Architecture Document
|
|
||||||
|
|
||||||
## 1. Technology Stack
|
|
||||||
|
|
||||||
### 1.1 Decision Summary
|
|
||||||
|
|
||||||
The portal is an internal line-of-business app with a well-defined data model, multi-role access, and transactional workflows. The stack below optimises for **developer velocity**, **type safety end-to-end**, and **operational simplicity** (minimal infrastructure to manage).
|
|
||||||
|
|
||||||
| Layer | Choice | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| **Framework** | Next.js 15 (App Router) | Full-stack React; server components reduce client JS; built-in API routes; excellent TypeScript support |
|
|
||||||
| **Language** | TypeScript 5 (strict mode) | Shared types between frontend and backend; catches contract mismatches at compile time |
|
|
||||||
| **UI Library** | React 19 | Concurrent rendering, Server Components |
|
|
||||||
| **Component Library** | shadcn/ui + Radix UI primitives | Accessible, unstyled primitives; copy-owned source, no black-box upgrade surprises |
|
|
||||||
| **Styling** | Tailwind CSS v4 | Utility-first; consistent design tokens; no CSS specificity battles |
|
|
||||||
| **ORM** | Prisma 5 | Type-safe DB client; schema-first migrations; Prisma Studio for admin data inspection |
|
|
||||||
| **Database** | PostgreSQL 16 | ACID transactions; JSON columns for flexible line-item metadata; mature RBAC at row level |
|
|
||||||
| **Auth** | NextAuth.js v5 (Auth.js) | Session-cookie auth; credentials provider for internal accounts; easy SSO adapter upgrade path |
|
|
||||||
| **File Storage** | Cloudflare R2 (S3-compatible) in production; local filesystem in development | Cheap egress; S3 API compatibility; presigned URLs keep uploads off the app server; dev mode avoids paid services |
|
|
||||||
| **Email** | Resend + React Email in production; console log in development | Transactional email with React-rendered templates; generous free tier; reliable deliverability; dev mode requires no API key |
|
|
||||||
| **Charts** | Recharts | Lightweight; composable; works well with server-fetched data in RSC |
|
|
||||||
| **Validation** | Zod | Schema validation shared between server actions and client form validation |
|
|
||||||
| **Testing** | Vitest + React Testing Library + Playwright | Unit/integration fast with Vitest; E2E critical paths with Playwright |
|
|
||||||
| **CI/CD** | GitHub Actions | Lint, type-check, test, build on every PR; deploy on merge to main |
|
|
||||||
| **Hosting** | Vercel (app) + Supabase (Postgres + Storage fallback) | Zero-config deploys; Vercel serverless functions match Next.js well |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. High-Level System Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────┐
|
|
||||||
│ Browser │
|
|
||||||
│ React 19 + shadcn/ui + Tailwind │
|
|
||||||
│ Server Components (read) + Client Components (forms) │
|
|
||||||
└──────────────────┬──────────────────────────────────────┘
|
|
||||||
│ HTTPS
|
|
||||||
┌──────────────────▼──────────────────────────────────────┐
|
|
||||||
│ Next.js 15 App Server │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
|
||||||
│ │ App Router Pages │ │ Server Actions / API │ │
|
|
||||||
│ │ (RSC rendering) │ │ Route Handlers │ │
|
|
||||||
│ └─────────────────────┘ └──────────┬──────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌────────────────────────────────────▼──────────────┐ │
|
|
||||||
│ │ Business Logic Layer │ │
|
|
||||||
│ │ (PO state machine, permission checks, notifier) │ │
|
|
||||||
│ └──────────────────────┬────────────────────────────┘ │
|
|
||||||
└─────────────────────────┼────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────────┼───────────────┐
|
|
||||||
│ │ │
|
|
||||||
┌─────────▼────┐ ┌───────▼──────┐ ┌────▼──────────┐
|
|
||||||
│ PostgreSQL │ │ Cloudflare R2│ │ Resend │
|
|
||||||
│ (Prisma) │ │ (documents, │ │ (transact- │
|
|
||||||
│ │ │ receipts) │ │ ional email) │
|
|
||||||
└──────────────┘ └──────────────┘ └───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Application Layer Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
pelagia-portal/
|
|
||||||
├── app/ # Next.js App Router
|
|
||||||
│ ├── (auth)/
|
|
||||||
│ │ └── login/
|
|
||||||
│ ├── (portal)/ # Authenticated shell
|
|
||||||
│ │ ├── layout.tsx # Sidebar + header shell
|
|
||||||
│ │ ├── dashboard/
|
|
||||||
│ │ ├── po/
|
|
||||||
│ │ │ ├── new/
|
|
||||||
│ │ │ ├── [id]/
|
|
||||||
│ │ │ │ ├── page.tsx # Detail view
|
|
||||||
│ │ │ │ └── edit/
|
|
||||||
│ │ ├── approvals/
|
|
||||||
│ │ ├── payments/
|
|
||||||
│ │ ├── history/
|
|
||||||
│ │ └── admin/
|
|
||||||
│ │ ├── users/
|
|
||||||
│ │ ├── vessels/
|
|
||||||
│ │ ├── accounts/
|
|
||||||
│ │ └── vendors/
|
|
||||||
│ └── api/
|
|
||||||
│ ├── auth/[...nextauth]/
|
|
||||||
│ └── files/
|
|
||||||
│ ├── sign/ # Generate presigned upload URL (production)
|
|
||||||
│ └── dev/[...key]/ # Local file upload/download handler (dev only)
|
|
||||||
│
|
|
||||||
├── components/
|
|
||||||
│ ├── ui/ # shadcn/ui primitives (owned copies)
|
|
||||||
│ ├── po/ # PO-specific composite components
|
|
||||||
│ ├── dashboard/
|
|
||||||
│ └── layout/
|
|
||||||
│
|
|
||||||
├── lib/
|
|
||||||
│ ├── db.ts # Prisma client singleton
|
|
||||||
│ ├── auth.ts # NextAuth config
|
|
||||||
│ ├── po-state-machine.ts # State transition logic + guards
|
|
||||||
│ ├── permissions.ts # Role → allowed-action map
|
|
||||||
│ ├── notifier.ts # Email dispatch (wraps Resend)
|
|
||||||
│ ├── storage.ts # R2 presigned URL helpers
|
|
||||||
│ └── validations/ # Zod schemas
|
|
||||||
│
|
|
||||||
├── emails/ # React Email templates
|
|
||||||
│ ├── po-submitted.tsx
|
|
||||||
│ ├── po-approved.tsx
|
|
||||||
│ ├── po-rejected.tsx
|
|
||||||
│ ├── edits-requested.tsx
|
|
||||||
│ ├── vendor-id-needed.tsx
|
|
||||||
│ ├── payment-processed.tsx
|
|
||||||
│ └── receipt-confirmed.tsx
|
|
||||||
│
|
|
||||||
├── prisma/
|
|
||||||
│ ├── schema.prisma
|
|
||||||
│ └── migrations/
|
|
||||||
│
|
|
||||||
└── tests/
|
|
||||||
├── unit/
|
|
||||||
├── integration/
|
|
||||||
└── e2e/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Data Model
|
|
||||||
|
|
||||||
### 4.1 Entity Relationship (Prisma Schema)
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
// prisma/schema.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
|
|
||||||
PAID_DELIVERED
|
|
||||||
CLOSED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ActionType {
|
|
||||||
CREATED
|
|
||||||
SUBMITTED
|
|
||||||
APPROVED
|
|
||||||
APPROVED_WITH_NOTE
|
|
||||||
REJECTED
|
|
||||||
EDITS_REQUESTED
|
|
||||||
VENDOR_ID_REQUESTED
|
|
||||||
VENDOR_ID_PROVIDED
|
|
||||||
PAYMENT_SENT
|
|
||||||
RECEIPT_CONFIRMED
|
|
||||||
CLOSED
|
|
||||||
REASSIGNED
|
|
||||||
PRODUCT_PRICE_UPDATED
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
employeeId String @unique
|
|
||||||
email String @unique
|
|
||||||
name String
|
|
||||||
passwordHash String
|
|
||||||
role Role
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
submittedPOs PurchaseOrder[] @relation("Submitter")
|
|
||||||
actions POAction[]
|
|
||||||
notifications Notification[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Vessel {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
|
|
||||||
siteId String?
|
|
||||||
site Site? @relation(fields: [siteId], references: [id])
|
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
code String @unique
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Vendor {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String
|
|
||||||
vendorId String? @unique
|
|
||||||
contactName String?
|
|
||||||
contactEmail String?
|
|
||||||
isVerified Boolean @default(false)
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
purchaseOrders PurchaseOrder[]
|
|
||||||
products Product[] @relation("ProductLastVendor")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Product {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
code String @unique
|
|
||||||
name String
|
|
||||||
description String?
|
|
||||||
lastPrice Decimal? @db.Decimal(12, 2)
|
|
||||||
lastVendorId String?
|
|
||||||
lastVendor Vendor? @relation("ProductLastVendor", fields: [lastVendorId], references: [id])
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
lineItems POLineItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PurchaseOrder {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
poNumber String @unique @default(cuid()) // formatted in app layer
|
|
||||||
title String
|
|
||||||
status POStatus @default(DRAFT)
|
|
||||||
totalAmount Decimal @db.Decimal(12, 2)
|
|
||||||
currency String @default("USD")
|
|
||||||
dateRequired DateTime?
|
|
||||||
projectCode String?
|
|
||||||
managerNote String?
|
|
||||||
paymentRef String?
|
|
||||||
submittedAt DateTime?
|
|
||||||
approvedAt DateTime?
|
|
||||||
paidAt DateTime?
|
|
||||||
closedAt DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
submitterId String
|
|
||||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
|
||||||
vesselId String
|
|
||||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
|
||||||
accountId String
|
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
|
||||||
vendorId String?
|
|
||||||
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
|
||||||
|
|
||||||
lineItems POLineItem[]
|
|
||||||
documents PODocument[]
|
|
||||||
actions POAction[]
|
|
||||||
receipt Receipt?
|
|
||||||
notifications Notification[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model POLineItem {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
description String
|
|
||||||
quantity Decimal @db.Decimal(10, 3)
|
|
||||||
unit String
|
|
||||||
unitPrice Decimal @db.Decimal(12, 2)
|
|
||||||
totalPrice Decimal @db.Decimal(12, 2)
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
productId String?
|
|
||||||
product Product? @relation(fields: [productId], references: [id])
|
|
||||||
|
|
||||||
poId String
|
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model PODocument {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
fileName String
|
|
||||||
fileSize Int
|
|
||||||
mimeType String
|
|
||||||
storageKey String // R2 object key
|
|
||||||
uploadedAt DateTime @default(now())
|
|
||||||
|
|
||||||
poId String
|
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model POAction {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
actionType ActionType
|
|
||||||
note String?
|
|
||||||
metadata Json? // flexible: payment ref, vendor ID, etc.
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
poId String
|
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id])
|
|
||||||
actorId String
|
|
||||||
actor User @relation(fields: [actorId], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Receipt {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
storageKey String // R2 object key
|
|
||||||
fileName String
|
|
||||||
notes String?
|
|
||||||
confirmedAt DateTime @default(now())
|
|
||||||
|
|
||||||
poId String @unique
|
|
||||||
po PurchaseOrder @relation(fields: [poId], references: [id])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Notification {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
subject String
|
|
||||||
body String
|
|
||||||
sentAt DateTime @default(now())
|
|
||||||
status String @default("sent") // sent | failed | bounced
|
|
||||||
|
|
||||||
poId String?
|
|
||||||
po PurchaseOrder? @relation(fields: [poId], references: [id])
|
|
||||||
userId String
|
|
||||||
user User @relation(fields: [userId], references: [id])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Authentication & Authorisation
|
|
||||||
|
|
||||||
### 5.1 Authentication
|
|
||||||
- Session-cookie based via NextAuth.js v5, `CredentialsProvider`.
|
|
||||||
- Passwords hashed with bcrypt (cost factor 12).
|
|
||||||
- Sessions stored server-side (database adapter); JWT not used to avoid stale role tokens.
|
|
||||||
- Session contains: `userId`, `role`, `name`, `email`.
|
|
||||||
|
|
||||||
### 5.2 Authorisation Model
|
|
||||||
Role permissions are enforced in a central `lib/permissions.ts` module and checked in Server Actions / Route Handlers before any data mutation. React Server Components also gate entire page segments server-side.
|
|
||||||
|
|
||||||
```
|
|
||||||
Action | Technical | 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 | | | | ✓ | ✓ | ✓ | ✓
|
|
||||||
manage_users | | | | | | | ✓
|
|
||||||
manage_vendors | | | | | | | ✓
|
|
||||||
manage_vessels_accounts | | | | | | | ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. PO State Machine Implementation
|
|
||||||
|
|
||||||
The state machine lives entirely in `lib/po-state-machine.ts`. No state transition may be performed without going through this module, ensuring the graph is enforced in one place.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// lib/po-state-machine.ts (illustrative)
|
|
||||||
|
|
||||||
export type POStatus =
|
|
||||||
| 'DRAFT' | 'SUBMITTED' | 'MGR_REVIEW' | 'VENDOR_ID_PENDING'
|
|
||||||
| 'EDITS_REQUESTED' | 'REJECTED' | 'MGR_APPROVED'
|
|
||||||
| 'SENT_FOR_PAYMENT' | 'PAID_DELIVERED' | 'CLOSED';
|
|
||||||
|
|
||||||
interface Transition {
|
|
||||||
to: POStatus;
|
|
||||||
allowedRoles: Role[];
|
|
||||||
requiresNote?: boolean;
|
|
||||||
sideEffects: SideEffect[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transitions: Record<POStatus, Record<string, Transition>> = {
|
|
||||||
DRAFT: {
|
|
||||||
submit: { to: 'SUBMITTED', allowedRoles: ['TECHNICAL','MANNING','SUPERUSER'], sideEffects: ['EMAIL_MANAGER'] },
|
|
||||||
},
|
|
||||||
MGR_REVIEW: {
|
|
||||||
approve: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] },
|
|
||||||
approve_note: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] },
|
|
||||||
reject: { to: 'REJECTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] },
|
|
||||||
request_edits: { to: 'EDITS_REQUESTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] },
|
|
||||||
request_vendor: { to: 'VENDOR_ID_PENDING', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER'] },
|
|
||||||
},
|
|
||||||
SENT_FOR_PAYMENT: {
|
|
||||||
confirm_payment: { to: 'PAID_DELIVERED', allowedRoles: ['ACCOUNTS'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_MANAGER','UPDATE_PRODUCT_PRICES'] },
|
|
||||||
},
|
|
||||||
// ...
|
|
||||||
};
|
|
||||||
|
|
||||||
export function canTransition(from: POStatus, action: string, role: Role): boolean { ... }
|
|
||||||
export async function applyTransition(poId: string, action: string, actor: User, note?: string): Promise<PurchaseOrder> { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Product Price Auto-Update (`UPDATE_PRODUCT_PRICES` side effect)
|
|
||||||
|
|
||||||
When `confirm_payment` fires on a `SENT_FOR_PAYMENT` PO, `applyTransition` iterates every line item that carries a `productId`. For each one it sets `Product.lastPrice = lineItem.unitPrice` and `Product.lastVendorId = po.vendorId`. A `PRODUCT_PRICE_UPDATED` `POAction` is logged per updated product. Line items without a `productId` are skipped.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. File Upload Flow
|
|
||||||
|
|
||||||
To avoid routing large files through the app server, uploads use **presigned URLs** in production. Development uses a local equivalent to avoid requiring Cloudflare credentials.
|
|
||||||
|
|
||||||
**Production (`NODE_ENV=production`) — Cloudflare R2:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Client App Server Cloudflare R2
|
|
||||||
│ │ │
|
|
||||||
│── POST /api/files/sign ──►│ │
|
|
||||||
│ { fileName, mimeType } │ │
|
|
||||||
│ │── generate presigned ─►│
|
|
||||||
│ │◄─── presigned URL ─────│
|
|
||||||
│◄── { uploadUrl, key } ────│ │
|
|
||||||
│ │ │
|
|
||||||
│─────── PUT uploadUrl ──────────────────────────────►│
|
|
||||||
│ │ │
|
|
||||||
│── Server Action: link ───►│ │
|
|
||||||
│ { poId, key, meta } │── INSERT PODocument ──►│ (DB)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Development (`NODE_ENV=development`) — local filesystem:**
|
|
||||||
|
|
||||||
```
|
|
||||||
Client App Server .dev-uploads/
|
|
||||||
│ │ │
|
|
||||||
│── POST /api/files/sign ──►│ │
|
|
||||||
│ { fileName, mimeType } │ │
|
|
||||||
│◄── { uploadUrl, key } ────│ │
|
|
||||||
│ uploadUrl = /api/files/dev/<key> │
|
|
||||||
│ │ │
|
|
||||||
│── PUT /api/files/dev/<key>►│ │
|
|
||||||
│ │── write to disk ───────►│
|
|
||||||
│ │ │
|
|
||||||
│── Server Action: link ───►│ │
|
|
||||||
│ { poId, key, meta } │── INSERT PODocument ──►│ (DB)
|
|
||||||
```
|
|
||||||
|
|
||||||
Downloads follow the same pattern: `generateDownloadUrl` returns a `/api/files/dev/<key>` GET URL in development and an R2 presigned URL in production. The `app/api/files/dev/[...key]/route.ts` route is auth-gated and returns 404 in production.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Notification System
|
|
||||||
|
|
||||||
`lib/notifier.ts` is the single point for dispatching emails. It is called exclusively from within state-machine side-effects, never directly from UI handlers.
|
|
||||||
|
|
||||||
```
|
|
||||||
notifier.notify({
|
|
||||||
event: 'PO_APPROVED',
|
|
||||||
po: PurchaseOrder, // full PO with relations
|
|
||||||
recipients: User[], // resolved from event matrix
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**In production**, email templates live in `/emails/` as React Email components, rendered server-side with `@react-email/render` and sent via the Resend SDK.
|
|
||||||
|
|
||||||
**In development**, the email content (recipient, subject, body) is printed to the terminal instead of being sent. No Resend API key is required.
|
|
||||||
|
|
||||||
In both modes, all notification events are persisted in the `Notification` table for audit purposes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. API Surface
|
|
||||||
|
|
||||||
All data mutations are implemented as **Next.js Server Actions** (no separate REST endpoints for mutations). Queries use React Server Components where possible; client components call `fetch` against route handlers only for dynamic/paginated data.
|
|
||||||
|
|
||||||
| Route Handler | Method | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `/api/auth/[...nextauth]` | GET/POST | Auth.js session endpoints |
|
|
||||||
| `/api/files/sign` | POST | Generate R2 presigned upload URL |
|
|
||||||
| `/api/po/[id]/export` | GET | Export single PO as PDF |
|
|
||||||
| `/api/reports/export` | GET | Export history report as CSV/PDF |
|
|
||||||
|
|
||||||
All other data operations (create PO, approve, reject, etc.) are Server Actions in `app/(portal)/*/actions.ts` co-located with their page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Deployment Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌────────────────────────────────────────────────┐
|
|
||||||
│ Vercel │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────┐ │
|
|
||||||
│ │ Next.js App (Edge + Node.js) │ │
|
|
||||||
│ │ - Static assets via Vercel CDN │ │
|
|
||||||
│ │ - Server Components on Node.js runtime │ │
|
|
||||||
│ │ - API routes / Server Actions │ │
|
|
||||||
│ └──────────────────────────────────────────┘ │
|
|
||||||
└────────────────────────────────────────────────┘
|
|
||||||
│ │
|
|
||||||
┌────────▼──────┐ ┌────────▼──────────────┐
|
|
||||||
│ Supabase │ │ Cloudflare R2 │
|
|
||||||
│ PostgreSQL │ │ (document storage) │
|
|
||||||
│ (managed, │ │ │
|
|
||||||
│ auto-backup)│ └────────────────────────┘
|
|
||||||
└───────────────┘
|
|
||||||
│
|
|
||||||
┌────────▼──────┐
|
|
||||||
│ Resend │
|
|
||||||
│ (email API) │
|
|
||||||
└───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
The set of required variables differs between development and production. The switch is automatic — controlled by `NODE_ENV` (set to `development` by `next dev` and `production` by `next build/start`).
|
|
||||||
|
|
||||||
| Variable | Dev Required | Prod Required | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `NEXTAUTH_SECRET` | Yes | Yes | 32-char random secret |
|
|
||||||
| `NEXTAUTH_URL` | Yes | Yes | Full app URL |
|
|
||||||
| `DATABASE_URL` | Yes | Yes | PostgreSQL connection string |
|
|
||||||
| `R2_ACCOUNT_ID` | No | Yes | Cloudflare account ID |
|
|
||||||
| `R2_ACCESS_KEY_ID` | No | Yes | R2 access key |
|
|
||||||
| `R2_SECRET_ACCESS_KEY` | No | Yes | R2 secret key |
|
|
||||||
| `R2_BUCKET_NAME` | No | Yes | R2 bucket name |
|
|
||||||
| `R2_PUBLIC_URL` | No | Yes | Public R2 bucket URL |
|
|
||||||
| `RESEND_API_KEY` | No | Yes | Resend API key |
|
|
||||||
| `EMAIL_FROM` | No | Yes | Sender address |
|
|
||||||
| `EMAIL_FROM_NAME` | No | No | Display name (default: "Pelagia Portal") |
|
|
||||||
|
|
||||||
In development, uploaded files are stored in `.dev-uploads/` at the project root and emails are printed to the terminal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Testing Strategy
|
|
||||||
|
|
||||||
| Layer | Tool | What is tested |
|
|
||||||
|---|---|---|
|
|
||||||
| Unit | Vitest | State machine transitions, permission checks, Zod validators, utility functions |
|
|
||||||
| Integration | Vitest + Prisma test DB | Server Actions against a real test database; seeded with fixture data |
|
|
||||||
| E2E | Playwright | Full happy paths per role: create PO → approve → pay → confirm receipt |
|
|
||||||
| Accessibility | axe-core + Playwright | WCAG violations on key pages |
|
|
||||||
|
|
||||||
CI runs all tests on every pull request. Playwright E2E runs against a preview deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Development Conventions
|
|
||||||
|
|
||||||
- **Branch strategy**: `main` (production) ← `staging` ← feature branches (`feat/`, `fix/`, `chore/`).
|
|
||||||
- **Commit style**: Conventional Commits (`feat:`, `fix:`, `refactor:`).
|
|
||||||
- **Code quality**: ESLint (Next.js config) + Prettier + TypeScript strict mode; enforced via husky pre-commit hook.
|
|
||||||
- **Database migrations**: Never edit `schema.prisma` without generating and committing a migration (`prisma migrate dev`). Migration files are committed and reviewed in PRs.
|
|
||||||
- **Secrets**: Never committed; managed via Vercel environment variable UI and `.env.local` locally (`.env.local` is git-ignored).
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Pelagia Portal — Open Questions & Decisions Log
|
|
||||||
|
|
||||||
Track decisions that need sign-off before the corresponding feature is built. Update the Status column when resolved.
|
|
||||||
|
|
||||||
| # | Question | Raised By | Status | Decision |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | Should a manager be able to directly edit a PO (bypass the submitter edit cycle) in exceptional circumstances? | Design review | Open | — |
|
|
||||||
| 2 | Is dual sign-off required for POs above a certain value threshold? If so, what is the threshold and how is the second approver selected? | Design review | Open | — |
|
|
||||||
| 3 | Is the vendor registry Admin-only, or can Managers also add/edit vendors? | Design review | Open | — |
|
|
||||||
| 4 | Is SSO (Azure AD / Google Workspace) required for login, or is internal credential management sufficient for v1? | Architecture review | Open | — |
|
|
||||||
| 5 | What currency / currencies does the system need to support? Is multi-currency (with FX rates) in scope? | Design review | Open | — |
|
|
||||||
| 6 | Should rejected POs be hard-deleted after a retention period or permanently archived? How long is the retention window? | Legal / compliance | Open | — |
|
|
||||||
| 7 | Should documents (PO attachments, receipts) be publicly accessible via URL, or always served through a signed/authenticated download? | Security review | Open | — |
|
|
||||||
| 8 | Are there specific vessels or accounts that certain submitters are restricted to (i.e., row-level vessel permissions), or is any submitter able to raise a PO against any vessel? | Design review | Open | — |
|
|
||||||
| 9 | What is the expected volume? (POs per day, concurrent users) — affects connection-pool sizing and whether Vercel serverless is sufficient. | Architecture review | Open | — |
|
|
||||||
| 10 | Should Manager analytics (spend by vessel/month) include only CLOSED POs, or all POs from MGR_APPROVED onwards? | Design review | Open | — |
|
|
||||||
719
Docs/DESIGN.md
719
Docs/DESIGN.md
|
|
@ -1,719 +0,0 @@
|
||||||
# Pelagia Portal — Design Document
|
|
||||||
|
|
||||||
Internal purchase-order management system for a maritime company.
|
|
||||||
This document describes every feature, page, workflow, and user story to guide UI/UX design.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
Pelagia Portal digitises the full purchase-order lifecycle — from a crew member raising a requisition aboard a vessel, through manager approval and payment by accounts, to receipt confirmation on delivery. It replaces paper and email-based processes with a traceable, role-gated workflow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. User Roles
|
|
||||||
|
|
||||||
Seven roles exist. Each role represents a real job function in the company.
|
|
||||||
|
|
||||||
| Role | Who they are | Core capability |
|
|
||||||
|------|-------------|-----------------|
|
|
||||||
| **TECHNICAL** | Ship technical crew | Create, submit, and track their own POs; confirm delivery |
|
|
||||||
| **MANNING** | Manning crew | Same as TECHNICAL |
|
|
||||||
| **ACCOUNTS** | Finance / accounts team | Process payments, manage vendor registry |
|
|
||||||
| **MANAGER** | Department manager | Review and approve POs, edit line items before approval, view analytics |
|
|
||||||
| **SUPERUSER** | Power user / ops lead | All PO actions across the board |
|
|
||||||
| **AUDITOR** | Internal auditor | Read-only view of all POs; export reports |
|
|
||||||
| **ADMIN** | System administrator | Manage users, vendors, vessels, accounts, products, and sites |
|
|
||||||
|
|
||||||
### Role Access Matrix
|
|
||||||
|
|
||||||
| Feature area | TECH / MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|
|
||||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
||||||
| Create / edit own POs | ✓ | | ✓ | ✓ | | |
|
|
||||||
| Approve / reject POs | | | ✓ | ✓ | | |
|
|
||||||
| Process payments | | ✓ | | ✓ | | |
|
|
||||||
| Confirm receipt | ✓ | | | ✓ | | |
|
|
||||||
| View all POs | | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| View analytics / export | | | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
| Vendor registry | | ✓ | ✓ | | | ✓ |
|
|
||||||
| Item catalogue | | | ✓ | | | ✓ |
|
|
||||||
| Vessel management | | | ✓ | | | ✓ |
|
|
||||||
| Site management | | | ✓ | | | ✓ |
|
|
||||||
| User management | | | | | | ✓ |
|
|
||||||
| Account management | | | ✓ | | | ✓ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Navigation Structure
|
|
||||||
|
|
||||||
The left sidebar adapts to the signed-in user's role.
|
|
||||||
|
|
||||||
```
|
|
||||||
Dashboard ← all users
|
|
||||||
|
|
||||||
─── Purchase Orders ──────────────────
|
|
||||||
New PO ← TECH, MANNING, MANAGER, SUPERUSER
|
|
||||||
My Orders ← TECH, MANNING, MANAGER, SUPERUSER
|
|
||||||
Approvals ← MANAGER, SUPERUSER
|
|
||||||
Import PO ← MANAGER, SUPERUSER, ADMIN
|
|
||||||
Payments ← ACCOUNTS
|
|
||||||
History / Export ← MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN
|
|
||||||
|
|
||||||
─── Inventory ───────────────────────
|
|
||||||
Vendors ← MANAGER, ACCOUNTS, ADMIN
|
|
||||||
Items ← MANAGER, ADMIN
|
|
||||||
Vessels ← MANAGER, ADMIN
|
|
||||||
Sites ← MANAGER, ADMIN
|
|
||||||
Cart ← TECH, MANNING, MANAGER, SUPERUSER
|
|
||||||
|
|
||||||
─── Administration ────────────────── (ADMIN only)
|
|
||||||
Users
|
|
||||||
Accounts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Authentication
|
|
||||||
|
|
||||||
### Login Page `/login`
|
|
||||||
|
|
||||||
- Email + password form
|
|
||||||
- Validates credentials against bcrypt hash
|
|
||||||
- On success: redirects to `/dashboard` (or pre-login destination)
|
|
||||||
- No self-registration; accounts are created by an ADMIN
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Page Catalogue
|
|
||||||
|
|
||||||
### 5.1 Dashboard `/dashboard`
|
|
||||||
|
|
||||||
Entry point after login. Content varies by role.
|
|
||||||
|
|
||||||
**Submitter view (TECHNICAL / MANNING / SUPERUSER)**
|
|
||||||
- Stat cards: Open orders count, Pending approval count, Completed orders
|
|
||||||
- Quick "New PO" call-to-action
|
|
||||||
- Link to full order list
|
|
||||||
|
|
||||||
**Manager view**
|
|
||||||
- Stat cards: Awaiting approval (clickable → approval queue), Approved this month, Total approved spend
|
|
||||||
- Recent approved POs table: PO number, title, vessel, amount, date
|
|
||||||
- Spend trend chart (monthly bar chart, last 6–12 months)
|
|
||||||
- Vessel spend breakdown chart (pie or bar)
|
|
||||||
|
|
||||||
**Accounts view**
|
|
||||||
- Stat cards: Ready for payment count, Total value awaiting payment
|
|
||||||
- Quick link to payment queue
|
|
||||||
|
|
||||||
**Auditor / Admin view**
|
|
||||||
- Total PO count with link to history
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.2 My Purchase Orders `/my-orders`
|
|
||||||
|
|
||||||
Personal PO list for submitters.
|
|
||||||
|
|
||||||
**Open orders table** (DRAFT, SUBMITTED, MGR_REVIEW, VENDOR_ID_PENDING, EDITS_REQUESTED)
|
|
||||||
- Columns: PO Number, Title, Vessel, Status badge, Amount, Last updated
|
|
||||||
- Manager note displayed inline if status = EDITS_REQUESTED
|
|
||||||
|
|
||||||
**Past orders table** (MGR_APPROVED through CLOSED / REJECTED)
|
|
||||||
- Same columns
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
- "New PO" button (top right)
|
|
||||||
- Click any row → PO detail page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.3 Approval Queue `/approvals`
|
|
||||||
|
|
||||||
All POs awaiting manager decision (status = MGR_REVIEW).
|
|
||||||
|
|
||||||
Filter bar:
|
|
||||||
- Search (PO number, submitter name, title)
|
|
||||||
- Vessel dropdown
|
|
||||||
- Date from picker
|
|
||||||
|
|
||||||
Table columns: PO Number, Title, Submitter, Vessel, Amount, Submitted date
|
|
||||||
|
|
||||||
Actions:
|
|
||||||
- "Review" link per row → approval detail page
|
|
||||||
- Pending count shown in heading
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.4 PO Detail `/po/[id]`
|
|
||||||
|
|
||||||
Full read view of a single PO. Accessible to: the submitter (own POs), ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN.
|
|
||||||
|
|
||||||
**Header band**
|
|
||||||
- PO number (monospace)
|
|
||||||
- Status badge (colour-coded)
|
|
||||||
- Export PDF button
|
|
||||||
|
|
||||||
**Body sections**
|
|
||||||
|
|
||||||
*Summary panel*
|
|
||||||
- Title, vessel, account, vendor (if assigned), project code, date required, currency, total amount
|
|
||||||
|
|
||||||
*Line items table*
|
|
||||||
- Columns: Item name, Description, Qty, Unit, Unit price, GST rate, Total (incl. GST)
|
|
||||||
- Read-only
|
|
||||||
|
|
||||||
*Terms & Conditions*
|
|
||||||
- Delivery, Dispatch, Inspection, Transit insurance, Payment terms, Others
|
|
||||||
|
|
||||||
*Documents*
|
|
||||||
- Uploaded files with download links
|
|
||||||
|
|
||||||
*Audit trail*
|
|
||||||
- Chronological list of every action on the PO
|
|
||||||
- Each row: actor name, action type, timestamp, optional note
|
|
||||||
|
|
||||||
*Timestamps sidebar (or footer)*
|
|
||||||
- Created, Submitted, Approved, Paid, Closed
|
|
||||||
|
|
||||||
**Contextual action buttons** (shown/hidden based on status and role)
|
|
||||||
|
|
||||||
| Condition | Button |
|
|
||||||
|-----------|--------|
|
|
||||||
| Status = DRAFT or EDITS_REQUESTED + own submitter | Edit |
|
|
||||||
| Status = DRAFT + own submitter or MANAGER/SUPERUSER | Discard (delete draft) |
|
|
||||||
| Status = VENDOR_ID_PENDING + can provide vendor | Vendor selection form inline |
|
|
||||||
| Status = PAID_DELIVERED + own submitter or SUPERUSER | Confirm Receipt |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.5 Approval Detail `/approvals/[id]`
|
|
||||||
|
|
||||||
Full PO view with approval action panel. MANAGER / SUPERUSER only.
|
|
||||||
|
|
||||||
Same content as PO detail, plus:
|
|
||||||
|
|
||||||
**Manager action panel**
|
|
||||||
- Approve button
|
|
||||||
- Approve with Note button (opens note textarea, then approves)
|
|
||||||
- Reject button (requires mandatory note)
|
|
||||||
- Request Edits button (requires mandatory note)
|
|
||||||
- Request Vendor ID button (sends back to submitter to supply vendor)
|
|
||||||
|
|
||||||
**Manager line-item edit form**
|
|
||||||
- Inline form allowing manager to adjust quantities, unit prices, GST rate, add/remove line items and change vessel, account, vendor before approving
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.6 New PO `/po/new`
|
|
||||||
|
|
||||||
Multi-section form to create a purchase order.
|
|
||||||
|
|
||||||
**Section 1 — Header**
|
|
||||||
- Title (required)
|
|
||||||
- Description / remarks
|
|
||||||
- Vessel (required, dropdown)
|
|
||||||
- Account / Cost Centre (required, dropdown)
|
|
||||||
- Vendor (optional, dropdown — can be added later)
|
|
||||||
- Date Required (date picker)
|
|
||||||
- Project Code
|
|
||||||
|
|
||||||
**Section 2 — Line Items**
|
|
||||||
- Dynamic table; rows can be added and removed
|
|
||||||
- Per-row fields: Name (searchable against item catalogue), Description, Qty, Unit, Size, Unit Price, GST Rate
|
|
||||||
- As-you-type name search shows matching products with per-vendor prices as hints
|
|
||||||
- Running totals shown below table: Taxable, GST, Grand Total
|
|
||||||
|
|
||||||
**Section 3 — Terms & Conditions**
|
|
||||||
- Delivery, Dispatch, Inspection, Transit Insurance, Payment Terms, Others (all text, optional)
|
|
||||||
|
|
||||||
**Section 4 — Documents**
|
|
||||||
- Drag-and-drop or browse file uploader
|
|
||||||
- Shows list of attached files
|
|
||||||
|
|
||||||
**Footer actions**
|
|
||||||
- Save as Draft
|
|
||||||
- Submit for Approval
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.7 Edit PO `/po/[id]/edit`
|
|
||||||
|
|
||||||
Identical form to New PO, pre-filled with existing data.
|
|
||||||
|
|
||||||
Available only when status = DRAFT or EDITS_REQUESTED, and the user is the submitter or SUPERUSER.
|
|
||||||
|
|
||||||
Footer actions:
|
|
||||||
- Save as Draft
|
|
||||||
- Update & Resubmit (only shown when status = EDITS_REQUESTED; transitions back to MGR_REVIEW)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.8 Import PO `/po/import`
|
|
||||||
|
|
||||||
Upload an Excel file in Pelagia's standard PO template format.
|
|
||||||
|
|
||||||
Steps (wizard-style or single page):
|
|
||||||
1. Drop / upload .xlsx file
|
|
||||||
2. System parses line items, vendor, quotation details
|
|
||||||
3. User selects Vessel and Account (not parsed from file)
|
|
||||||
4. Preview of extracted line items in editable table
|
|
||||||
5. Save as Draft
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.9 Confirm Receipt `/po/[id]/receipt`
|
|
||||||
|
|
||||||
Receipt confirmation form. Shown only when status = PAID_DELIVERED.
|
|
||||||
|
|
||||||
- PO number and title shown as context
|
|
||||||
- File upload for delivery receipt document
|
|
||||||
- Optional notes field
|
|
||||||
- Submit button → transitions PAID_DELIVERED → CLOSED
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.10 Payment Queue `/payments`
|
|
||||||
|
|
||||||
ACCOUNTS role only.
|
|
||||||
|
|
||||||
Card list of POs in MGR_APPROVED and SENT_FOR_PAYMENT statuses.
|
|
||||||
|
|
||||||
**Per card**
|
|
||||||
- PO number, title
|
|
||||||
- Vessel, Submitter, Vendor
|
|
||||||
- Approved date
|
|
||||||
- Amount (prominent)
|
|
||||||
- Status badge: "Ready for Payment" or "Processing — awaiting confirmation"
|
|
||||||
|
|
||||||
**Per card actions**
|
|
||||||
- MGR_APPROVED → "Send for Payment" button
|
|
||||||
- SENT_FOR_PAYMENT → "Mark as Paid" button
|
|
||||||
- View PO detail link
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.11 History & Export `/history`
|
|
||||||
|
|
||||||
All POs in all statuses. MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN.
|
|
||||||
|
|
||||||
**Filter bar**
|
|
||||||
- Date range (from / to)
|
|
||||||
- Vessel dropdown
|
|
||||||
- Status dropdown
|
|
||||||
|
|
||||||
**Table columns**: PO Number, Title, Vessel, Submitter, Status badge, Amount, Created date
|
|
||||||
|
|
||||||
**Export buttons** (apply current filters to export)
|
|
||||||
- Export PDF
|
|
||||||
- Export CSV
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.12 Vendor Registry `/admin/vendors`
|
|
||||||
|
|
||||||
Vendor list. MANAGER, ACCOUNTS, ADMIN.
|
|
||||||
|
|
||||||
**Table columns**: Vendor ID (or "Pending"), Name, Contact (name + email), Item count, Verified badge, Status badge
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Add Vendor button → modal form (GSTIN lookup, name, address, pincode auto-filled via GST portal captcha; manual contact fields)
|
|
||||||
- Edit / Delete per row
|
|
||||||
- Click vendor name → Vendor Detail page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.13 Vendor Detail `/admin/vendors/[id]`
|
|
||||||
|
|
||||||
**Header**
|
|
||||||
- Vendor name, vendor ID, verified / active badges
|
|
||||||
- Edit button
|
|
||||||
|
|
||||||
**Info card**
|
|
||||||
- GSTIN, address, pincode, contact name, mobile, email
|
|
||||||
|
|
||||||
**Items supplied table**
|
|
||||||
- Product code, name, last quoted price, last updated
|
|
||||||
- Click product name → Item Detail page
|
|
||||||
|
|
||||||
**Recent POs table**
|
|
||||||
- PO number, status, amount, created date (last 10)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.14 GSTIN Lookup (modal / inline within vendor form)
|
|
||||||
|
|
||||||
Two-step flow embedded in the Add / Edit Vendor form:
|
|
||||||
|
|
||||||
1. User types a 15-character GSTIN and clicks "Look up"
|
|
||||||
2. System loads GST portal captcha image from the microservice → displays inline
|
|
||||||
3. User types the 6-digit captcha answer
|
|
||||||
4. User clicks "Verify" → microservice submits to GST portal → returns taxpayer data
|
|
||||||
5. Form auto-fills: name, address, pincode (lat/lng geocoded silently from pincode)
|
|
||||||
|
|
||||||
Error states: wrong captcha (shows error, resets), session expired (auto-reset), GST portal unavailable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.15 Item Catalogue `/admin/products`
|
|
||||||
|
|
||||||
MANAGER, ADMIN.
|
|
||||||
|
|
||||||
**Table columns**: Name, Code, Description, Vendor count, Last price, Last vendor, Updated date, Status badge
|
|
||||||
|
|
||||||
Footer note: "Items are added automatically when a PO is marked as paid."
|
|
||||||
|
|
||||||
**Actions** (ADMIN only)
|
|
||||||
- Add Product → modal form (code, name, description)
|
|
||||||
- Toggle Active / Inactive per row
|
|
||||||
- Delete per row
|
|
||||||
- Click name → Item Detail page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.16 Item Detail `/admin/products/[id]`
|
|
||||||
|
|
||||||
**Header**
|
|
||||||
- Name, code, status badge, description
|
|
||||||
- Add to Cart button
|
|
||||||
- Toggle Active button (ADMIN only)
|
|
||||||
|
|
||||||
**Stat cards**
|
|
||||||
- Vendor count, Lowest price, Highest price, Sites with stock
|
|
||||||
|
|
||||||
**Price comparison bar chart**
|
|
||||||
- One bar per vendor, Y-axis = unit price
|
|
||||||
|
|
||||||
**Site distance filter**
|
|
||||||
- Dropdown: "Sort by distance from site" — re-sorts vendor table by proximity
|
|
||||||
- Uses geocoded pincode of vendor vs site lat/lng for distance
|
|
||||||
|
|
||||||
**Vendor pricing table**
|
|
||||||
- Columns: Vendor (link to vendor detail), Verified badge, Unit price, Distance (if site selected), Last updated, Add to Cart
|
|
||||||
- Closest vendor gets a ★ marker when a site is selected
|
|
||||||
|
|
||||||
**Stock by site**
|
|
||||||
- Chip list: site name + quantity on hand (link to site detail)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.17 Vessel Management `/admin/vessels`
|
|
||||||
|
|
||||||
MANAGER, ADMIN.
|
|
||||||
|
|
||||||
**Table columns**: Name, Status badge
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Add / Edit / Delete per row (all modal)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.18 Account / Cost Centre Management `/admin/accounts`
|
|
||||||
|
|
||||||
MANAGER, ADMIN.
|
|
||||||
|
|
||||||
**Table columns**: Code, Name, Description, Status badge
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Add / Edit / Delete per row (all modal)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.19 Sites `/admin/sites`
|
|
||||||
|
|
||||||
MANAGER, ADMIN (ADMIN-only for add/edit/delete).
|
|
||||||
|
|
||||||
Ports, depots, and offices that hold inventory.
|
|
||||||
|
|
||||||
**Table columns**: Name, Code, Address, Vessels, Items tracked, Location (lat/lon from pincode), Status badge
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Add Site → modal form (name, code, address, pincode for auto-geocoding)
|
|
||||||
- Edit / Delete per row
|
|
||||||
- Click name → Site Detail page
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.20 Site Detail `/admin/sites/[id]`
|
|
||||||
|
|
||||||
**Header**
|
|
||||||
- Name, code, address, geocoded location
|
|
||||||
- Edit button (ADMIN only)
|
|
||||||
|
|
||||||
**Stat cards**
|
|
||||||
- Vessels at site, Items tracked, Total inventory value (if calculable)
|
|
||||||
|
|
||||||
**Inventory bar chart**
|
|
||||||
- X-axis = product name, Y-axis = quantity on hand
|
|
||||||
|
|
||||||
**Consumption line chart**
|
|
||||||
- Last 30 days of daily consumption, one line per product
|
|
||||||
|
|
||||||
**Inventory table**
|
|
||||||
- Product name, quantity on hand, last updated; link to item detail
|
|
||||||
|
|
||||||
**Log consumption form**
|
|
||||||
- Fields: Product (dropdown), Date (date picker), Quantity, Note
|
|
||||||
- Submits immediately; chart and table refresh
|
|
||||||
|
|
||||||
**Assigned vessels**
|
|
||||||
- Chip list linking to vessel detail
|
|
||||||
|
|
||||||
**Recent POs for this site**
|
|
||||||
- Last 8 POs with status, vendor, amount
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.21 User Management `/admin/users`
|
|
||||||
|
|
||||||
ADMIN only.
|
|
||||||
|
|
||||||
**Table columns**: Employee ID, Name, Email, Role badge, Status badge, Created date
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Add User → modal form (employee ID, name, email, role, initial password)
|
|
||||||
- Edit → modal form (same fields, password optional)
|
|
||||||
- Delete per row
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5.22 Cart `/inventory/cart`
|
|
||||||
|
|
||||||
Persistent cart collecting items selected from product detail pages. Stored in localStorage.
|
|
||||||
|
|
||||||
**Cart view**
|
|
||||||
- Item list: product name, description, vendor (if selected), unit price, quantity (editable inline)
|
|
||||||
- Summary: subtotal, GST, grand total
|
|
||||||
- Site selector (to indicate delivery site)
|
|
||||||
|
|
||||||
**Actions**
|
|
||||||
- Remove item
|
|
||||||
- Clear cart
|
|
||||||
- Create PO → opens New PO form pre-filled with cart line items and selected site/vendor
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. PO Lifecycle State Machine
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────┐
|
|
||||||
▼ │
|
|
||||||
[DRAFT] ──submit──► [SUBMITTED] ──auto──► [MGR_REVIEW]
|
|
||||||
│ │ │ │
|
|
||||||
approve ◄───────┘ │ │ └──── reject ──► [REJECTED]
|
|
||||||
│ │ │
|
|
||||||
│ request_edits─┘ └── request_vendor_id ──► [VENDOR_ID_PENDING]
|
|
||||||
│ │
|
|
||||||
│ ◄──── provide_vendor_id ──────────────────────┘
|
|
||||||
│
|
|
||||||
[MGR_APPROVED]
|
|
||||||
│
|
|
||||||
process_payment
|
|
||||||
│
|
|
||||||
[SENT_FOR_PAYMENT]
|
|
||||||
│
|
|
||||||
mark_paid
|
|
||||||
│
|
|
||||||
[PAID_DELIVERED]
|
|
||||||
│
|
|
||||||
confirm_receipt
|
|
||||||
│
|
|
||||||
[CLOSED]
|
|
||||||
```
|
|
||||||
|
|
||||||
States that allow re-entry into the flow:
|
|
||||||
- **EDITS_REQUESTED** → submitter edits PO → re-submits → MGR_REVIEW
|
|
||||||
- **VENDOR_ID_PENDING** → submitter selects vendor → MGR_REVIEW
|
|
||||||
|
|
||||||
Terminal states: **REJECTED**, **CLOSED**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Workflows
|
|
||||||
|
|
||||||
### 7.1 Submit a Purchase Order (TECHNICAL / MANNING)
|
|
||||||
|
|
||||||
1. Click **New PO** in sidebar
|
|
||||||
2. Select vessel and account
|
|
||||||
3. Add line items (type name to search item catalogue; previous vendor prices appear as hints)
|
|
||||||
4. Optionally attach documents and fill in T&C fields
|
|
||||||
5. Click **Submit for Approval**
|
|
||||||
6. Manager receives email notification
|
|
||||||
7. Status shows as "Under Review" on My Orders page
|
|
||||||
8. If manager requests edits: submitter sees EDITS_REQUESTED status with manager note; edits form; resubmits
|
|
||||||
9. If manager requests vendor ID: submitter selects a vendor and submits; returns to manager queue
|
|
||||||
10. On approval: submitter notified by email; accounts team can see PO in payment queue
|
|
||||||
|
|
||||||
### 7.2 Approve a Purchase Order (MANAGER)
|
|
||||||
|
|
||||||
1. Click **Approvals** in sidebar; see count of pending POs
|
|
||||||
2. Click **Review** on a PO
|
|
||||||
3. Read full detail: line items, vendor, documents, submitter notes
|
|
||||||
4. Optionally: click **Edit** to adjust line items, change vendor, vessel, or account
|
|
||||||
5. Choose action:
|
|
||||||
- **Approve** → immediately moves to accounts payment queue
|
|
||||||
- **Approve with Note** → same, with a note visible to submitter
|
|
||||||
- **Request Edits** → write note explaining required changes; PO returned to submitter
|
|
||||||
- **Request Vendor ID** → PO returned to submitter to select vendor; then returns to manager queue
|
|
||||||
- **Reject** → write reason; PO is closed permanently
|
|
||||||
|
|
||||||
### 7.3 Process a Payment (ACCOUNTS)
|
|
||||||
|
|
||||||
1. Click **Payments** in sidebar
|
|
||||||
2. See cards for all MGR_APPROVED POs
|
|
||||||
3. Click **Send for Payment** → initiates payment; notifies submitter and manager
|
|
||||||
4. When payment is confirmed by bank/finance: click **Mark as Paid** → notifies all parties
|
|
||||||
5. Submitter can now upload delivery receipt
|
|
||||||
|
|
||||||
### 7.4 Confirm Receipt (TECHNICAL / MANNING)
|
|
||||||
|
|
||||||
1. Goods are delivered on site / to vessel
|
|
||||||
2. Navigate to PO detail page (status = PAID_DELIVERED)
|
|
||||||
3. Click **Confirm Receipt**
|
|
||||||
4. Upload delivery receipt document and optionally add notes
|
|
||||||
5. Submit → PO is CLOSED; accounts and manager notified
|
|
||||||
|
|
||||||
### 7.5 Look Up a Vendor by GSTIN (MANAGER / ADMIN)
|
|
||||||
|
|
||||||
1. Open Add/Edit Vendor modal
|
|
||||||
2. Type the 15-digit GSTIN
|
|
||||||
3. Click **Look up** → captcha image loads from GST portal (via microservice)
|
|
||||||
4. Type the 6-digit captcha shown in the image
|
|
||||||
5. Click **Verify** → form auto-fills with legal name, trade name, registered address, pincode
|
|
||||||
6. Review and save; location is geocoded silently from pincode for distance calculations
|
|
||||||
|
|
||||||
### 7.6 Source Items by Proximity (MANAGER)
|
|
||||||
|
|
||||||
1. Navigate to **Items** → click an item name
|
|
||||||
2. See all vendors that supply the item with their last quoted price
|
|
||||||
3. Select a **site** from the "Sort by distance from" dropdown
|
|
||||||
4. Table re-sorts: vendors nearest to the site appear first; distance shown per row; closest vendor marked ★
|
|
||||||
5. Click **Add to Cart** on the desired vendor row → item added to cart
|
|
||||||
|
|
||||||
### 7.7 Create a PO from the Cart (MANAGER / TECHNICAL)
|
|
||||||
|
|
||||||
1. Browse Item catalogue and add items to cart (Add to Cart button per vendor row)
|
|
||||||
2. Click **Cart** in sidebar
|
|
||||||
3. Review cart: adjust quantities inline; remove items; select delivery site
|
|
||||||
4. Click **Create PO** → opens New PO form pre-filled with all cart items and vendor
|
|
||||||
5. Fill in title, vessel, account; submit normally
|
|
||||||
|
|
||||||
### 7.8 Track Inventory at a Site (MANAGER / ADMIN)
|
|
||||||
|
|
||||||
1. Navigate to **Sites** → click a site
|
|
||||||
2. View bar chart of current stock (quantity per product)
|
|
||||||
3. View consumption line chart (last 30 days)
|
|
||||||
4. Use **Log Consumption** form to record daily drawdown: select product, pick date, enter quantity
|
|
||||||
|
|
||||||
### 7.9 Auto-sync Catalogue on Payment Confirmation (ACCOUNTS → SYSTEM)
|
|
||||||
|
|
||||||
When accounts clicks **Mark as Paid**:
|
|
||||||
- System checks each PO line item that has a product link
|
|
||||||
- For unlinked items: attempts fuzzy-match on name; creates new product record if no match
|
|
||||||
- Upserts `ProductVendorPrice` — if this vendor/product combination is new or the price changed, updates the catalogue
|
|
||||||
- Sets `Product.lastPrice` and `Product.lastVendorId`
|
|
||||||
- Future POs using that product name will see this vendor's latest price as a hint
|
|
||||||
|
|
||||||
### 7.10 Import a PO from Excel (MANAGER)
|
|
||||||
|
|
||||||
1. Navigate to **Import PO**
|
|
||||||
2. Upload an Excel file in Pelagia's standard template format
|
|
||||||
3. System extracts: line items (name, description, qty, unit, price, GST), vendor details, quotation number/date
|
|
||||||
4. User selects vessel and account from dropdowns
|
|
||||||
5. Review and optionally edit extracted line items
|
|
||||||
6. Save as Draft → PO created; submitter can then edit and submit
|
|
||||||
|
|
||||||
### 7.11 Export PO History (AUDITOR / MANAGER)
|
|
||||||
|
|
||||||
1. Navigate to **History**
|
|
||||||
2. Apply filters: date range, vessel, status
|
|
||||||
3. Click **Export PDF** or **Export CSV**
|
|
||||||
4. File downloaded with all matching POs; up to 200 results per export
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Data Entities
|
|
||||||
|
|
||||||
### Purchase Order
|
|
||||||
Fields: PO number (auto-generated), title, status, total amount, currency, date required, project code, manager note, payment reference, quotation number/date, requisition number/date, place of delivery, all T&C text fields, timestamps.
|
|
||||||
|
|
||||||
### PO Line Item
|
|
||||||
Fields: name, description, quantity, unit, size, unit price, GST rate (default 18%), total price (computed), sort order, optional product link.
|
|
||||||
|
|
||||||
### Vendor
|
|
||||||
Fields: name, vendor ID (optional, unique), address, pincode, GSTIN, contact name/mobile/email, latitude/longitude (geocoded silently from pincode), verified flag, active flag.
|
|
||||||
|
|
||||||
### Product (Item)
|
|
||||||
Fields: code (auto-generated or manual), name, description, last price, last vendor, active flag. Prices tracked per vendor via `ProductVendorPrice` (one record per product–vendor pair).
|
|
||||||
|
|
||||||
### Vessel
|
|
||||||
Fields: name, active flag, assigned site (optional).
|
|
||||||
|
|
||||||
### Site
|
|
||||||
Fields: name, code, address, pincode, latitude/longitude, active flag.
|
|
||||||
|
|
||||||
### Account (Cost Centre)
|
|
||||||
Fields: code, name, description, active flag.
|
|
||||||
|
|
||||||
### User
|
|
||||||
Fields: employee ID, email, name, role, active flag, password hash.
|
|
||||||
|
|
||||||
### Inventory & Consumption
|
|
||||||
- `ItemInventory`: quantity of a product at a site (one row per product–site pair)
|
|
||||||
- `ItemConsumption`: daily draw-down record (one row per product–site–date)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Key UI Patterns
|
|
||||||
|
|
||||||
### Status Badges
|
|
||||||
Each PO status has a distinct colour:
|
|
||||||
- DRAFT — neutral grey
|
|
||||||
- SUBMITTED / MGR_REVIEW — blue (in-progress)
|
|
||||||
- VENDOR_ID_PENDING — orange/warning
|
|
||||||
- EDITS_REQUESTED — yellow/warning
|
|
||||||
- MGR_APPROVED — teal/success-adjacent
|
|
||||||
- SENT_FOR_PAYMENT — purple
|
|
||||||
- PAID_DELIVERED — blue-green
|
|
||||||
- CLOSED — green/success
|
|
||||||
- REJECTED — red/danger
|
|
||||||
|
|
||||||
### Confirmation before Destructive Actions
|
|
||||||
Delete buttons use a two-step inline confirm: "Delete [name]? Confirm / Cancel". No modal dialog — the confirm state replaces the button in-place.
|
|
||||||
|
|
||||||
### Inline Editing in Tables
|
|
||||||
Manager line-item editing in the approval flow happens in an inline form on the same page, not in a modal, so the manager can reference the rest of the PO while editing.
|
|
||||||
|
|
||||||
### GST Calculation (always visible in PO forms)
|
|
||||||
Below the line-items table, a live summary shows:
|
|
||||||
- Taxable amount (sum of qty × unit price)
|
|
||||||
- GST amount (sum of qty × unit price × GST rate)
|
|
||||||
- Grand Total (taxable + GST)
|
|
||||||
|
|
||||||
### Product Autocomplete
|
|
||||||
In the PO line-item name field, typing triggers a fuzzy search of the item catalogue. Dropdown shows:
|
|
||||||
- Product name and code
|
|
||||||
- Price hints per vendor: "Vendor A: ₹1,200 · Vendor B: ₹1,050"
|
|
||||||
|
|
||||||
### Cart Persistence
|
|
||||||
Cart is stored in browser `localStorage` under a fixed key. It survives navigation but is local to the device and user. A `cart-updated` custom event allows components to react to changes in real time.
|
|
||||||
|
|
||||||
### Notifications / Emails
|
|
||||||
Every PO status transition triggers an email to relevant parties:
|
|
||||||
- Submit → manager
|
|
||||||
- Approve → submitter + accounts
|
|
||||||
- Reject → submitter
|
|
||||||
- Request Edits → submitter
|
|
||||||
- Request Vendor ID → submitter
|
|
||||||
- Payment sent → submitter + manager
|
|
||||||
- Mark paid → submitter + manager
|
|
||||||
- Receipt confirmed → manager + accounts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Non-Goals (Out of Scope)
|
|
||||||
|
|
||||||
- Mobile app (web-only, desktop-first)
|
|
||||||
- Public-facing pages (entirely internal)
|
|
||||||
- Self-registration / OAuth login
|
|
||||||
- Vendor portal (vendors do not log in)
|
|
||||||
- Automated bank/payment-gateway integration (payment is marked manually)
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
# Playwright Test Design — Pelagia Portal
|
|
||||||
|
|
||||||
This document describes how to save, structure, and extend the Playwright verification
|
|
||||||
scripts written during development sessions. Every script here was used to confirm a
|
|
||||||
bug fix before committing; they should be promoted to a permanent test suite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Playwright is currently installed in `GstService/` (a sibling service). For the Portal's
|
|
||||||
own test suite, install it once:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd App/pelagia-portal
|
|
||||||
pnpm add -D playwright @playwright/test
|
|
||||||
npx playwright install chromium
|
|
||||||
```
|
|
||||||
|
|
||||||
Then place tests in `App/pelagia-portal/tests/e2e/` and run with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx playwright test # headless
|
|
||||||
npx playwright test --headed # headed (watch the browser)
|
|
||||||
npx playwright test --ui # interactive Playwright UI
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Principles
|
|
||||||
|
|
||||||
### 1. Log every step with a symbol prefix
|
|
||||||
Use `✓` for passing assertions, `✗` for failures, and plain text for context.
|
|
||||||
This makes CI output scannable without opening a full trace.
|
|
||||||
|
|
||||||
```js
|
|
||||||
console.log('✓ Logged in');
|
|
||||||
console.log('✓ Expanded item with', vendorCount, 'vendors');
|
|
||||||
console.log('✗ Could not find item with multiple vendors');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Wait for URLs, not just network idle
|
|
||||||
Client-side `router.push` navigations finish asynchronously. Always pair a
|
|
||||||
`selectOption` / `click` that triggers navigation with `page.waitForURL(...)`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const nav = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 });
|
|
||||||
await page.locator('select').first().selectOption({ index: 1 });
|
|
||||||
await nav;
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Account for preserved React state across soft navigation
|
|
||||||
Next.js App Router soft-navigates between pages that share a layout. Client component
|
|
||||||
state (`useState`) is **preserved** — a row that was expanded before `router.push` stays
|
|
||||||
expanded after. Tests must model this or they will double-click a row and accidentally
|
|
||||||
close it.
|
|
||||||
|
|
||||||
```js
|
|
||||||
// Expand BEFORE selecting a site — row stays open through navigation
|
|
||||||
await page.locator('tbody tr').first().click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
// select site → navigate → row is still expanded, no second click needed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Find items with enough data to test
|
|
||||||
Not every item has multiple vendors or a known distance. Loop over rows until one
|
|
||||||
with sufficient vendors is found rather than assuming the first row is suitable:
|
|
||||||
|
|
||||||
```js
|
|
||||||
for (let i = 0; i < Math.min(rowCount, 10); i++) {
|
|
||||||
await rows.nth(i).click();
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
const vendorCount = await page.locator('table table tbody tr').count();
|
|
||||||
if (vendorCount > 1) { expanded = true; break; }
|
|
||||||
await rows.nth(i).click(); // close and try next
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Exit with a non-zero code on failure
|
|
||||||
Scripts run in CI; call `process.exit(1)` so a failed check surfaces as a build error.
|
|
||||||
|
|
||||||
```js
|
|
||||||
if (!allGood) process.exit(1);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Test Scripts
|
|
||||||
|
|
||||||
### AUTH — helpers used by every test
|
|
||||||
|
|
||||||
```js
|
|
||||||
// tests/e2e/helpers/auth.js
|
|
||||||
async function login(page, email = 'tech@pelagia.local', password = 'tech1234') {
|
|
||||||
await page.goto('http://localhost:3000/login');
|
|
||||||
await page.fill('#email', email);
|
|
||||||
await page.fill('#password', password);
|
|
||||||
await page.click('button[type=submit]');
|
|
||||||
await page.waitForURL('**/dashboard', { timeout: 8000 });
|
|
||||||
console.log(`✓ Logged in as ${email}`);
|
|
||||||
}
|
|
||||||
module.exports = { login };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TEST 1 — Auto-sort by distance when site is selected or changed
|
|
||||||
|
|
||||||
**Bug:** Sorting did not automatically switch to "Distance" when a site was selected
|
|
||||||
from the site dropdown on the Items page. `useState` only evaluates its initial value
|
|
||||||
once on mount. Next.js soft navigation preserves component state, so changing the
|
|
||||||
`?siteId=` URL param never re-ran the initialiser. A `useEffect` keyed on
|
|
||||||
`currentSiteId` was added to reset `sortBy` whenever the selected site changes.
|
|
||||||
|
|
||||||
**File:** `tests/e2e/inventory/items-sort-by-site.js`
|
|
||||||
|
|
||||||
```js
|
|
||||||
const { chromium } = require('playwright');
|
|
||||||
const { login } = require('../helpers/auth');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage();
|
|
||||||
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
// ── 1. No site selected → Price should be the active sort ──────────────
|
|
||||||
await page.goto('http://localhost:3000/inventory/items');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Expand first row to reveal the sort toggle
|
|
||||||
await page.locator('tbody tr').first().click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
const priceActiveNoSite = await page
|
|
||||||
.locator('button:has-text("Price")')
|
|
||||||
.evaluate(el => el.classList.contains('bg-primary-100'));
|
|
||||||
console.log('1. No site → Price active:', priceActiveNoSite);
|
|
||||||
if (!priceActiveNoSite) { console.error('✗ Expected Price to be active'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
// ── 2. Select a site → Distance should become active automatically ──────
|
|
||||||
// Row stays expanded through soft navigation — do NOT click again
|
|
||||||
const nav1 = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 });
|
|
||||||
await page.locator('select').first().selectOption({ index: 1 });
|
|
||||||
await nav1;
|
|
||||||
await page.waitForTimeout(400); // allow useEffect to run
|
|
||||||
|
|
||||||
console.log('2. Navigated to:', new URL(page.url()).search);
|
|
||||||
|
|
||||||
const distanceActiveSite = await page
|
|
||||||
.locator('button:has-text("Distance")')
|
|
||||||
.evaluate(el => el.classList.contains('bg-primary-100'));
|
|
||||||
console.log(' Distance auto-active:', distanceActiveSite);
|
|
||||||
if (!distanceActiveSite) { console.error('✗ Expected Distance to be auto-active'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
// ── 3. Manual switch to Price still works ───────────────────────────────
|
|
||||||
await page.locator('button:has-text("Price")').click();
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
const priceManual = await page
|
|
||||||
.locator('button:has-text("Price")')
|
|
||||||
.evaluate(el => el.classList.contains('bg-primary-100'));
|
|
||||||
console.log('3. Manual switch → Price active:', priceManual);
|
|
||||||
if (!priceManual) { console.error('✗ Manual switch to Price did not work'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
// ── 4. Change to a different site → Distance resets automatically ───────
|
|
||||||
const options = await page.locator('select option').all();
|
|
||||||
if (options.length > 2) {
|
|
||||||
const nav2 = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 });
|
|
||||||
await page.locator('select').first().selectOption({ index: 2 });
|
|
||||||
await nav2;
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
|
|
||||||
const distanceReset = await page
|
|
||||||
.locator('button:has-text("Distance")')
|
|
||||||
.evaluate(el => el.classList.contains('bg-primary-100'));
|
|
||||||
console.log('4. Different site → Distance reset:', distanceReset);
|
|
||||||
if (!distanceReset) { console.error('✗ Expected Distance to reset on site change'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
} else {
|
|
||||||
console.log('4. Skipped — only one site available in seed data');
|
|
||||||
}
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log('\n✓ All checks passed — items-sort-by-site');
|
|
||||||
})().catch(e => { console.error('✗', e.message); process.exit(1); });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### TEST 2 — Cheapest and Closest tags appear independent of sort order
|
|
||||||
|
|
||||||
**Bug:** The `★ Closest` tag was only rendered when `sortBy === "distance"` and the
|
|
||||||
`Cheapest` tag only when `sortBy === "price"`. Switching sort order hid one of the
|
|
||||||
tags entirely. The fix computes each tag independently — `minPrice` for cheapest,
|
|
||||||
`closestVendorId` for nearest by `distanceKm` — so both can appear simultaneously
|
|
||||||
on whichever vendor qualifies, regardless of the active sort.
|
|
||||||
|
|
||||||
**File:** `tests/e2e/inventory/items-vendor-tags.js`
|
|
||||||
|
|
||||||
```js
|
|
||||||
const { chromium } = require('playwright');
|
|
||||||
const { login } = require('../helpers/auth');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage();
|
|
||||||
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
// ── Setup: navigate to items with a site selected ───────────────────────
|
|
||||||
await page.goto('http://localhost:3000/inventory/items');
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
const nav = page.waitForURL('**/inventory/items?siteId=**', { timeout: 8000 });
|
|
||||||
await page.locator('select').first().selectOption({ index: 1 });
|
|
||||||
await nav;
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
console.log('✓ Site selected:', new URL(page.url()).searchParams.get('siteId'));
|
|
||||||
|
|
||||||
// ── Find an item with multiple vendors ──────────────────────────────────
|
|
||||||
const rows = page.locator('tbody tr');
|
|
||||||
const rowCount = await rows.count();
|
|
||||||
let expanded = false;
|
|
||||||
let vendorCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.min(rowCount, 10); i++) {
|
|
||||||
await rows.nth(i).click();
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
vendorCount = await page.locator('table table tbody tr').count();
|
|
||||||
if (vendorCount > 1) {
|
|
||||||
expanded = true;
|
|
||||||
console.log(`✓ Expanded item ${i + 1} with ${vendorCount} vendors`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await rows.nth(i).click(); // close and try next
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expanded) {
|
|
||||||
console.error('✗ Could not find item with multiple vendors — check seed data');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1. Distance sort (default): both tags must be visible ───────────────
|
|
||||||
const distanceActiveBefore = await page
|
|
||||||
.locator('button:has-text("Distance")')
|
|
||||||
.evaluate(el => el.classList.contains('bg-primary-100'));
|
|
||||||
console.log(' Active sort:', distanceActiveBefore ? 'Distance' : 'Price');
|
|
||||||
|
|
||||||
const closestDistSort = await page.locator('text=★ Closest').count();
|
|
||||||
const cheapestDistSort = await page.locator('text=Cheapest').count();
|
|
||||||
console.log(`1. Distance sort → ★ Closest: ${closestDistSort} Cheapest: ${cheapestDistSort}`);
|
|
||||||
|
|
||||||
if (closestDistSort < 1) { console.error('✗ ★ Closest tag missing under Distance sort'); process.exit(1); }
|
|
||||||
if (cheapestDistSort < 1) { console.error('✗ Cheapest tag missing under Distance sort'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
// ── 2. Price sort: both tags must still be visible ──────────────────────
|
|
||||||
await page.locator('button:has-text("Price")').click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
const closestPriceSort = await page.locator('text=★ Closest').count();
|
|
||||||
const cheapestPriceSort = await page.locator('text=Cheapest').count();
|
|
||||||
console.log(`2. Price sort → ★ Closest: ${closestPriceSort} Cheapest: ${cheapestPriceSort}`);
|
|
||||||
|
|
||||||
if (closestPriceSort < 1) { console.error('✗ ★ Closest tag missing under Price sort'); process.exit(1); }
|
|
||||||
if (cheapestPriceSort < 1) { console.error('✗ Cheapest tag missing under Price sort'); process.exit(1); }
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
// ── 3. No site: neither tag should appear ───────────────────────────────
|
|
||||||
const navBack = page.waitForURL(/\/inventory\/items$/, { timeout: 8000 });
|
|
||||||
await page.locator('select').first().selectOption({ value: '' });
|
|
||||||
await navBack;
|
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Expand the same row
|
|
||||||
await page.locator('tbody tr').first().click();
|
|
||||||
await page.waitForTimeout(400);
|
|
||||||
|
|
||||||
const closestNoSite = await page.locator('text=★ Closest').count();
|
|
||||||
const cheapestNoSite = await page.locator('text=Cheapest').count();
|
|
||||||
console.log(`3. No site → ★ Closest: ${closestNoSite} Cheapest: ${cheapestNoSite}`);
|
|
||||||
|
|
||||||
if (closestNoSite > 0) { console.error('✗ ★ Closest should not appear without a site'); process.exit(1); }
|
|
||||||
if (cheapestNoSite > 0) { console.error('✗ Cheapest should not appear when only one vendor visible without site sort'); }
|
|
||||||
// Cheapest may legitimately appear if item still has multiple vendor prices — not a hard failure
|
|
||||||
console.log('✓ Pass');
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log('\n✓ All checks passed — items-vendor-tags');
|
|
||||||
})().catch(e => { console.error('✗', e.message); process.exit(1); });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running all e2e scripts manually
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# From GstService directory (current Playwright install location)
|
|
||||||
node ../App/pelagia-portal/tests/e2e/inventory/items-sort-by-site.js
|
|
||||||
node ../App/pelagia-portal/tests/e2e/inventory/items-vendor-tags.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Once Playwright is installed in the portal itself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd App/pelagia-portal
|
|
||||||
npx playwright test tests/e2e/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Adding new tests
|
|
||||||
|
|
||||||
When a bug is fixed and browser-verified during a dev session, follow this checklist:
|
|
||||||
|
|
||||||
1. **Name the file after the feature area** — `tests/e2e/<section>/<feature>.js`
|
|
||||||
2. **Open with a comment block** describing the bug, the fix, and what the script checks
|
|
||||||
3. **Log every decision point** with `✓`/`✗` prefix and plain-English labels
|
|
||||||
4. **Use `waitForURL`** (not `waitForLoadState`) for router.push-triggered navigations
|
|
||||||
5. **Account for preserved state** — React state survives soft nav; model that explicitly
|
|
||||||
6. **Exit non-zero** on any assertion failure so CI catches it
|
|
||||||
7. **Add an entry to this document** under `## Test Scripts` with the bug description
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known gotchas
|
|
||||||
|
|
||||||
| Situation | Symptom | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| Clicking a row that is already expanded | Row closes, sort toggle disappears, selectors time out | Expand row *before* triggering soft navigation so state is preserved |
|
|
||||||
| `waitForLoadState('networkidle')` after `router.push` | URL still shows old path | Use `page.waitForURL(pattern)` concurrently with the action |
|
|
||||||
| `button:has-text("Distance")` times out | Sort toggle only renders when `expandedId` is truthy | Ensure a row is expanded before asserting on the sort toggle |
|
|
||||||
| Tags not found after switching sites | `sortBy` state did not reset (stale closure) | `useEffect` on `currentSiteId` resets it — confirm the effect dependency is correct |
|
|
||||||
28
Docs/README.md
Normal file
28
Docs/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Docs — retired (moved to the wiki)
|
||||||
|
|
||||||
|
The design, architecture, and test documents that used to live here have been
|
||||||
|
**migrated to the project wiki** and removed from the repo. The wiki is the
|
||||||
|
living reference going forward.
|
||||||
|
|
||||||
|
**Wiki:** <https://git.pelagiamarine.com/shad0w/pelagia-portal/wiki>
|
||||||
|
(working clone: `pelagia-portal.wiki/` alongside this repo).
|
||||||
|
|
||||||
|
## Where each retired doc went
|
||||||
|
|
||||||
|
| Retired file | Now in the wiki |
|
||||||
|
|---|---|
|
||||||
|
| `01-design-document.md` | `System/Architecture`, `Product/Workflows` (user stories), `Product/Design-System`, `Overview/Open-Questions` |
|
||||||
|
| `02-architecture.md` | `System/Architecture` (+ `System/Data-Model`, `Ops/Deployment-and-Operations`, `Build-and-Run/Environment-Variables`) |
|
||||||
|
| `03-open-questions.md` | `Overview/Open-Questions` |
|
||||||
|
| `DESIGN.md` | `Product/Workflows`, `Product/Pages-and-Navigation`, `Product/Design-System` |
|
||||||
|
| `TEST_PLAN.md` | `Quality/Test-Plan` |
|
||||||
|
| `e2e-test-framework.md` | `Quality/E2E-Test-Framework` |
|
||||||
|
| `e2e-test-plan.md` | `Quality/E2E-Test-Plan` |
|
||||||
|
| `PLAYRIGHT_TEST_DESIGN.md` | `Quality/Playwright-Test-Design` |
|
||||||
|
|
||||||
|
The wiki's on-disk folder layout (Overview / Build-and-Run / System / Product /
|
||||||
|
Quality / Ops) mirrors its sidebar hierarchy.
|
||||||
|
|
||||||
|
> Keep current behaviour documented in the wiki, not here. Other authoritative
|
||||||
|
> in-repo sources remain: `App/CLAUDE.md`, `App/README.md`,
|
||||||
|
> `automation/README.md`, and `CHANGELOG.md`.
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
# Pelagia Portal — Test Plan
|
|
||||||
|
|
||||||
**Version:** 1.0
|
|
||||||
**Date:** 2026-05-09
|
|
||||||
**Project:** Pelagia Marine Services PO Portal
|
|
||||||
**Scope:** Unit, Integration, and E2E test coverage across all portal features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
This document describes the testing strategy, scope, tooling, and coverage matrix for the Pelagia Portal. It is intended as the authoritative reference for what is tested, why, and how to run each layer.
|
|
||||||
|
|
||||||
The portal manages the full lifecycle of purchase orders: creation, submission, manager review, vendor assignment, payment, and receipt confirmation. Testing focuses on correctness of state transitions, permission enforcement, and data integrity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Testing Stack
|
|
||||||
|
|
||||||
| Layer | Tool | Environment | Command |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Unit | Vitest 2.x | jsdom | `pnpm test` |
|
|
||||||
| Integration | Vitest 2.x | Node (real DB) | `pnpm test:integration` |
|
|
||||||
| E2E | Playwright 1.49 | Chromium (dev server) | `pnpm test:e2e` |
|
|
||||||
|
|
||||||
**Key libraries:** `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`.
|
|
||||||
|
|
||||||
Unit tests live in `tests/unit/`. Integration tests live in `tests/integration/`. E2E specs live in `tests/e2e/`.
|
|
||||||
|
|
||||||
Integration tests run serially in a single fork (`poolOptions.forks.singleFork = true`) to avoid database conflicts. Each test suite cleans up its own data via `afterEach` using the `deletePosByTitle(PREFIX)` helper.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Test Data & Environment
|
|
||||||
|
|
||||||
### 3.1 Seeded Data (prisma/seed.ts)
|
|
||||||
|
|
||||||
| Entity | Records | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Users | 5 | admin, manager, tech, accounts, manning |
|
|
||||||
| Vessels | 3 | MV Pelagia Star, MV Aegean Wind, MV Poseidon |
|
|
||||||
| Accounts | 3 | TECH-OPS, CREW-MGT, FUEL-BNK |
|
|
||||||
| Vendors | 12 | VND-0001 to VND-0012; VND-0003 and VND-0012 are unverified |
|
|
||||||
| Products | 25 | Spanning lubricants, filters, safety, rope, electrical, paint, navigation |
|
|
||||||
|
|
||||||
Re-run with `npx tsx prisma/seed.ts` before integration tests if the database is reset.
|
|
||||||
|
|
||||||
### 3.2 Authentication Mocking
|
|
||||||
|
|
||||||
Integration tests mock `@/auth` to inject a session without real credentials:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
||||||
vi.mocked(auth).mockResolvedValue(makeSession(userId, "MANAGER"));
|
|
||||||
```
|
|
||||||
|
|
||||||
`makeSession(userId, role)` is defined in `tests/integration/helpers.ts`.
|
|
||||||
|
|
||||||
### 3.3 Side-Effect Mocking
|
|
||||||
|
|
||||||
All integration and unit tests mock:
|
|
||||||
- `@/lib/notifier` — prevents email dispatch
|
|
||||||
- `next/cache` (`revalidatePath`) — avoids Next.js cache calls outside a server context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Coverage Matrix
|
|
||||||
|
|
||||||
### 4.1 Unit Tests
|
|
||||||
|
|
||||||
| File | Test File | Cases Covered |
|
|
||||||
|---|---|---|
|
|
||||||
| `lib/permissions.ts` | `tests/unit/permissions.test.ts` | All 7 roles × key permissions; `requirePermission` throws |
|
|
||||||
| `lib/po-state-machine.ts` | `tests/unit/po-state-machine.test.ts` | `canPerformAction`, `getTransition`, `requiresNote`, `getAvailableActions`; MANAGER/ACCOUNTS expansions |
|
|
||||||
| `lib/po-import-parser.ts` | `tests/unit/po-import-parser.test.ts` | `cellStr`, `cellNum`, `parseSheet` (real + synthetic), `parseWorkbook` |
|
|
||||||
| `lib/validations/po.ts` | `tests/unit/validations.test.ts` | `lineItemSchema`, `createPoSchema`, TC defaults |
|
|
||||||
| `components/po/po-line-items-editor.tsx` | `tests/unit/po-line-items-editor.test.tsx` | Edit mode, read-only mode, totals, add/remove |
|
|
||||||
| `components/po/po-status-badge.tsx` | `tests/unit/po-status-badge.test.tsx` | All status labels |
|
|
||||||
| `lib/utils.ts` | `tests/unit/utils.test.ts` | `formatCurrency`, `formatDate`, `generatePoNumber`, status maps |
|
|
||||||
|
|
||||||
### 4.2 Integration Tests
|
|
||||||
|
|
||||||
| Test File | Feature | Scenarios |
|
|
||||||
|---|---|---|
|
|
||||||
| `create-po.test.ts` | S-01, S-02, S-03 | Draft, submit, line items, totals, optional fields, notifications |
|
|
||||||
| `approval-actions.test.ts` | M-02, M-03, M-04, S-06, S-07 | Approve, reject, request edits, vendor ID flow, resubmit |
|
|
||||||
| `payment-actions.test.ts` | A-01, A-02 | Payment queue, mark paid |
|
|
||||||
| `discard-po.test.ts` | Discard draft | Owner, MANAGER, SUPERUSER can discard; ACCOUNTS and non-owners denied; status guard; cascade cleanup |
|
|
||||||
| `vendor-approval.test.ts` | Vendor gate + provide vendor ID | Approval blocked without vendor; ACCOUNTS can provide vendor ID; unverified vendor rejected; AUDITOR denied |
|
|
||||||
| `manager-po-creation.test.ts` | Manager creates POs | MANAGER can create, submit, discard; ACCOUNTS denied; role documented for self-approval |
|
|
||||||
| `products-search.test.ts` | Product search API | Auth, min-length validation, name/code/description search, case-insensitive, max 10, inactive excluded, Decimal serialised |
|
|
||||||
| `import-api.test.ts` | Excel import API | Auth (TECHNICAL/ACCOUNTS → 403), no file, invalid file, correct parse of Sample_PO.xlsx |
|
|
||||||
|
|
||||||
### 4.3 E2E Tests (Playwright)
|
|
||||||
|
|
||||||
| Spec File | Scenarios |
|
|
||||||
|---|---|
|
|
||||||
| `auth.spec.ts` | Login, redirect on bad creds, role badge, sign-out |
|
|
||||||
| `submitter-journey.spec.ts` | Create draft, add line items, submit, see status transitions |
|
|
||||||
| `manager-approvals.spec.ts` | Review PO, approve with/without note, reject, request edits |
|
|
||||||
| `accounts-payment.spec.ts` | Payment queue, process payment, confirm receipt |
|
|
||||||
| `po-export.spec.ts` | PDF and XLSX export buttons and content |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Permission Test Matrix
|
|
||||||
|
|
||||||
The table below documents every role's expected access to key operations. ✓ = allowed, ✗ = denied.
|
|
||||||
|
|
||||||
| Operation | TECHNICAL | MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Create PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Submit PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Edit own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Discard own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Discard any draft | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Approve PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Reject PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Request edits | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Provide vendor ID | Own PO only | Own PO only | ✓ | ✓ | ✓ | ✗ | ✗ |
|
|
||||||
| Process payment | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
|
||||||
| Confirm receipt | Own PO only | Own PO only | ✗ | ✗ | ✓ | ✗ | ✗ |
|
|
||||||
| Manage vendors | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ |
|
|
||||||
| Manage products | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ |
|
|
||||||
| Import PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✓ |
|
|
||||||
| View analytics | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
|
|
||||||
|
|
||||||
**Business rules tested explicitly:**
|
|
||||||
- A vendor must be assigned before a manager can approve a PO.
|
|
||||||
- Only verified vendors (those with a `vendorId` field) may be assigned via `provideVendorId`.
|
|
||||||
- Discarding is only possible on `DRAFT` status POs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Feature-Level Test Scenarios
|
|
||||||
|
|
||||||
### F-01: PO Creation & Draft Management
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| S-01 | Create PO with multiple line items; verify totals | Integration | `create-po.test.ts` |
|
|
||||||
| S-02 | Save as draft; verify status = DRAFT | Integration | `create-po.test.ts` |
|
|
||||||
| S-02a | ACCOUNTS role denied creation | Integration | `create-po.test.ts` |
|
|
||||||
| S-02b | MANAGER can create and save a draft | Integration | `manager-po-creation.test.ts` |
|
|
||||||
| S-03 | Submit for approval; status = MGR_REVIEW | Integration | `create-po.test.ts` |
|
|
||||||
| S-04 | Discard draft by owner | Integration | `discard-po.test.ts` |
|
|
||||||
| S-04a | MANAGER discards any draft | Integration | `discard-po.test.ts` |
|
|
||||||
| S-04b | ACCOUNTS cannot discard | Integration | `discard-po.test.ts` |
|
|
||||||
| S-04c | Cannot discard a submitted PO | Integration | `discard-po.test.ts` |
|
|
||||||
|
|
||||||
### F-02: Approval Workflow
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| M-01 | Manager sees pending POs | E2E | `manager-approvals.spec.ts` |
|
|
||||||
| M-02 | Approve PO → MGR_APPROVED | Integration / E2E | `approval-actions.test.ts` |
|
|
||||||
| M-02a | Approve with note stores managerNote | Integration | `approval-actions.test.ts` |
|
|
||||||
| M-02b | Approval blocked — no vendor assigned | Integration | `vendor-approval.test.ts` |
|
|
||||||
| M-03 | Reject PO with note | Integration / E2E | `approval-actions.test.ts` |
|
|
||||||
| M-04 | Request edits → EDITS_REQUESTED | Integration | `approval-actions.test.ts` |
|
|
||||||
| M-04a | Request vendor ID → VENDOR_ID_PENDING | Integration | `approval-actions.test.ts` |
|
|
||||||
| M-04b | TECHNICAL denied approval | Integration | `approval-actions.test.ts` |
|
|
||||||
|
|
||||||
### F-03: Vendor ID Assignment
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| S-06 | TECHNICAL provides vendor ID on own PO | Integration | `approval-actions.test.ts` |
|
|
||||||
| S-06a | ACCOUNTS provides vendor ID | Integration | `vendor-approval.test.ts` |
|
|
||||||
| S-06b | Unverified vendor rejected | Integration | `vendor-approval.test.ts` |
|
|
||||||
| S-06c | AUDITOR cannot provide vendor ID | Integration | `vendor-approval.test.ts` |
|
|
||||||
| S-06d | Wrong status → error | Integration | `vendor-approval.test.ts` |
|
|
||||||
|
|
||||||
### F-04: Payment & Receipt
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A-01 | Accounts processes payment | Integration / E2E | `payment-actions.test.ts` |
|
|
||||||
| A-02 | Mark as paid with reference | Integration / E2E | `payment-actions.test.ts` |
|
|
||||||
|
|
||||||
### F-05: Excel Import
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| I-01 | Parser extracts 1 line item from Sample_PO.xlsx | Unit | `po-import-parser.test.ts` |
|
|
||||||
| I-02 | T&C rows not included in line items | Unit | `po-import-parser.test.ts` |
|
|
||||||
| I-03 | Vendor name, PI quotation, place of delivery extracted | Unit | `po-import-parser.test.ts` |
|
|
||||||
| I-04 | GST rate > 1 normalised to fraction | Unit | `po-import-parser.test.ts` |
|
|
||||||
| I-05 | INSTRUCTIONS TO VENDORS row stops parsing | Unit | `po-import-parser.test.ts` |
|
|
||||||
| I-06 | TECHNICAL / ACCOUNTS denied (403) | Integration | `import-api.test.ts` |
|
|
||||||
| I-07 | Unauthenticated denied (401) | Integration | `import-api.test.ts` |
|
|
||||||
| I-08 | No file → 400 | Integration | `import-api.test.ts` |
|
|
||||||
| I-09 | Invalid binary → 400 | Integration | `import-api.test.ts` |
|
|
||||||
| I-10 | MANAGER receives parsed results (200) | Integration | `import-api.test.ts` |
|
|
||||||
| I-11 | Correct line item values in API response | Integration | `import-api.test.ts` |
|
|
||||||
|
|
||||||
### F-06: Product Fuzzy Search
|
|
||||||
| ID | Scenario | Type | File |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P-01 | Unauthenticated → 401 | Integration | `products-search.test.ts` |
|
|
||||||
| P-02 | Query < 2 chars → empty array | Integration | `products-search.test.ts` |
|
|
||||||
| P-03 | Search by name substring | Integration | `products-search.test.ts` |
|
|
||||||
| P-04 | Search by product code | Integration | `products-search.test.ts` |
|
|
||||||
| P-05 | Search by description text | Integration | `products-search.test.ts` |
|
|
||||||
| P-06 | Case-insensitive matching | Integration | `products-search.test.ts` |
|
|
||||||
| P-07 | Max 10 results returned | Integration | `products-search.test.ts` |
|
|
||||||
| P-08 | lastPrice serialised as `number` not Prisma Decimal | Integration | `products-search.test.ts` |
|
|
||||||
| P-09 | Inactive products excluded | Integration | `products-search.test.ts` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Known Gaps & Out-of-Scope Items
|
|
||||||
|
|
||||||
### Currently untested (acceptable gaps)
|
|
||||||
| Area | Reason |
|
|
||||||
|---|---|
|
|
||||||
| File upload to S3 / storage | Requires live AWS credentials; tested manually in staging |
|
|
||||||
| Email notification content | `notify()` is mocked; email body format tested via review |
|
|
||||||
| PDF/XLSX export content | Snapshot-tested manually; E2E checks endpoint responds |
|
|
||||||
| Receipt confirmation workflow | Happy path covered in E2E; integration test pending |
|
|
||||||
| Admin CRUD (users, vessels, accounts, products) | Standard CRUD; covered by E2E smoke tests |
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
- Performance / load testing
|
|
||||||
- Accessibility (a11y) automated checks
|
|
||||||
- Cross-browser testing (Chromium only)
|
|
||||||
- Mobile viewport testing
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Running the Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# All unit tests (fast, no DB needed)
|
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# Unit tests in watch mode during development
|
|
||||||
pnpm test:watch
|
|
||||||
|
|
||||||
# Integration tests (requires seeded DB)
|
|
||||||
pnpm test:integration
|
|
||||||
|
|
||||||
# All unit + integration
|
|
||||||
pnpm test:all
|
|
||||||
|
|
||||||
# E2E tests (requires running dev server)
|
|
||||||
pnpm test:e2e
|
|
||||||
|
|
||||||
# E2E with interactive Playwright UI
|
|
||||||
pnpm test:e2e:ui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pre-requisites for integration tests
|
|
||||||
1. A PostgreSQL instance running and `.env` pointing to it (`DATABASE_URL`).
|
|
||||||
2. Schema applied: `npx prisma migrate deploy` (or `npx prisma db push` in dev).
|
|
||||||
3. Data seeded: `npx tsx prisma/seed.ts`.
|
|
||||||
|
|
||||||
### CI behaviour
|
|
||||||
Integration tests and E2E tests run on every PR. E2E tests retry twice on failure (`playwright.config.ts`). The `test:all` script is used for pre-merge validation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Test Authorship Conventions
|
|
||||||
|
|
||||||
- **Naming:** `describe` blocks map to feature scenarios (e.g., `"S-02 — save as draft"`). `it` blocks describe the outcome, not the action.
|
|
||||||
- **Prefix isolation:** Every integration test uses a `PREFIX` constant (e.g., `"INTTEST_DISCARD_"`) and cleans up with `afterEach(() => deletePosByTitle(PREFIX))`.
|
|
||||||
- **No test interdependence:** Each test creates its own data. Tests must pass in isolation and in any order.
|
|
||||||
- **Negative tests first:** Each describe block should include at least one negative (denial/error) case before or after the happy path.
|
|
||||||
- **Avoid `any`:** Type assertions in tests should use `as { id: string }` or similar narrow casts, not `as any`.
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
# PPMS — E2E Test Framework Reference
|
|
||||||
|
|
||||||
This document describes the Playwright-based end-to-end test framework for the
|
|
||||||
PPMS portal: its stack, directory layout, configuration, shared utilities, and
|
|
||||||
the conventions every spec must follow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
| Layer | Tool | Version |
|
|
||||||
|---|---|---|
|
|
||||||
| Test runner | `@playwright/test` | 1.60 |
|
|
||||||
| Browser | Chromium (headless) | bundled with Playwright |
|
|
||||||
| Language | TypeScript | inherits from app `tsconfig.json` |
|
|
||||||
| Package manager | pnpm | same as portal app |
|
|
||||||
| App server | Next.js 15 dev server (`pnpm dev`) | auto-started by Playwright config |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Directory Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
App/pelagia-portal/
|
|
||||||
├── playwright.config.ts # Root config — workers, retries, baseURL, webServer
|
|
||||||
└── tests/
|
|
||||||
├── e2e/
|
|
||||||
│ ├── helpers/
|
|
||||||
│ │ ├── login.ts # Shared login(), createDraftPo(), submitPo(), USERS
|
|
||||||
│ │ └── auth.js # Legacy plain-JS login helper (pre-existing)
|
|
||||||
│ ├── dashboard/
|
|
||||||
│ │ └── po-status-badges.js
|
|
||||||
│ ├── inventory/
|
|
||||||
│ │ ├── items-tags.spec.ts
|
|
||||||
│ │ └── cart-icon.spec.ts
|
|
||||||
│ ├── mobile/
|
|
||||||
│ │ ├── desktop-required.spec.ts
|
|
||||||
│ │ ├── manager-approvals.spec.ts
|
|
||||||
│ │ ├── accounts-payments.spec.ts
|
|
||||||
│ │ └── bottom-nav.spec.ts
|
|
||||||
│ ├── admin-bordered-buttons.spec.ts
|
|
||||||
│ ├── approvals-edit-highlight.spec.ts
|
|
||||||
│ ├── export-gate.spec.ts
|
|
||||||
│ ├── notification-bell.spec.ts
|
|
||||||
│ ├── partial-receipt.spec.ts
|
|
||||||
│ ├── payment-history.spec.ts
|
|
||||||
│ ├── po-submit-button.spec.ts
|
|
||||||
│ ├── profile.spec.ts
|
|
||||||
│ ├── rebrand.spec.ts
|
|
||||||
│ └── vendor-auto-verify.spec.ts
|
|
||||||
├── integration/ # Vitest integration tests (separate suite)
|
|
||||||
└── unit/ # Vitest unit tests (separate suite)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration (`playwright.config.ts`)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests/e2e",
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 2 : 1, // 1 local retry reduces flakiness from auth concurrency
|
|
||||||
workers: process.env.CI ? 1 : 2, // 2 local workers — more causes NextAuth bcrypt flooding
|
|
||||||
reporter: "html",
|
|
||||||
use: {
|
|
||||||
baseURL: "http://localhost:3000",
|
|
||||||
trace: "on-first-retry",
|
|
||||||
},
|
|
||||||
webServer: {
|
|
||||||
command: "pnpm dev",
|
|
||||||
url: "http://localhost:3000",
|
|
||||||
reuseExistingServer: !process.env.CI, // reuse running dev server locally
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why workers: 2
|
|
||||||
|
|
||||||
The app uses NextAuth v5 with bcrypt password hashing for every login. Under high
|
|
||||||
parallelism (the default of ~50% CPU cores) all workers attempt to authenticate
|
|
||||||
simultaneously, overwhelming the dev server and causing login redirects to time out.
|
|
||||||
Two workers provide enough parallelism to keep the suite fast without triggering
|
|
||||||
the concurrency limit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Shared Helpers (`tests/e2e/helpers/login.ts`)
|
|
||||||
|
|
||||||
### `USERS` — seed credentials
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export const USERS = {
|
|
||||||
TECH: { email: "tech@pelagia.local", password: "tech1234" },
|
|
||||||
MANNING: { email: "manning@pelagia.local", password: "manning1234" },
|
|
||||||
ACCOUNTS: { email: "accounts@pelagia.local", password: "accounts1234" },
|
|
||||||
MANAGER: { email: "manager@pelagia.local", password: "manager1234" },
|
|
||||||
SUPERUSER: { email: "superuser@pelagia.local", password: "super1234" },
|
|
||||||
AUDITOR: { email: "auditor@pelagia.local", password: "audit1234" },
|
|
||||||
ADMIN: { email: "admin@pelagia.local", password: "admin1234" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### `login(page, creds)`
|
|
||||||
|
|
||||||
Navigates to `/login`, fills credentials, and waits up to **20 s** for the
|
|
||||||
redirect away from `/login`. The 20 s timeout is intentional — the bcrypt hash
|
|
||||||
check plus DB round-trip can exceed the Playwright default 5 s under any load.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await login(page, USERS.MANAGER);
|
|
||||||
```
|
|
||||||
|
|
||||||
### `createDraftPo(page, title)`
|
|
||||||
|
|
||||||
Creates a minimal PO as DRAFT and returns the absolute PO URL. Uses
|
|
||||||
**`name`-attribute selectors** because the PO form labels have no `htmlFor`/`id`
|
|
||||||
binding — `getByLabel()` will not resolve.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const poUrl = await createDraftPo(page, "Test PO - boiler parts");
|
|
||||||
```
|
|
||||||
|
|
||||||
### `submitPo(page, title)`
|
|
||||||
|
|
||||||
Same as `createDraftPo` but clicks the **Submit for Approval** button instead of
|
|
||||||
Save as Draft. Returns the PO URL after redirect.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Selector Conventions
|
|
||||||
|
|
||||||
### Critical: PO form has no accessible label bindings
|
|
||||||
|
|
||||||
The new-PO form (`/po/new`) and the edit form use `<label>` elements that are
|
|
||||||
**visual only** — they have no `for` attribute and the inputs have no `id`.
|
|
||||||
`page.getByLabel()` will not find them.
|
|
||||||
|
|
||||||
**Always use name-attribute selectors for PO form fields:**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// CORRECT
|
|
||||||
page.locator('input[name="title"]')
|
|
||||||
page.locator('select[name="vesselId"]')
|
|
||||||
page.locator('select[name="accountId"]')
|
|
||||||
page.locator('input[name="projectCode"]')
|
|
||||||
|
|
||||||
// WRONG — will time out
|
|
||||||
page.getByLabel(/title/i)
|
|
||||||
page.getByLabel(/vessel/i)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Role-badge selectors (profile page)
|
|
||||||
|
|
||||||
The user's role appears in both the desktop sidebar/header and the profile page.
|
|
||||||
`getByText("Technical")` will fail with a strict-mode violation. Scope to `<dd>`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// CORRECT — scoped to the profile <dd> role badge
|
|
||||||
await expect(page.locator("dd span").filter({ hasText: "Technical" })).toBeVisible();
|
|
||||||
|
|
||||||
// WRONG — strict-mode violation (role appears in header too)
|
|
||||||
await expect(page.getByText("Technical")).toBeVisible();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mobile viewport
|
|
||||||
|
|
||||||
Mobile tests must set the viewport explicitly before `login()`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const MOBILE_VIEWPORT = { width: 375, height: 812 };
|
|
||||||
|
|
||||||
test("...", async ({ page }) => {
|
|
||||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
|
||||||
await login(page, USERS.MANAGER);
|
|
||||||
// ...
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Checking CSS classes (not computed styles)
|
|
||||||
|
|
||||||
For visual-only assertions (bordered buttons, colored badges), check the element's
|
|
||||||
`class` attribute rather than computed CSS, since Tailwind classes are the source
|
|
||||||
of truth:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const cls = await page.locator("button", { hasText: "Edit" }).first().getAttribute("class");
|
|
||||||
expect(cls).toMatch(/border/);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Writing a New Spec
|
|
||||||
|
|
||||||
### File naming
|
|
||||||
|
|
||||||
- Feature specs: `tests/e2e/<section>/<feature>.spec.ts`
|
|
||||||
- Pre-`@playwright/test` scripts: `tests/e2e/<section>/<feature>.js` (legacy format)
|
|
||||||
|
|
||||||
New specs must use `@playwright/test` format (`import { test, expect } from "@playwright/test"`).
|
|
||||||
|
|
||||||
### Header comment
|
|
||||||
|
|
||||||
Every new spec file must open with a JSDoc block:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
/**
|
|
||||||
* User stories covered: Feature N — <Name>
|
|
||||||
* - Story 1
|
|
||||||
* - Story 2
|
|
||||||
*
|
|
||||||
* Created: YYYY-MM-DD
|
|
||||||
*/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step logging
|
|
||||||
|
|
||||||
Log every meaningful assertion with a `✓` prefix so CI output is scannable:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
console.log("✓ Export buttons hidden on DRAFT PO");
|
|
||||||
console.log(`✓ Logged in as ${creds.email}`);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Graceful skips for seed-dependent tests
|
|
||||||
|
|
||||||
Tests that require specific seed data (e.g., an item with multiple vendors, or a
|
|
||||||
PO in a particular status) should skip rather than fail hard if the precondition
|
|
||||||
is absent:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const poRow = page.locator("[data-status='MGR_APPROVED']").first();
|
|
||||||
if ((await poRow.count()) === 0) {
|
|
||||||
test.skip(true, "No MGR_APPROVED PO in seed data");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP-level assertions (no browser needed)
|
|
||||||
|
|
||||||
Use `request.get()` for API-level checks (status codes, headers) rather than
|
|
||||||
driving the full browser:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
test("export returns 403 for DRAFT PO", async ({ request }) => {
|
|
||||||
// Must first obtain a session cookie — use page-based login or apiRequestContext
|
|
||||||
const resp = await request.get(`/api/po/${draftPoId}/export?format=pdf`);
|
|
||||||
expect(resp.status()).toBe(403);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running the Suite
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd App/pelagia-portal
|
|
||||||
|
|
||||||
pnpm test:e2e # headless, 2 workers
|
|
||||||
pnpm test:e2e:ui # Playwright interactive UI
|
|
||||||
pnpm test:e2e -- --headed # watch the browser
|
|
||||||
|
|
||||||
# Single file
|
|
||||||
pnpm test:e2e -- tests/e2e/mobile/bottom-nav.spec.ts
|
|
||||||
|
|
||||||
# Name filter
|
|
||||||
pnpm test:e2e -- --grep "Feature 20"
|
|
||||||
|
|
||||||
# All tests with trace on every run (debugging)
|
|
||||||
pnpm test:e2e -- --trace on
|
|
||||||
```
|
|
||||||
|
|
||||||
HTML report at `playwright-report/index.html` after every run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known Gotchas
|
|
||||||
|
|
||||||
| Situation | Symptom | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| Many workers, all trying to log in at once | Login times out; page stays on `/login` | Keep `workers ≤ 2` locally; use `storageState` for auth |
|
|
||||||
| `getByLabel(/title/i)` on PO form | Locator times out — no `htmlFor` binding | Use `locator('input[name="title"]')` |
|
|
||||||
| `getByText("Technical")` on profile page | Strict-mode violation — appears in header AND profile | Scope to `page.locator("dd span").filter(...)` |
|
|
||||||
| Multi-role flow in one test (submit → approve → pay) | Flaky under 2 workers; competing for same user | Use `beforeAll` + a dedicated seed PO or run single-threaded |
|
|
||||||
| Viewport-dependent `md:hidden` elements | Element found at desktop viewport but not mobile, or vice versa | Always set viewport before login for mobile tests |
|
|
||||||
| `router.push` soft navigation | `waitForLoadState('networkidle')` still sees old URL | Use `page.waitForURL(pattern)` concurrently with the click/select |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
1. **Auth state sharing** — Save one `storageState` per role in a global setup file.
|
|
||||||
This eliminates ~100 login round-trips and should cut suite time from 25 min to
|
|
||||||
under 5 min.
|
|
||||||
|
|
||||||
2. **Fix pre-existing specs** — Update `submitter-journey.spec.ts` and
|
|
||||||
`po-export.spec.ts` to use the shared helper's name-based selectors (FIX-1 in
|
|
||||||
test report).
|
|
||||||
|
|
||||||
3. **`data-testid` attributes** — Add sparse `data-testid` attributes to
|
|
||||||
ambiguous elements (unit price input, line-item rows) so specs don't depend on
|
|
||||||
implementation details like placeholder text or CSS class names.
|
|
||||||
|
|
||||||
4. **CI integration** — Run `pnpm test:e2e` in GitHub Actions on every PR.
|
|
||||||
Use `workers: 1` and `retries: 2` (already wired for `process.env.CI`).
|
|
||||||
|
|
||||||
5. **Visual regression** — Add Percy or Playwright's built-in screenshot comparison
|
|
||||||
for the status badge colors and mobile card layout.
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
# PPMS — E2E Test Plan
|
|
||||||
|
|
||||||
**Version:** 1.0
|
|
||||||
**Date:** 2026-05-17
|
|
||||||
**Scope:** PPMS portal (`App/pelagia-portal`)
|
|
||||||
**Test type:** Browser-level end-to-end (Playwright / Chromium)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1 · Objectives
|
|
||||||
|
|
||||||
1. Verify that each shipped feature behaves correctly from a user's perspective in
|
|
||||||
a real browser session against a live Next.js dev server and PostgreSQL database.
|
|
||||||
2. Catch regressions introduced by new features before they reach production.
|
|
||||||
3. Document the expected user experience for each role so that future developers
|
|
||||||
have a runnable specification, not just written prose.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2 · Scope
|
|
||||||
|
|
||||||
### In scope
|
|
||||||
|
|
||||||
- All authenticated portal routes under `/(portal)/`
|
|
||||||
- Login / logout flows
|
|
||||||
- Role-based access control (page redirects, element visibility)
|
|
||||||
- Mobile-specific layout and navigation (375 × 812 viewport)
|
|
||||||
- API-level gate checks (HTTP status codes on export endpoint)
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
- Unit tests for individual components and utilities → `tests/unit/` (Vitest)
|
|
||||||
- Integration tests for Server Actions and database mutations → `tests/integration/` (Vitest + real DB)
|
|
||||||
- Email delivery (Resend is console-logged in dev; not browser-testable)
|
|
||||||
- File storage (R2 is mocked to `.dev-uploads/` in dev)
|
|
||||||
- GstService GST-number lookup (separate Node.js service; tested independently)
|
|
||||||
- Visual pixel-perfect regression (not yet implemented)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3 · Test Environment
|
|
||||||
|
|
||||||
| Item | Value |
|
|
||||||
|---|---|
|
|
||||||
| Base URL | `http://localhost:3000` |
|
|
||||||
| App server | Next.js 15 dev server (`pnpm dev`) — auto-started by Playwright webServer config |
|
|
||||||
| Database | Local PostgreSQL populated with `pnpm db:seed` |
|
|
||||||
| Browser | Chromium (headless by default) |
|
|
||||||
| Auth | Fresh login per test using seeded credentials |
|
|
||||||
|
|
||||||
**Prerequisite:** run `pnpm db:seed` before the first test run to ensure all users,
|
|
||||||
vessels, accounts, vendors, and POs are present.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4 · User Roles Under Test
|
|
||||||
|
|
||||||
| Role | Email | Capabilities tested |
|
|
||||||
|---|---|---|
|
|
||||||
| TECHNICAL | tech@pelagia.local | Create/submit POs, view status, receipt confirmation |
|
|
||||||
| MANNING | manning@pelagia.local | Same as TECHNICAL; separate user for isolation |
|
|
||||||
| ACCOUNTS | accounts@pelagia.local | Payment queue, mark paid, payment history, partial receipt |
|
|
||||||
| MANAGER | manager@pelagia.local | Approval queue, approve/reject/request-edits, mobile |
|
|
||||||
| SUPERUSER | superuser@pelagia.local | All manager capabilities + admin read |
|
|
||||||
| ADMIN | admin@pelagia.local | Admin CRUD pages (users, vendors, vessels, etc.) |
|
|
||||||
| AUDITOR | auditor@pelagia.local | Desktop Required overlay (non-mobile role) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5 · Feature Coverage Matrix
|
|
||||||
|
|
||||||
Each row maps a shipped feature (linked to its git commit) to the spec file
|
|
||||||
that verifies it, the roles exercised, and the current test status.
|
|
||||||
|
|
||||||
| # | Feature | Spec File | Roles | Status |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 1 | PPMS rebrand — login, sidebar, title | `rebrand.spec.ts` | TECH | ✅ Pass |
|
|
||||||
| 2 | Color-coded PO status badges on dashboard | `dashboard/po-status-badges.js` | TECH, MANAGER | ✅ Pass |
|
|
||||||
| 3 | Submit for Approval button on DRAFT PO detail | `po-submit-button.spec.ts` | TECH | ⚠️ Selector fix needed |
|
|
||||||
| 4 | In-app notification bell with unread badge | `notification-bell.spec.ts` | TECH, MANAGER, ACCOUNTS | ✅ Pass |
|
|
||||||
| 5 | Export gate — PDF/XLSX only on MGR_APPROVED+ | `export-gate.spec.ts` | TECH, MANAGER | ✅ Pass |
|
|
||||||
| 6 | Approver name as signatory on exported docs | `export-gate.spec.ts` | ACCOUNTS | ✅ Pass |
|
|
||||||
| 7 | Payment history page at `/payments/history` | `payment-history.spec.ts` | ACCOUNTS, MANAGER, TECH | ✅ Pass |
|
|
||||||
| 8 | Partial receipt confirmation (per-item delivery) | `partial-receipt.spec.ts` | ACCOUNTS, TECH | ✅ Pass |
|
|
||||||
| 9 | Auto-verify vendor on first successful payment | `vendor-auto-verify.spec.ts` | ADMIN, ACCOUNTS | ✅ Pass (UI only; full flow skipped) |
|
|
||||||
| 10 | Bordered buttons on admin pages | `admin-bordered-buttons.spec.ts` | ADMIN | ✅ Pass |
|
|
||||||
| 11 | User profile page and manager signature | `profile.spec.ts` | TECH, ACCOUNTS, MANAGER, SUPERUSER | ✅ 6/7 pass |
|
|
||||||
| 12 | Cheapest / ★ Closest tags on inventory items | `inventory/items-tags.spec.ts` | TECH | ✅ Pass |
|
|
||||||
| 13 | Auto-sort by distance when site is selected | `inventory/items-tags.spec.ts` | TECH | ✅ Pass |
|
|
||||||
| 14 | Cart icon in header with item count badge | `inventory/cart-icon.spec.ts` | TECH | ✅ Pass |
|
|
||||||
| 15 | Item and vendor detail pages at `/inventory/…/[id]` | `inventory/cart-icon.spec.ts` | TECH | ✅ Pass |
|
|
||||||
| 16 | Desktop Required overlay for non-mobile roles | `mobile/desktop-required.spec.ts` | AUDITOR, TECH | ✅ Pass |
|
|
||||||
| 17 | Manager approval queue as mobile cards | `mobile/manager-approvals.spec.ts` | MANAGER | ✅ Pass |
|
|
||||||
| 18 | Accounts payment actions on mobile | `mobile/accounts-payments.spec.ts` | ACCOUNTS | ✅ Pass |
|
|
||||||
| 19 | Sign-out button on Desktop Required overlay | `mobile/desktop-required.spec.ts` | AUDITOR | ✅ Pass |
|
|
||||||
| 20 | Home tab in mobile bottom navigation | `mobile/bottom-nav.spec.ts` | MANAGER, ACCOUNTS | ✅ Pass |
|
|
||||||
| 21 | Edit-highlight diff on resubmitted POs | `approvals-edit-highlight.spec.ts` | TECH, MANAGER | ⚡ Flaky |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6 · Test Case Descriptions
|
|
||||||
|
|
||||||
### Feature 1 — PPMS Rebrand
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-1a | Visit `/login` | Page shows text "PPMS" and "Pelagia Payment Management System" |
|
|
||||||
| US-1a | Visit `/login` | Page does NOT show "Pelagia Portal" |
|
|
||||||
| US-1b | Log in as any user | Sidebar displays "PPMS" |
|
|
||||||
| US-1c | Log in as any user | Browser tab title matches `/PPMS/i` |
|
|
||||||
|
|
||||||
### Feature 2 — Dashboard Status Badges
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-2a | TECHNICAL logs in, visits `/dashboard` | Each PO row has a visible badge element with a background-color class |
|
|
||||||
| US-2b | MANAGER logs in, visits `/dashboard` | Same — badges present on manager view |
|
|
||||||
|
|
||||||
### Feature 4 — Notification Bell
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-4a | Any user logs in | A bell icon button is visible in the header |
|
|
||||||
| US-4b | User has unread notifications | A numeric badge or dot is visible on/near the bell |
|
|
||||||
| US-4c | User clicks the bell | A dropdown/panel appears containing notification items |
|
|
||||||
|
|
||||||
### Feature 5 & 6 — Export Gate
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-5a | Visit a DRAFT PO detail page | No "Export PDF" or "Export XLSX" buttons visible |
|
|
||||||
| US-5b | Visit a MGR_APPROVED PO detail page | Export buttons are visible |
|
|
||||||
| US-5c | `GET /api/po/[draftId]/export?format=pdf` | HTTP 403 with error JSON |
|
|
||||||
| US-6a | `GET /api/po/[approvedId]/export?format=xlsx` | HTTP 200, content-type `application/vnd.openxmlformats…` |
|
|
||||||
| US-6b | `GET /api/po/[approvedId]/export?format=pdf` | HTTP 200, content-type `application/pdf` |
|
|
||||||
|
|
||||||
### Feature 7 — Payment History
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-7a | ACCOUNTS visits `/payments/history` | Page loads; shows table or empty-state |
|
|
||||||
| US-7a | MANAGER visits `/payments/history` | Page loads (MANAGER has `view_all_pos` permission) |
|
|
||||||
| US-7b | TECHNICAL visits `/payments/history` | Redirected to `/dashboard` |
|
|
||||||
| US-7b | MANNING visits `/payments/history` | Redirected to `/dashboard` |
|
|
||||||
|
|
||||||
### Feature 10 — Admin Bordered Buttons
|
|
||||||
|
|
||||||
| ID | Description | Expected |
|
|
||||||
|---|---|---|
|
|
||||||
| US-10a | ADMIN visits `/admin/vendors` | Edit and Delete/Deactivate buttons have a CSS class containing `border` |
|
|
||||||
| US-10b | ADMIN visits `/admin/users` | Same |
|
|
||||||
| US-10c | ADMIN visits `/admin/vessels` | Same |
|
|
||||||
| US-10d | ADMIN visits `/admin/accounts` | Same |
|
|
||||||
|
|
||||||
### Feature 16–19 — Mobile Experience
|
|
||||||
|
|
||||||
| ID | Description | Viewport | Expected |
|
|
||||||
|---|---|---|---|
|
|
||||||
| US-16a | AUDITOR logs in | 375 × 812 | "Desktop Required" overlay covers the page |
|
|
||||||
| US-16a | TECHNICAL logs in | 375 × 812 | "Desktop Required" overlay visible |
|
|
||||||
| US-16a | MANAGER logs in | 375 × 812 | No overlay — portal content visible |
|
|
||||||
| US-16a | ACCOUNTS logs in | 375 × 812 | No overlay — portal content visible |
|
|
||||||
| US-19a | AUDITOR on Desktop Required screen | 375 × 812 | "Sign out" button present in overlay |
|
|
||||||
| US-19b | AUDITOR clicks "Sign out" | 375 × 812 | Redirected to `/login` |
|
|
||||||
| US-17a | MANAGER visits `/approvals` | 375 × 812 | PO cards rendered (not a table) |
|
|
||||||
| US-17b | MANAGER taps a PO card | 375 × 812 | Navigates to `/approvals/[id]` |
|
|
||||||
| US-17c | MANAGER on `/approvals/[id]` | 375 × 812 | Edit form hidden; Approve/Reject buttons visible |
|
|
||||||
| US-18a | ACCOUNTS visits `/payments` | 375 × 812 | Payment queue loads; no Desktop Required overlay |
|
|
||||||
| US-18b | ACCOUNTS sees MGR_APPROVED PO | 375 × 812 | "Start Payment Processing" button visible |
|
|
||||||
| US-18c | ACCOUNTS sees SENT_FOR_PAYMENT PO | 375 × 812 | Reference input + "Confirm Payment Sent" button visible |
|
|
||||||
|
|
||||||
### Feature 20 — Mobile Bottom Navigation
|
|
||||||
|
|
||||||
| ID | Description | Viewport | Expected |
|
|
||||||
|---|---|---|---|
|
|
||||||
| US-20a | MANAGER logs in | 375 × 812 | Bottom nav has links to `/dashboard`, `/approvals`, `/profile` |
|
|
||||||
| US-20b | MANAGER taps Home tab | 375 × 812 | Navigates to `/dashboard` |
|
|
||||||
| US-20c | ACCOUNTS logs in | 375 × 812 | Bottom nav has links to `/dashboard`, `/payments`, `/profile` |
|
|
||||||
| US-20c | ACCOUNTS taps Home tab | 375 × 812 | Navigates to `/dashboard` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7 · Regression Checklist
|
|
||||||
|
|
||||||
Run after any change to the following areas:
|
|
||||||
|
|
||||||
| Area changed | Specs to run |
|
|
||||||
|---|---|
|
|
||||||
| Auth / login / NextAuth config | `auth.spec.ts`, `rebrand.spec.ts` |
|
|
||||||
| Portal layout (sidebar, header, mobile nav) | `mobile/bottom-nav.spec.ts`, `mobile/desktop-required.spec.ts`, `rebrand.spec.ts` |
|
|
||||||
| PO state machine / status transitions | `export-gate.spec.ts`, `po-submit-button.spec.ts`, `approvals-edit-highlight.spec.ts` |
|
|
||||||
| Payment / Accounts flows | `accounts-payment.spec.ts`, `payment-history.spec.ts`, `mobile/accounts-payments.spec.ts` |
|
|
||||||
| Approval / Manager flows | `manager-approvals.spec.ts`, `mobile/manager-approvals.spec.ts` |
|
|
||||||
| Admin pages | `admin-bordered-buttons.spec.ts` |
|
|
||||||
| Inventory / Items | `inventory/items-tags.spec.ts`, `inventory/cart-icon.spec.ts` |
|
|
||||||
| Profile page | `profile.spec.ts` |
|
|
||||||
| Notifications | `notification-bell.spec.ts` |
|
|
||||||
| Export endpoint | `export-gate.spec.ts`, `po-export.spec.ts` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8 · Gaps & Future Test Coverage
|
|
||||||
|
|
||||||
The following areas are not yet covered by automated E2E tests:
|
|
||||||
|
|
||||||
| Gap | Priority | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| Full vendor auto-verify flow (TECH → submit → MANAGER → approve → ACCOUNTS → pay → verify) | Medium | Requires `beforeAll` multi-role setup; skip currently in place |
|
|
||||||
| PO edit form (`/po/[id]/edit`) — field pre-population | High | `submitter-journey.spec.ts` covers this but currently fails due to selector issue |
|
|
||||||
| Edits-requested email trigger | Low | Email is console-logged in dev; not directly testable in browser |
|
|
||||||
| AUDITOR read-only views | Medium | AUDITOR can view all POs; no spec yet |
|
|
||||||
| Superuser access requests on profile page | Low | UI exists; no spec |
|
|
||||||
| PDF/XLSX content verification (signature name, PO fields) | Medium | API returns correct status; content inspection not yet asserted |
|
|
||||||
| MANNING/TECHNICAL Desktop Required overlay | Done | Covered in `desktop-required.spec.ts` |
|
|
||||||
| Password change flow | Low | Form exists on profile page; not yet exercised |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9 · Continuous Integration (Planned)
|
|
||||||
|
|
||||||
When wired into CI (GitHub Actions), the following configuration applies:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .github/workflows/e2e.yml
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
run: pnpm test:e2e
|
|
||||||
env:
|
|
||||||
CI: "true"
|
|
||||||
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
|
|
||||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
|
||||||
NEXTAUTH_URL: "http://localhost:3000"
|
|
||||||
```
|
|
||||||
|
|
||||||
In CI mode (`process.env.CI = "true"`), the config uses:
|
|
||||||
- `workers: 1` — no concurrency, avoids auth flooding on constrained runners
|
|
||||||
- `retries: 2` — two retry attempts before marking a test as failed
|
|
||||||
- `forbidOnly: true` — fails the run if any `test.only` is left in the code
|
|
||||||
|
|
@ -7,12 +7,16 @@ running in production:
|
||||||
Portal header (bug icon) [App/components/layout/report-issue-button.tsx]
|
Portal header (bug icon) [App/components/layout/report-issue-button.tsx]
|
||||||
│ server action → Forgejo API
|
│ server action → Forgejo API
|
||||||
▼
|
▼
|
||||||
Forgejo issue (labels: portal, claude-queue) [git.pelagiamarine.com/shad0w/pelagia-portal]
|
Forgejo issue (label: portal) [git.pelagiamarine.com/shad0w/pelagia-portal]
|
||||||
│ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher"
|
│ polled every 10 min by Windows Scheduled Task "PelagiaClaudeIssueWatcher"
|
||||||
▼
|
▼
|
||||||
claude-issue-watcher.ps1 (this folder) [dev PC, runs headless Claude Code]
|
TRIAGE (watcher phase 1) [dev PC, headless Claude Code, analysis only]
|
||||||
│ Claude implements + verifies fix in C:\...\src\pelagia-autofix
|
│ Claude reads the issue + repo, posts a requirements-breakdown comment,
|
||||||
│ watcher pushes branch claude/issue-N and opens a PR (label: claude-pr)
|
│ and routes it: adds `claude-queue` (auto-fixable) or `interactive` (human)
|
||||||
|
▼
|
||||||
|
FIX (watcher phase 2, only for claude-queue) [headless Claude Code in C:\...\src\pelagia-autofix]
|
||||||
|
│ Claude implements + verifies fix; watcher pushes branch claude/issue-N
|
||||||
|
│ and opens a PR (label: claude-pr)
|
||||||
▼
|
▼
|
||||||
Human review: merge the PR, then create a release tag vX.Y.Z
|
Human review: merge the PR, then create a release tag vX.Y.Z
|
||||||
│ tag push triggers .forgejo/workflows/deploy.yml
|
│ tag push triggers .forgejo/workflows/deploy.yml
|
||||||
|
|
@ -23,37 +27,156 @@ forgejo-runner on pms1 (pm2: forgejo-runner, label "host")
|
||||||
pm2 restart ppms → live at pms.pelagiamarine.com
|
pm2 restart ppms → live at pms.pelagiamarine.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`interactive`-routed issues stop after triage for a human to pick up (run with
|
||||||
|
Claude in a steered session). The triage breakdown comment is plain (no bot
|
||||||
|
marker) so, for `claude-queue` issues, the fix stage reads it back as refined
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
## Contribution policy (all changes via PR)
|
||||||
|
|
||||||
|
**Every change lands through a pull request — no direct pushes to `master`.** This applies
|
||||||
|
to humans and to the automated pipeline alike (the watcher already opens PRs).
|
||||||
|
|
||||||
|
Each PR must include:
|
||||||
|
|
||||||
|
- **Tests** for any code change. Model: the integration test on `claude/issue-12` —
|
||||||
|
it targets the prod-mirror test DB, anchors on existing rows, inserts fixtures via
|
||||||
|
raw SQL (schema-tolerant), isolates them with a unique prefix, and cleans up in
|
||||||
|
`afterEach`. Docs/config/automation-only PRs are exempt.
|
||||||
|
- **Docs** updates where relevant (`App/README.md`, `App/CLAUDE.md`, `Docs/`,
|
||||||
|
this file, `CHANGELOG.md`).
|
||||||
|
|
||||||
|
**Enforcement** — [`.forgejo/workflows/pr-checks.yml`](../.forgejo/workflows/pr-checks.yml)
|
||||||
|
runs on every PR into `master`:
|
||||||
|
|
||||||
|
1. **Test-presence gate:** a PR touching `App/app|lib|components|hooks` with no test
|
||||||
|
change fails. Justify genuine exceptions in the PR body for a reviewer to override.
|
||||||
|
2. **Type-check:** `pnpm type-check` must be clean across the whole project (tests
|
||||||
|
included). The test suite's old type baseline was repaired when this gate landed.
|
||||||
|
3. **Unit tests:** `pnpm test` must pass.
|
||||||
|
|
||||||
|
All three are **hard** gates. `pnpm lint` is intentionally not run — it currently
|
||||||
|
requires an interactive ESLint migration (a follow-up). Integration tests are
|
||||||
|
type-checked here but executed against the `pelagia_test` DB by the autofix / locally
|
||||||
|
(not in this shared CI, to avoid prod-mirror schema drift).
|
||||||
|
|
||||||
|
A [`PULL_REQUEST_TEMPLATE.md`](../.forgejo/PULL_REQUEST_TEMPLATE.md) carries the checklist.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
| Piece | Where | Notes |
|
| Piece | Where | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files issue with `portal` + `claude-queue` labels |
|
| Report Issue button | `App/components/layout/report-issue-button.tsx` + `report-issue-actions.ts` | Any signed-in user; files issue with only the `portal` label (triage routes it) |
|
||||||
|
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
||||||
|
| Issue watcher (active) | `automation/claude-issue-watcher.sh` on pms1 | Bash port; runs 24/7 via cron. Config + logs under `~/issue-watcher/` |
|
||||||
|
| Issue watcher (Windows, disabled) | `automation/claude-issue-watcher.ps1` | PowerShell original. `PelagiaClaudeIssueWatcher` task is **disabled** (pms1 is the sole worker; two pollers would race) |
|
||||||
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
| Forgejo helper | `App/lib/forgejo.ts` | Needs `FORGEJO_URL`, `FORGEJO_REPO`, `FORGEJO_TOKEN` env (token scope: `write:issue`) |
|
||||||
| Issue watcher | `automation/claude-issue-watcher.ps1` | Config in `watcher.config.json` (gitignored — copy from the example). Logs in `automation/logs/` |
|
|
||||||
| Scheduled task | `automation/register-watcher-task.ps1` | Registers `PelagiaClaudeIssueWatcher`, every 10 min, single-instance |
|
|
||||||
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
|
| Deploy workflow | `.forgejo/workflows/deploy.yml` | Triggers on `v*` tags; runs on the `host` runner |
|
||||||
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
|
| Runner | pms1 `~/forgejo-runner`, pm2 process `forgejo-runner` | Registered as `pms1-host` with labels `host`, `docker` |
|
||||||
|
|
||||||
|
## Where the watcher runs (pms1)
|
||||||
|
|
||||||
|
The watcher runs on **pms1** under cron (every 10 min), polling Forgejo over the
|
||||||
|
local loopback (`http://127.0.0.1:3001`).
|
||||||
|
|
||||||
|
- Script: `~/issue-watcher/claude-issue-watcher.sh` (source: `automation/claude-issue-watcher.sh`)
|
||||||
|
- Config: `~/issue-watcher/watcher.config.json` (gitignored; holds the token + `claudeExe` = the nvm `claude` path)
|
||||||
|
- Work clone: `~/pelagia-autofix` (separate from the deployed `~/pms`)
|
||||||
|
- Logs: `~/issue-watcher/logs/` (`watcher-<date>.log`, per-issue `claude-*.log`, `cron.log`)
|
||||||
|
- Crontab: `*/10 * * * * PATH=<nvm bin>:... ~/issue-watcher/claude-issue-watcher.sh >> ~/issue-watcher/logs/cron.log 2>&1`
|
||||||
|
|
||||||
|
**Auth:** Claude Code must be signed in on pms1 (`ssh` in, run `claude`, complete
|
||||||
|
the login → writes `~/.claude/.credentials.json`). The watcher has a preflight that
|
||||||
|
no-ops until those credentials exist, so cron can be enabled before sign-in and
|
||||||
|
activates automatically once signed in. (An `ANTHROPIC_API_KEY` env var also satisfies it.)
|
||||||
|
|
||||||
|
The Windows variant (`.ps1` + `register-watcher-task.ps1`) is the portable fallback;
|
||||||
|
re-enable its task only if pms1 is unavailable, and disable one before enabling the other.
|
||||||
|
|
||||||
|
## Test database (for autofix verification)
|
||||||
|
|
||||||
|
So the fix stage can verify against realistic data without touching production:
|
||||||
|
|
||||||
|
- **`pelagia_test`** — a PostgreSQL database on pms1, owned by `pelagia_user`, that is
|
||||||
|
a **daily mirror of production** (`pelagia`). Created once as superuser; refreshed by
|
||||||
|
`automation/refresh-test-db.sh` via cron at **03:30** (`pg_dump pelagia | psql pelagia_test`).
|
||||||
|
- The autofix clone's `~/pelagia-autofix/App/.env` points `DATABASE_URL` at `pelagia_test`
|
||||||
|
and runs in **safe dev mode** — no Resend/SSO secrets, so email is console-logged and
|
||||||
|
storage is local. `NEXTAUTH_URL`/`PORT` are set to **3100** (production app is on 3000).
|
||||||
|
- The fix prompt tells Claude it may run integration tests against this DB
|
||||||
|
(`set -a; . ./.env; set +a; pnpm test:integration`) and may start a dev server on
|
||||||
|
**port 3100 only**, stopping it by port (`fuser -k 3100/tcp`) — never a broad `pkill next`,
|
||||||
|
which would take down production (it also runs a `next-server`).
|
||||||
|
|
||||||
|
Because the test DB is refreshed daily, anything the autofix writes to it (test data,
|
||||||
|
schema experiments) is disposable. Schema-migration issues are routed to `interactive`
|
||||||
|
by triage, so the unattended fixer should not be altering the schema anyway.
|
||||||
|
|
||||||
|
## Staging (smoke test before deploy)
|
||||||
|
|
||||||
|
`automation/staging-up.sh` (deployed to `~/issue-watcher/` on pms1) brings up a
|
||||||
|
**staging instance of the latest `master`** so changes can be clicked through
|
||||||
|
before a release tag deploys them to prod.
|
||||||
|
|
||||||
|
- Checkout: `~/pelagia-staging` (separate from `~/pms` and `~/pelagia-autofix`)
|
||||||
|
- Process: pm2 `ppms-staging` on **port 3200**, against the prod-mirror test DB
|
||||||
|
(`pelagia_test`), safe dev mode (console email, local storage, SSO disabled).
|
||||||
|
- **Auto-refresh:** [`.forgejo/workflows/staging.yml`](../.forgejo/workflows/staging.yml)
|
||||||
|
rebuilds staging on **every push to `master`** (i.e. every merged PR) on the host runner,
|
||||||
|
so staging always tracks the trunk. It runs `~/issue-watcher/staging-up.sh`; concurrent
|
||||||
|
runs are coalesced (newest master wins). Also triggerable on demand (`workflow_dispatch`).
|
||||||
|
- Manual refresh / restart: re-run `~/issue-watcher/staging-up.sh`.
|
||||||
|
- Stop: `pm2 delete ppms-staging`.
|
||||||
|
- **Access is SSH-tunnel only** — the dev server binds to `127.0.0.1:3200`, so it is
|
||||||
|
not reachable from the public internet. Open a tunnel and browse `http://localhost:3200`:
|
||||||
|
`ssh -L 3200:localhost:3200 shad0w@<pms1>`. On Windows, the desktop shortcut
|
||||||
|
**"Pelagia Staging (tunnel)"** (`automation/staging-tunnel.cmd`) opens the tunnel and
|
||||||
|
the browser in one click.
|
||||||
|
- A fixed banner **"INTERNAL DEV / STAGING - NOT PRODUCTION"** is shown (driven by
|
||||||
|
`NEXT_PUBLIC_ENV_LABEL` in the staging `.env`; the `EnvBanner` component renders nothing
|
||||||
|
when the var is unset, so production is unaffected).
|
||||||
|
- Log in with a password user (SSO is off here), e.g. `admin@pelagiamarine.com`.
|
||||||
|
|
||||||
## Issue label lifecycle
|
## Issue label lifecycle
|
||||||
|
|
||||||
`claude-queue` → `claude-working` → `claude-pr` (PR opened, awaiting review)
|
```
|
||||||
or `claude-failed` (no verified fix; reason posted as an issue comment).
|
portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─▶ claude-pr | claude-failed
|
||||||
|
└─────▶ triaged + interactive (stops here — handle with Claude interactively)
|
||||||
|
```
|
||||||
|
|
||||||
To retry a failed issue, re-add the `claude-queue` label.
|
- **Triage owns routing for every `portal` issue.** Each untriaged portal issue is
|
||||||
To queue any manually-created issue for Claude, just add the `claude-queue` label.
|
triaged once (`maxTriagePerRun` per run); triage adds `triaged`, a routing label
|
||||||
|
(`claude-queue` or `interactive`), a type label (`bug` or `feature`), and posts a
|
||||||
|
breakdown. Triage skips an issue only once it carries `triaged`, `interactive`,
|
||||||
|
`claude-working`, `claude-pr`, or `claude-failed`.
|
||||||
|
- **`claude-queue` alone does NOT skip triage on a portal issue.** The Report Issue
|
||||||
|
button may stamp `claude-queue` at creation; triage still claims the issue and
|
||||||
|
decides routing (stripping the stray `claude-queue` if it routes to `interactive`).
|
||||||
|
This is why triage works even if an older button build is deployed.
|
||||||
|
- `claude-queue` → `claude-working` → `claude-pr` (PR opened) or `claude-failed`.
|
||||||
|
- To retry a failed issue, re-add `claude-queue` (and remove `claude-failed`).
|
||||||
|
- To queue a **non-portal** issue for Claude (skipping triage), add `claude-queue`
|
||||||
|
directly — triage never claims issues without the `portal` label.
|
||||||
|
- To force a portal issue straight to fix, add `triaged` + `claude-queue` yourself.
|
||||||
|
|
||||||
## Releasing
|
## Releasing
|
||||||
|
|
||||||
After merging a Claude PR (or any change) on `master`:
|
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on
|
||||||
|
> `v*` tags — a bare tag like `0.2.2` will **NOT** deploy (the runner ignores it and prod
|
||||||
|
> stays on the previous version). Push the **tag** specifically; pushing `master` alone
|
||||||
|
> never deploys.
|
||||||
|
|
||||||
|
After merging PR(s) on `master`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git pull
|
git pull
|
||||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
git tag v0.2.2 # MUST start with "v"; semver: patch = fixes, minor = features
|
||||||
git push pms1 master --tags
|
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
The runner deploys the tag and restarts the app. Watch progress under
|
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` +
|
||||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch
|
||||||
|
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||||
|
|
||||||
## Operational notes
|
## Operational notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
# Claude issue watcher for the Pelagia portal.
|
# Claude issue watcher for the Pelagia portal. Two phases per run:
|
||||||
#
|
#
|
||||||
# Polls Forgejo for open issues labelled `claude-queue`, runs headless
|
# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude
|
||||||
# Claude Code on a dedicated clone to implement a fix, pushes a
|
# reads each (analysis only, no code changes), posts a requirements
|
||||||
# `claude/issue-N` branch, and opens a PR that closes the issue.
|
# breakdown comment, and routes it to `claude-queue` or `interactive`.
|
||||||
# Label lifecycle: claude-queue -> claude-working -> claude-pr | claude-failed
|
# 2. FIX -- find open `claude-queue` issues. Claude implements a fix on a
|
||||||
|
# dedicated clone, pushes a `claude/issue-N` branch, and opens a PR.
|
||||||
|
#
|
||||||
|
# Label lifecycle:
|
||||||
|
# portal -> (triage) -> claude-queue | interactive
|
||||||
|
# claude-queue -> claude-working -> claude-pr | claude-failed
|
||||||
#
|
#
|
||||||
# Intended to run unattended via Windows Task Scheduler (see
|
# Intended to run unattended via Windows Task Scheduler (see
|
||||||
# register-watcher-task.ps1). Logs to automation/logs/.
|
# register-watcher-task.ps1). Logs to automation/logs/.
|
||||||
|
|
@ -54,19 +59,51 @@ $headers = @{ Authorization = "token $($cfg.token)" }
|
||||||
function Api([string]$Method, [string]$Path, $Body = $null) {
|
function Api([string]$Method, [string]$Path, $Body = $null) {
|
||||||
$params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers }
|
$params = @{ Method = $Method; Uri = "$apiBase$Path"; Headers = $headers }
|
||||||
if ($null -ne $Body) {
|
if ($null -ne $Body) {
|
||||||
$params.Body = ($Body | ConvertTo-Json -Depth 5)
|
# Send UTF-8 bytes, not a string. PS 5.1's Invoke-RestMethod encodes a
|
||||||
|
# string body in a non-UTF-8 charset, which mangles any non-ASCII (e.g.
|
||||||
|
# an em-dash in Claude's triage breakdown) and makes Forgejo reject the JSON.
|
||||||
|
$json = $Body | ConvertTo-Json -Depth 5
|
||||||
|
$params.Body = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||||||
$params.ContentType = 'application/json'
|
$params.ContentType = 'application/json'
|
||||||
}
|
}
|
||||||
Invoke-RestMethod @params
|
Invoke-RestMethod @params
|
||||||
}
|
}
|
||||||
|
|
||||||
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) {
|
# Resolve label names to their numeric ids (always an array).
|
||||||
|
function Resolve-LabelIds([string[]]$Names) {
|
||||||
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
|
$allLabels = Api GET "/repos/$($cfg.repo)/labels?limit=50"
|
||||||
|
@(@($allLabels) | Where-Object { $Names -contains $_.name } | ForEach-Object { [int]$_.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
# PUT/POST a labels body. Build the JSON by hand: PS 5.1 ConvertTo-Json unwraps a
|
||||||
|
# single-element array to a scalar, which Forgejo rejects ("cannot unmarshal number
|
||||||
|
# into ... []interface {}"). [int[]] also coerces a lone scalar id back to an array.
|
||||||
|
function Send-IssueLabels([int]$IssueNumber, [string]$Method, [int[]]$Ids) {
|
||||||
|
$body = '{"labels":[' + (@($Ids) -join ',') + ']}'
|
||||||
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($body)
|
||||||
|
Invoke-RestMethod -Method $Method -Uri "$apiBase/repos/$($cfg.repo)/issues/$IssueNumber/labels" -Headers $headers -Body $bytes -ContentType 'application/json' | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Additively attach labels (Forgejo POST does not replace existing ones). Safe:
|
||||||
|
# it can never clear labels, unlike the replace-the-whole-set PUT below.
|
||||||
|
function Add-IssueLabels([int]$IssueNumber, [string[]]$Add) {
|
||||||
|
$ids = Resolve-LabelIds $Add
|
||||||
|
if (@($ids).Count -eq 0) { Log "Add-IssueLabels: no ids resolved for [$($Add -join ',')] on #$IssueNumber"; return }
|
||||||
|
Send-IssueLabels $IssueNumber 'POST' $ids
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replace the issue's label set (used for fix-phase transitions that remove labels).
|
||||||
|
function Set-IssueLabels([int]$IssueNumber, [string[]]$Remove, [string[]]$Add) {
|
||||||
$issue = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber"
|
$issue = Api GET "/repos/$($cfg.repo)/issues/$IssueNumber"
|
||||||
$current = @($issue.labels | ForEach-Object { $_.name })
|
$current = @($issue.labels | ForEach-Object { $_.name })
|
||||||
$wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique)
|
$wanted = @(($current | Where-Object { $Remove -notcontains $_ }) + $Add | Select-Object -Unique)
|
||||||
$ids = @($allLabels | Where-Object { $wanted -contains $_.name } | ForEach-Object { $_.id })
|
$ids = Resolve-LabelIds $wanted
|
||||||
Api PUT "/repos/$($cfg.repo)/issues/$IssueNumber/labels" @{ labels = $ids } | Out-Null
|
# Guard: never wipe labels because an id match unexpectedly came back empty.
|
||||||
|
if ($wanted.Count -gt 0 -and @($ids).Count -eq 0) {
|
||||||
|
Log "Set-IssueLabels: refusing to clear all labels on #$IssueNumber (wanted [$($wanted -join ',')] resolved to no ids)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Send-IssueLabels $IssueNumber 'PUT' $ids
|
||||||
}
|
}
|
||||||
|
|
||||||
function Add-IssueComment([int]$IssueNumber, [string]$Text) {
|
function Add-IssueComment([int]$IssueNumber, [string]$Text) {
|
||||||
|
|
@ -114,20 +151,41 @@ function Run-Git([string[]]$GitArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Find queued issues ──────────────────────────────────────────────
|
# List open issues carrying a given label. Capture to a variable before filtering:
|
||||||
# NB: capture the API result into a variable before filtering. Piping the Api
|
# piping the Api function's array output straight into Where-Object does NOT unroll
|
||||||
# function's output straight into Where-Object does NOT unroll the array in
|
# in PS 5.1 -- it collapses every issue into one object whose props are arrays.
|
||||||
# PS 5.1 — it collapses all issues into one object whose props are arrays.
|
function Get-OpenIssuesByLabel([string]$Label) {
|
||||||
$queuedResp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=claude-queue&type=issues&limit=20"
|
$resp = Api GET "/repos/$($cfg.repo)/issues?state=open&labels=$Label&type=issues&limit=50"
|
||||||
$queued = @(@($queuedResp) | Where-Object { $_ -and $_.number })
|
@(@($resp) | Where-Object { $_ -and $_.number })
|
||||||
if ($queued.Count -eq 0) {
|
|
||||||
Log "No queued issues."
|
|
||||||
exit 0
|
|
||||||
}
|
}
|
||||||
$queued = @($queued | Sort-Object { [int]$_.number } | Select-Object -First ([int]$cfg.maxIssuesPerRun))
|
|
||||||
Log "Found $($queued.Count) queued issue(s): $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
|
|
||||||
|
|
||||||
# ── Prepare the dedicated work clone ────────────────────────────────
|
# True if the issue object carries any of the given label names.
|
||||||
|
function Test-IssueHasLabel($Issue, [string[]]$Names) {
|
||||||
|
$have = @($Issue.labels | ForEach-Object { $_.name })
|
||||||
|
foreach ($x in $Names) { if ($have -contains $x) { return $true } }
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run headless Claude on a prompt file inside the clone; output -> $LogPath. Returns exit code.
|
||||||
|
function Invoke-Claude([string]$PromptFile, [string]$LogPath, [int]$MaxTurns) {
|
||||||
|
# cmd handles the redirects so native stderr never becomes a PS ErrorRecord.
|
||||||
|
Push-Location $cfg.workDir
|
||||||
|
try {
|
||||||
|
cmd /c "`"$($cfg.claudeExe)`" -p --dangerously-skip-permissions --max-turns $MaxTurns --output-format text < `"$PromptFile`" > `"$LogPath`" 2>&1"
|
||||||
|
return $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reset the work clone to a clean checkout of the base branch (discards stray files).
|
||||||
|
function Reset-CloneToBase {
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'fetch', 'origin') | Out-Null
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'checkout', '-f', "origin/$($cfg.baseBranch)") | Out-Null
|
||||||
|
Run-Git @('-C', $cfg.workDir, 'clean', '-fd') | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Prepare the dedicated work clone (needed by both phases) ─────────
|
||||||
$repoHost = ([Uri]$cfg.forgejoUrl).Host
|
$repoHost = ([Uri]$cfg.forgejoUrl).Host
|
||||||
$owner = $cfg.repo.Split('/')[0]
|
$owner = $cfg.repo.Split('/')[0]
|
||||||
$cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git"
|
$cloneUrl = "https://$($owner):$($cfg.token)@$repoHost/$($cfg.repo).git"
|
||||||
|
|
@ -139,6 +197,105 @@ if (-not (Test-Path (Join-Path $cfg.workDir '.git'))) {
|
||||||
Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null
|
Run-Git @('-C', $cfg.workDir, 'config', 'user.email', 'claude-autofix@pelagiamarine.com') | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$DecisionLabels = @('claude-queue', 'interactive', 'claude-working', 'claude-pr', 'claude-failed')
|
||||||
|
$maxTriage = if ($cfg.maxTriagePerRun) { [int]$cfg.maxTriagePerRun } else { 3 }
|
||||||
|
$triageTurns = if ($cfg.triageMaxTurns) { [int]$cfg.triageMaxTurns } else { 80 }
|
||||||
|
|
||||||
|
# ── Phase 1: triage new portal issues ───────────────────────────────
|
||||||
|
$portalIssues = Get-OpenIssuesByLabel 'portal'
|
||||||
|
$toTriage = @($portalIssues |
|
||||||
|
Where-Object { -not (Test-IssueHasLabel $_ $DecisionLabels) } |
|
||||||
|
Sort-Object { [int]$_.number } |
|
||||||
|
Select-Object -First $maxTriage)
|
||||||
|
Log "Triage: $($toTriage.Count) portal issue(s) awaiting triage"
|
||||||
|
|
||||||
|
foreach ($issue in $toTriage) {
|
||||||
|
$n = $issue.number
|
||||||
|
Log "-- Triaging #${n}: $($issue.title)"
|
||||||
|
Reset-CloneToBase
|
||||||
|
|
||||||
|
$commentsBlock = Get-IssueCommentsBlock $n
|
||||||
|
# Two plain output files instead of one JSON blob: a JSON object with a big
|
||||||
|
# embedded markdown string is fragile (Claude often emits literal newlines,
|
||||||
|
# which PS 5.1 ConvertFrom-Json rejects). A bare label file + a raw markdown
|
||||||
|
# file need no escaping and parse trivially.
|
||||||
|
$labelFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE_LABEL.txt'
|
||||||
|
$breakdownFile = Join-Path $cfg.workDir 'CLAUDE_TRIAGE.md'
|
||||||
|
foreach ($f in $labelFile, $breakdownFile) { if (Test-Path $f) { Remove-Item $f -Force } }
|
||||||
|
|
||||||
|
$tprompt = @"
|
||||||
|
You are TRIAGING issue #$n of the Pelagia Portal (PPMS), a Next.js 15 purchase-order management
|
||||||
|
system for a maritime company. The web app is in App/ -- read App/CLAUDE.md and explore the relevant
|
||||||
|
code to judge feasibility. This is ANALYSIS ONLY: do NOT modify any existing file, do NOT run builds
|
||||||
|
or tests, do NOT commit. You only create the two output files described below.
|
||||||
|
|
||||||
|
## Issue #${n}: $($issue.title)
|
||||||
|
|
||||||
|
$($issue.body)
|
||||||
|
|
||||||
|
$commentsBlock
|
||||||
|
|
||||||
|
## Your job
|
||||||
|
1. Interpret the request and break it into concrete technical action item(s), the way a developer
|
||||||
|
would in review -- note the files/areas likely involved and any open questions.
|
||||||
|
2. Decide whether an UNATTENDED automated coding run can safely and verifiably implement it:
|
||||||
|
- "claude-queue" = localized change, clear acceptance, verifiable by type-check / lint / unit
|
||||||
|
tests, and NOT touching DB migrations, auth/permissions, payments/money, external live systems
|
||||||
|
(e.g. the GST website), or large multi-file features.
|
||||||
|
- "interactive" = needs human steering: ambiguous or underspecified, needs business content or a
|
||||||
|
design decision, a schema migration, permissions/payments changes, an external dependency, or a
|
||||||
|
large feature needing visual verification.
|
||||||
|
3. Write TWO files in the repository root, nothing else:
|
||||||
|
- CLAUDE_TRIAGE_LABEL.txt -- a single line containing EXACTLY one word: claude-queue OR interactive
|
||||||
|
- CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas involved,
|
||||||
|
open questions, and a final one-line "Routing rationale: ..." explaining the choice.
|
||||||
|
"@
|
||||||
|
|
||||||
|
$tpromptFile = Join-Path $env:TEMP "claude-triage-$n-prompt.txt"
|
||||||
|
$tprompt | Out-File -FilePath $tpromptFile -Encoding utf8
|
||||||
|
$tlog = Join-Path $logDir "claude-triage-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||||||
|
|
||||||
|
Log "Running Claude triage on #$n (log: $tlog)"
|
||||||
|
$rc = Invoke-Claude $tpromptFile $tlog $triageTurns
|
||||||
|
Log "Claude triage exited with code $rc for #$n"
|
||||||
|
|
||||||
|
$label = $null
|
||||||
|
if (Test-Path $labelFile) {
|
||||||
|
$raw = Get-Content $labelFile -Raw -Encoding UTF8
|
||||||
|
if ($raw -match 'interactive') { $label = 'interactive' }
|
||||||
|
elseif ($raw -match 'claude-queue') { $label = 'claude-queue' }
|
||||||
|
}
|
||||||
|
# Read as UTF-8 so non-ASCII in the breakdown (em-dash etc.) is not mojibaked.
|
||||||
|
$breakdown = if (Test-Path $breakdownFile) { (Get-Content $breakdownFile -Raw -Encoding UTF8).Trim() } else { "" }
|
||||||
|
Reset-CloneToBase # discard the triage output files and any stray edits
|
||||||
|
|
||||||
|
if (-not $label) {
|
||||||
|
Log "Triage for #$n produced no valid decision; leaving for a human"
|
||||||
|
Add-IssueComment $n "$BotMarker`n[Claude triage] Could not auto-triage this issue. A human should review it and add either ``claude-queue`` or ``interactive``."
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Label FIRST: it marks the issue as triaged, so a failure while posting the
|
||||||
|
# comment below cannot cause a re-triage next run that double-posts the breakdown.
|
||||||
|
Add-IssueLabels $n @($label)
|
||||||
|
# NB: deliberately NO bot marker on the breakdown -- it is genuine refined
|
||||||
|
# requirements and SHOULD be fed to the fix stage (Get-IssueCommentsBlock
|
||||||
|
# includes it). The routing line is bot chatter but harmless as fix context.
|
||||||
|
$note = if ($breakdown) { $breakdown } else { "(no breakdown produced)" }
|
||||||
|
Add-IssueComment $n "## Claude triage`n`n$note`n`n**Routing:** ``$label``"
|
||||||
|
Log "Triaged #$n -> $label"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 2: fix queued issues ──────────────────────────────────────
|
||||||
|
# Assign to a variable before piping (see Get-OpenIssuesByLabel note).
|
||||||
|
$queuedAll = Get-OpenIssuesByLabel 'claude-queue'
|
||||||
|
$queued = @($queuedAll | Sort-Object { [int]$_.number } | Select-Object -First ([int]$cfg.maxIssuesPerRun))
|
||||||
|
if ($queued.Count -eq 0) {
|
||||||
|
Log "No queued issues to fix."
|
||||||
|
} else {
|
||||||
|
Log "Found $($queued.Count) queued issue(s) to fix: $(($queued | ForEach-Object { '#' + $_.number }) -join ', ')"
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($issue in $queued) {
|
foreach ($issue in $queued) {
|
||||||
$n = $issue.number
|
$n = $issue.number
|
||||||
$branch = "$($cfg.branchPrefix)$n"
|
$branch = "$($cfg.branchPrefix)$n"
|
||||||
|
|
@ -189,14 +346,8 @@ explanation to a file named CLAUDE_RESULT.md in the repository root (it will be
|
||||||
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
$claudeLog = Join-Path $logDir "claude-issue-$n-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
|
||||||
|
|
||||||
Log "Running Claude Code on #$n (log: $claudeLog)"
|
Log "Running Claude Code on #$n (log: $claudeLog)"
|
||||||
# cmd handles the redirects so native stderr never becomes a PS ErrorRecord
|
$rc = Invoke-Claude $promptFile $claudeLog ([int]$cfg.claudeMaxTurns)
|
||||||
Push-Location $cfg.workDir
|
Log "Claude exited with code $rc for #$n"
|
||||||
try {
|
|
||||||
cmd /c "`"$($cfg.claudeExe)`" -p --dangerously-skip-permissions --max-turns $($cfg.claudeMaxTurns) --output-format text < `"$promptFile`" > `"$claudeLog`" 2>&1"
|
|
||||||
Log "Claude exited with code $LASTEXITCODE for #$n"
|
|
||||||
} finally {
|
|
||||||
Pop-Location
|
|
||||||
}
|
|
||||||
|
|
||||||
# Relay an abort explanation if Claude declined the fix
|
# Relay an abort explanation if Claude declined the fix
|
||||||
$resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md'
|
$resultFile = Join-Path $cfg.workDir 'CLAUDE_RESULT.md'
|
||||||
|
|
|
||||||
368
automation/claude-issue-watcher.sh
Normal file
368
automation/claude-issue-watcher.sh
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Claude issue watcher -- Linux port (runs on pms1 via cron). Two phases per run:
|
||||||
|
#
|
||||||
|
# 1. TRIAGE -- find open `portal` issues with no decision label yet. Claude
|
||||||
|
# reads each (analysis only), writes a label + a markdown breakdown, the
|
||||||
|
# watcher posts the breakdown as a comment and adds `claude-queue` or
|
||||||
|
# `interactive`.
|
||||||
|
# 2. FIX -- find open `claude-queue` issues. Claude implements a fix on a
|
||||||
|
# dedicated clone, pushes `claude/issue-N`, and opens a PR.
|
||||||
|
#
|
||||||
|
# Label lifecycle:
|
||||||
|
# portal -> (triage) -> claude-queue | interactive
|
||||||
|
# claude-queue -> claude-working -> claude-pr | claude-failed
|
||||||
|
#
|
||||||
|
# Config: watcher.config.json next to this script (or pass a path as $1).
|
||||||
|
# Mirrors the Windows claude-issue-watcher.ps1; see automation/README.md.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONFIG="${1:-$SCRIPT_DIR/watcher.config.json}"
|
||||||
|
[ -f "$CONFIG" ] || { echo "Config not found: $CONFIG (copy watcher.config.example.json and fill in the token)"; exit 1; }
|
||||||
|
|
||||||
|
cfg() { jq -r "$1" "$CONFIG"; }
|
||||||
|
FORGEJO_URL=$(cfg .forgejoUrl)
|
||||||
|
REPO=$(cfg .repo)
|
||||||
|
TOKEN=$(cfg .token)
|
||||||
|
WORKDIR=$(cfg .workDir)
|
||||||
|
BASE_BRANCH=$(cfg .baseBranch)
|
||||||
|
BRANCH_PREFIX=$(cfg .branchPrefix)
|
||||||
|
MAX_FIX=$(cfg '.maxIssuesPerRun // 1')
|
||||||
|
MAX_TRIAGE=$(cfg '.maxTriagePerRun // 3')
|
||||||
|
CLAUDE=$(cfg .claudeExe)
|
||||||
|
FIX_TURNS=$(cfg '.claudeMaxTurns // 150')
|
||||||
|
TRIAGE_TURNS=$(cfg '.triageMaxTurns // 80')
|
||||||
|
API="$FORGEJO_URL/api/v1"
|
||||||
|
|
||||||
|
LOG_DIR="$SCRIPT_DIR/logs"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
LOG_FILE="$LOG_DIR/watcher-$(date +%F).log"
|
||||||
|
log() { echo "$(date +%T) $*" | tee -a "$LOG_FILE"; }
|
||||||
|
|
||||||
|
BOT_MARKER='<!-- ppms-bot -->'
|
||||||
|
# Bot status comments are excluded from the context fed back to Claude. New ones
|
||||||
|
# carry the marker; legacy ones are matched by stable phrases.
|
||||||
|
BOT_PATTERN='ppms-bot|has started working on this issue|Claude opened PR \[#|Automated fix attempt did not produce'
|
||||||
|
|
||||||
|
# --- single-instance lock ---
|
||||||
|
exec 9>"$SCRIPT_DIR/.watcher.lock"
|
||||||
|
if ! flock -n 9; then log "Another watcher run is active; exiting."; exit 0; fi
|
||||||
|
|
||||||
|
# --- preflight: idle until Claude Code is authenticated on this host ---
|
||||||
|
# Lets cron be enabled before sign-in: the watcher no-ops until creds appear,
|
||||||
|
# then activates on its own. Avoids wrongly marking issues claude-failed.
|
||||||
|
if [ ! -f "$HOME/.claude/.credentials.json" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||||
|
log "Claude Code not authenticated yet (no ~/.claude/.credentials.json or ANTHROPIC_API_KEY); skipping."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Forgejo API helpers (curl + jq; UTF-8 and JSON arrays are handled natively) ---
|
||||||
|
api() { # METHOD PATH [JSON_BODY]
|
||||||
|
local method=$1 path=$2 body=${3:-}
|
||||||
|
if [ -n "$body" ]; then
|
||||||
|
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" --data "$body"
|
||||||
|
else
|
||||||
|
curl -fsS -X "$method" "$API$path" -H "Authorization: token $TOKEN"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
issues_by_label() { api GET "/repos/$REPO/issues?state=open&labels=$1&type=issues&limit=50"; }
|
||||||
|
|
||||||
|
add_comment() { # NUMBER TEXT
|
||||||
|
api POST "/repos/$REPO/issues/$1/comments" "$(jq -nc --arg b "$2" '{body:$b}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build {"labels":[ids]} for the given label names from the live label list.
|
||||||
|
label_ids_body() { # NAME...
|
||||||
|
local names; names=$(printf '%s\n' "$@" | jq -R . | jq -sc .)
|
||||||
|
issues_labels_cache=${issues_labels_cache:-$(api GET "/repos/$REPO/labels?limit=50")}
|
||||||
|
printf '%s' "$issues_labels_cache" | jq -c --argjson want "$names" '{labels: [ .[] | select(.name as $n | $want|index($n)) | .id ]}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Additive: never clears existing labels.
|
||||||
|
add_labels() { # NUMBER NAME...
|
||||||
|
local num=$1; shift
|
||||||
|
local body; body=$(label_ids_body "$@")
|
||||||
|
if [ "$(printf '%s' "$body" | jq '.labels|length')" -eq 0 ]; then
|
||||||
|
log "add_labels: no ids resolved for [$*] on #$num"; return
|
||||||
|
fi
|
||||||
|
api POST "/repos/$REPO/issues/$num/labels" "$body" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replace the label set: (current - remove) + add. Guards against wiping.
|
||||||
|
set_labels() { # NUMBER "remove names" "add names"
|
||||||
|
local num=$1 remove="$2" add="$3"
|
||||||
|
local cur kept wanted body n wn
|
||||||
|
cur=$(api GET "/repos/$REPO/issues/$num" | jq -r '.labels[].name')
|
||||||
|
if [ -n "${remove// /}" ]; then
|
||||||
|
kept=$(printf '%s\n' $cur | grep -vxF "$(printf '%s\n' $remove)")
|
||||||
|
else
|
||||||
|
kept=$cur
|
||||||
|
fi
|
||||||
|
wanted=$(printf '%s\n' $kept $add | grep -v '^$' | sort -u)
|
||||||
|
body=$(label_ids_body $wanted)
|
||||||
|
n=$(printf '%s' "$body" | jq '.labels|length')
|
||||||
|
wn=$(printf '%s\n' $wanted | grep -vc '^$')
|
||||||
|
if [ "$wn" -gt 0 ] && [ "$n" -eq 0 ]; then
|
||||||
|
log "set_labels: refusing to clear all labels on #$num"; return
|
||||||
|
fi
|
||||||
|
api PUT "/repos/$REPO/issues/$num/labels" "$body" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Human comments as a markdown block (bot status comments excluded). Empty if none.
|
||||||
|
comments_block() { # NUMBER
|
||||||
|
local human
|
||||||
|
human=$(api GET "/repos/$REPO/issues/$1/comments?limit=50" \
|
||||||
|
| jq -r --arg pat "$BOT_PATTERN" '[.[] | select(.body != null) | select(.body | test($pat) | not)]')
|
||||||
|
[ "$(printf '%s' "$human" | jq 'length')" -eq 0 ] && return
|
||||||
|
printf '## Comments on the issue (read these -- they refine the scope/repro)\n\n'
|
||||||
|
printf '%s' "$human" | jq -r '.[] | "**\(.user.login) commented:**\n\(.body)\n"'
|
||||||
|
}
|
||||||
|
|
||||||
|
run_claude() { # PROMPT_FILE LOG_FILE MAX_TURNS
|
||||||
|
( cd "$WORKDIR" && "$CLAUDE" -p --dangerously-skip-permissions \
|
||||||
|
--max-turns "$3" --output-format text < "$1" > "$2" 2>&1 )
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_clone() {
|
||||||
|
git -C "$WORKDIR" fetch origin -q
|
||||||
|
git -C "$WORKDIR" checkout -f "origin/$BASE_BRANCH" -q 2>/dev/null
|
||||||
|
git -C "$WORKDIR" clean -fdq
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prepare the dedicated work clone (needed by both phases) ---
|
||||||
|
host_no_scheme=$(printf '%s' "$FORGEJO_URL" | sed 's#^https\?://##')
|
||||||
|
owner=${REPO%%/*}
|
||||||
|
CLONE_URL="http://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
|
||||||
|
[ "${FORGEJO_URL#https}" != "$FORGEJO_URL" ] && CLONE_URL="https://${owner}:${TOKEN}@${host_no_scheme}/${REPO}.git"
|
||||||
|
|
||||||
|
if [ ! -d "$WORKDIR/.git" ]; then
|
||||||
|
log "Cloning $REPO into $WORKDIR"
|
||||||
|
if ! git clone -q "$CLONE_URL" "$WORKDIR"; then log "git clone failed"; exit 1; fi
|
||||||
|
git -C "$WORKDIR" config user.name "Claude (auto-fix)"
|
||||||
|
git -C "$WORKDIR" config user.email "claude-autofix@pelagiamarine.com"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Triage OWNS routing for every portal issue. It claims a portal issue until it has
|
||||||
|
# been triaged (`triaged`) or is already in progress/done. NOTE: claude-queue is
|
||||||
|
# deliberately NOT a skip reason — the Report Issue button may stamp claude-queue at
|
||||||
|
# creation, and triage must still decide claude-queue vs interactive itself.
|
||||||
|
TRIAGE_SKIP_LABELS="interactive claude-working claude-pr claude-failed triaged"
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase 1: triage new portal issues
|
||||||
|
# =====================================================================
|
||||||
|
dl_json=$(printf '%s\n' $TRIAGE_SKIP_LABELS | jq -R . | jq -sc .)
|
||||||
|
to_triage=$(issues_by_label portal | jq -c --argjson dl "$dl_json" \
|
||||||
|
'[ .[] | select((.labels|map(.name)) as $have | ($dl | any(. as $d | $have|index($d))) | not) ] | sort_by(.number)')
|
||||||
|
to_triage=$(printf '%s' "$to_triage" | jq -c ".[:$MAX_TRIAGE]")
|
||||||
|
n_triage=$(printf '%s' "$to_triage" | jq 'length')
|
||||||
|
log "Triage: $n_triage portal issue(s) awaiting triage"
|
||||||
|
|
||||||
|
t=0
|
||||||
|
while [ "$t" -lt "$n_triage" ]; do
|
||||||
|
issue=$(printf '%s' "$to_triage" | jq -c ".[$t]")
|
||||||
|
t=$((t+1))
|
||||||
|
num=$(printf '%s' "$issue" | jq -r .number)
|
||||||
|
title=$(printf '%s' "$issue" | jq -r .title)
|
||||||
|
body=$(printf '%s' "$issue" | jq -r '.body // ""')
|
||||||
|
log "-- Triaging #$num: $title"
|
||||||
|
reset_clone
|
||||||
|
comments=$(comments_block "$num")
|
||||||
|
rm -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt" "$WORKDIR/CLAUDE_TRIAGE.md"
|
||||||
|
|
||||||
|
prompt_file=$(mktemp)
|
||||||
|
{
|
||||||
|
printf '%s\n' "You are TRIAGING issue #$num of the Pelagia Portal (PPMS), a Next.js 15 purchase-order"
|
||||||
|
printf '%s\n' "management system for a maritime company. The web app is in App/ -- read App/CLAUDE.md and"
|
||||||
|
printf '%s\n' "explore the relevant code to judge feasibility. This is ANALYSIS ONLY: do NOT modify any"
|
||||||
|
printf '%s\n' "existing file, do NOT run builds or tests, do NOT commit. You only create two output files."
|
||||||
|
printf '\n## Issue #%s: %s\n\n' "$num" "$title"
|
||||||
|
printf '%s\n\n' "$body"
|
||||||
|
printf '%s\n\n' "$comments"
|
||||||
|
printf '%s\n' "## Your job"
|
||||||
|
printf '%s\n' "1. Interpret the request and break it into concrete technical action item(s), the way a"
|
||||||
|
printf '%s\n' " developer would in review -- note the files/areas likely involved and any open questions."
|
||||||
|
printf '%s\n' "2. Decide whether an UNATTENDED automated coding run can safely and verifiably implement it:"
|
||||||
|
printf '%s\n' " - claude-queue = localized change, clear acceptance, verifiable by type-check / lint / unit"
|
||||||
|
printf '%s\n' " tests, and NOT touching DB migrations, auth/permissions, payments/money, external live"
|
||||||
|
printf '%s\n' " systems (e.g. the GST website), or large multi-file features."
|
||||||
|
printf '%s\n' " - interactive = needs human steering: ambiguous or underspecified, needs business content"
|
||||||
|
printf '%s\n' " or a design decision, a schema migration, permissions/payments changes, an external"
|
||||||
|
printf '%s\n' " dependency, or a large feature needing visual verification."
|
||||||
|
printf '%s\n' "3. Classify the issue as a BUG (something is broken / not working as intended) or a"
|
||||||
|
printf '%s\n' " FEATURE (new capability or a change/enhancement to existing behaviour)."
|
||||||
|
printf '%s\n' "4. Write THREE files in the repository root, nothing else:"
|
||||||
|
printf '%s\n' " - CLAUDE_TRIAGE_LABEL.txt -- one line, EXACTLY one word: claude-queue OR interactive"
|
||||||
|
printf '%s\n' " - CLAUDE_TRIAGE_TYPE.txt -- one line, EXACTLY one word: bug OR feature"
|
||||||
|
printf '%s\n' " - CLAUDE_TRIAGE.md -- your requirements breakdown as markdown: action items, files/areas"
|
||||||
|
printf '%s\n' " involved, open questions, and a final one-line 'Routing rationale: ...'."
|
||||||
|
} > "$prompt_file"
|
||||||
|
|
||||||
|
tlog="$LOG_DIR/claude-triage-$num-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
log "Running Claude triage on #$num (log: $tlog)"
|
||||||
|
run_claude "$prompt_file" "$tlog" "$TRIAGE_TURNS"; rc=$?
|
||||||
|
log "Claude triage exited with code $rc for #$num"
|
||||||
|
rm -f "$prompt_file"
|
||||||
|
|
||||||
|
label=""
|
||||||
|
if [ -f "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt" ]; then
|
||||||
|
raw=$(cat "$WORKDIR/CLAUDE_TRIAGE_LABEL.txt")
|
||||||
|
if printf '%s' "$raw" | grep -q interactive; then label=interactive
|
||||||
|
elif printf '%s' "$raw" | grep -q claude-queue; then label=claude-queue; fi
|
||||||
|
fi
|
||||||
|
breakdown=""
|
||||||
|
[ -f "$WORKDIR/CLAUDE_TRIAGE.md" ] && breakdown=$(cat "$WORKDIR/CLAUDE_TRIAGE.md")
|
||||||
|
type=""
|
||||||
|
if [ -f "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt" ]; then
|
||||||
|
traw=$(cat "$WORKDIR/CLAUDE_TRIAGE_TYPE.txt")
|
||||||
|
if printf '%s' "$traw" | grep -qiw feature; then type=feature
|
||||||
|
elif printf '%s' "$traw" | grep -qiw bug; then type=bug; fi
|
||||||
|
fi
|
||||||
|
reset_clone
|
||||||
|
|
||||||
|
# Classify bug/feature regardless of routing outcome (additive, never clears).
|
||||||
|
[ -n "$type" ] && { add_labels "$num" "$type"; log "Classified #$num as $type"; }
|
||||||
|
|
||||||
|
if [ -z "$label" ]; then
|
||||||
|
log "Triage for #$num produced no valid decision; leaving for a human"
|
||||||
|
# Mark triaged + strip any button-stamped claude-queue so it is NOT auto-fixed.
|
||||||
|
set_labels "$num" "claude-queue" "triaged"
|
||||||
|
add_comment "$num" "$BOT_MARKER
|
||||||
|
[Claude triage] Could not auto-triage this issue. A human should review it and add either \`claude-queue\` or \`interactive\`."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Label FIRST so a comment failure cannot trigger a re-triage that double-posts.
|
||||||
|
# Mark `triaged` so triage won't re-claim it. For interactive, strip any
|
||||||
|
# claude-queue the Report Issue button may have stamped so the fix phase ignores it.
|
||||||
|
if [ "$label" = "interactive" ]; then
|
||||||
|
set_labels "$num" "claude-queue" "interactive triaged"
|
||||||
|
else
|
||||||
|
add_labels "$num" claude-queue triaged
|
||||||
|
fi
|
||||||
|
# No bot marker on the breakdown: it is genuine refined requirements and SHOULD
|
||||||
|
# be fed to the fix stage (comments_block includes it).
|
||||||
|
note=${breakdown:-"(no breakdown produced)"}
|
||||||
|
add_comment "$num" "## Claude triage
|
||||||
|
|
||||||
|
$note
|
||||||
|
|
||||||
|
**Routing:** \`$label\`${type:+ | **Type:** \`$type\`}"
|
||||||
|
log "Triaged #$num -> $label"
|
||||||
|
done
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase 2: fix queued issues
|
||||||
|
# =====================================================================
|
||||||
|
queued=$(issues_by_label claude-queue | jq -c "sort_by(.number) | .[:$MAX_FIX]")
|
||||||
|
n_fix=$(printf '%s' "$queued" | jq 'length')
|
||||||
|
if [ "$n_fix" -eq 0 ]; then
|
||||||
|
log "No queued issues to fix."
|
||||||
|
else
|
||||||
|
log "Found $n_fix queued issue(s) to fix: $(printf '%s' "$queued" | jq -r '[.[].number|"#\(.)"]|join(", ")')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
f=0
|
||||||
|
while [ "$f" -lt "$n_fix" ]; do
|
||||||
|
issue=$(printf '%s' "$queued" | jq -c ".[$f]")
|
||||||
|
f=$((f+1))
|
||||||
|
num=$(printf '%s' "$issue" | jq -r .number)
|
||||||
|
title=$(printf '%s' "$issue" | jq -r .title)
|
||||||
|
body=$(printf '%s' "$issue" | jq -r '.body // ""')
|
||||||
|
branch="${BRANCH_PREFIX}${num}"
|
||||||
|
log "-- Working issue #$num: $title"
|
||||||
|
|
||||||
|
set_labels "$num" "claude-queue claude-failed" "claude-working"
|
||||||
|
add_comment "$num" "$BOT_MARKER
|
||||||
|
[Claude] Started working on this issue on branch \`$branch\`."
|
||||||
|
|
||||||
|
git -C "$WORKDIR" fetch origin -q
|
||||||
|
if ! git -C "$WORKDIR" checkout -B "$branch" "origin/$BASE_BRANCH" -q 2>>"$LOG_FILE"; then
|
||||||
|
log "checkout failed for #$num"; continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
comments=$(comments_block "$num")
|
||||||
|
[ -n "$comments" ] && log "Including human comment(s) for #$num"
|
||||||
|
|
||||||
|
prompt_file=$(mktemp)
|
||||||
|
{
|
||||||
|
printf '%s\n' "You are working autonomously on issue #$num of the Pelagia Portal (PPMS), a Next.js 15"
|
||||||
|
printf '%s\n' "purchase-order management system. The web app lives in App/ -- read App/CLAUDE.md first."
|
||||||
|
printf '\n## Issue #%s: %s\n\n' "$num" "$title"
|
||||||
|
printf '%s\n\n' "$body"
|
||||||
|
printf '%s\n\n' "$comments"
|
||||||
|
printf '%s\n' "## Test environment available to you"
|
||||||
|
printf '%s\n' "- App/.env points DATABASE_URL at a TEST database (pelagia_test) -- a daily mirror of"
|
||||||
|
printf '%s\n' " production, safe to read and write. It is NOT production. Email is console-logged and"
|
||||||
|
printf '%s\n' " storage is local in this dev mode (no real emails/uploads)."
|
||||||
|
printf '%s\n' "- To run integration tests against it, load the env first:"
|
||||||
|
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
|
||||||
|
printf '%s\n' "- If you need runtime verification, you MAY start a dev server ON PORT 3100 ONLY:"
|
||||||
|
printf '%s\n' " cd App && pnpm dev -p 3100 (production runs on 3000 -- NEVER touch 3000)"
|
||||||
|
printf '%s\n' " When done, stop ONLY your own server by port: 'fuser -k 3100/tcp' (or kill its exact PID)."
|
||||||
|
printf '%s\n' " NEVER use a broad 'pkill -f next' -- it would kill the production app."
|
||||||
|
printf '%s\n' "- Never connect to or modify the production database or the production app."
|
||||||
|
printf '%s\n' ""
|
||||||
|
printf '%s\n' "## Your job (PR policy: every code change ships with tests + docs)"
|
||||||
|
printf '%s\n' "1. Investigate the issue and implement a focused, minimal fix in this repository."
|
||||||
|
printf '%s\n' "2. REQUIRED: add or update a test that fails before your fix and passes after. Model it on"
|
||||||
|
printf '%s\n' " App/tests/integration/dashboard-approved-this-month.test.ts (from issue #12): target the"
|
||||||
|
printf '%s\n' " prod-mirror test DB, anchor on existing rows (findFirstOrThrow), insert fixtures via raw"
|
||||||
|
printf '%s\n' " SQL with a unique prefix, and clean them up in afterEach. The PR check REJECTS code"
|
||||||
|
printf '%s\n' " changes under App/app|lib|components|hooks with no test change."
|
||||||
|
printf '%s\n' "3. Verify: 'pnpm type-check' (no new app-code errors) and run your test against the test DB:"
|
||||||
|
printf '%s\n' " cd App && set -a && . ./.env && set +a && pnpm test:integration"
|
||||||
|
printf '%s\n' "4. REQUIRED: update any docs the change affects (App/README.md, App/CLAUDE.md, Docs/,"
|
||||||
|
printf '%s\n' " CHANGELOG.md) — skip only if nothing documented is affected."
|
||||||
|
printf '%s\n' "5. Commit ALL changes (fix + test + docs) to the current branch with a conventional message"
|
||||||
|
printf '%s\n' " ending: Fixes #$num"
|
||||||
|
printf '%s\n' "6. Do NOT push, do NOT create tags, do NOT switch branches. The supervisor handles push and PR."
|
||||||
|
printf '%s\n' "If the issue is unclear, too risky (migrations, payments, permissions), or you cannot verify"
|
||||||
|
printf '%s\n' "the fix, make NO commits and write a short explanation to CLAUDE_RESULT.md in the repo root."
|
||||||
|
} > "$prompt_file"
|
||||||
|
|
||||||
|
clog="$LOG_DIR/claude-issue-$num-$(date +%Y%m%d-%H%M%S).log"
|
||||||
|
log "Running Claude Code on #$num (log: $clog)"
|
||||||
|
run_claude "$prompt_file" "$clog" "$FIX_TURNS"; rc=$?
|
||||||
|
log "Claude exited with code $rc for #$num"
|
||||||
|
rm -f "$prompt_file"
|
||||||
|
|
||||||
|
abort_note=""
|
||||||
|
if [ -f "$WORKDIR/CLAUDE_RESULT.md" ]; then
|
||||||
|
abort_note=$(cat "$WORKDIR/CLAUDE_RESULT.md")
|
||||||
|
rm -f "$WORKDIR/CLAUDE_RESULT.md"
|
||||||
|
git -C "$WORKDIR" checkout -- . 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
commits=$(git -C "$WORKDIR" rev-list "origin/$BASE_BRANCH..HEAD" --count)
|
||||||
|
if [ "$commits" -gt 0 ]; then
|
||||||
|
log "Claude made $commits commit(s); pushing $branch"
|
||||||
|
if ! git -C "$WORKDIR" push -f -u origin "$branch" -q 2>>"$LOG_FILE"; then
|
||||||
|
log "push failed for #$num"; set_labels "$num" "claude-working" "claude-failed"; continue
|
||||||
|
fi
|
||||||
|
pr_title="fix: $(printf '%s' "$title" | sed 's/^\[Issue\]: //')"
|
||||||
|
pr_body="Automated fix by Claude Code for #$num.
|
||||||
|
|
||||||
|
Closes #$num
|
||||||
|
|
||||||
|
Review, merge, then create a release tag (vX.Y.Z) to deploy."
|
||||||
|
pr=$(api POST "/repos/$REPO/pulls" "$(jq -nc --arg base "$BASE_BRANCH" --arg head "$branch" --arg t "$pr_title" --arg b "$pr_body" '{base:$base,head:$head,title:$t,body:$b}')")
|
||||||
|
prnum=$(printf '%s' "$pr" | jq -r .number)
|
||||||
|
prurl=$(printf '%s' "$pr" | jq -r .html_url)
|
||||||
|
set_labels "$num" "claude-working" "claude-pr"
|
||||||
|
add_comment "$num" "$BOT_MARKER
|
||||||
|
[Claude] Opened PR [#$prnum]($prurl) with a proposed fix. Review and merge it, then create a release tag to deploy."
|
||||||
|
log "PR #$prnum opened for issue #$num"
|
||||||
|
else
|
||||||
|
log "No commits produced for #$num; marking claude-failed"
|
||||||
|
set_labels "$num" "claude-working" "claude-failed"
|
||||||
|
reason=${abort_note:-"Claude did not produce a verified fix. See watcher logs on pms1: $clog"}
|
||||||
|
add_comment "$num" "$BOT_MARKER
|
||||||
|
[Claude] Automated fix attempt did not produce a change.
|
||||||
|
|
||||||
|
$reason"
|
||||||
|
fi
|
||||||
|
done
|
||||||
65
automation/refresh-test-db.sh
Normal file
65
automation/refresh-test-db.sh
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refresh the test database from production. Runs daily via cron on pms1.
|
||||||
|
#
|
||||||
|
# pelagia_test is a throwaway mirror of prod (pelagia) so the autofix Claude can
|
||||||
|
# run integration tests / a dev server against realistic data WITHOUT touching
|
||||||
|
# production. The test DB is owned by pelagia_user (created once as superuser);
|
||||||
|
# this refresh runs purely as pelagia_user using the prod connection string.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${1:-/home/shad0w/pms/App/.env}"
|
||||||
|
PROD_DB="pelagia"
|
||||||
|
TEST_DB="pelagia_test"
|
||||||
|
|
||||||
|
log() { echo "$(date '+%F %T') $*"; }
|
||||||
|
|
||||||
|
PROD_URL=$(grep -E '^DATABASE_URL' "$ENV_FILE" | sed -E 's/^DATABASE_URL=//; s/^"//; s/"$//')
|
||||||
|
[ -n "$PROD_URL" ] || { log "ERROR: no DATABASE_URL in $ENV_FILE"; exit 1; }
|
||||||
|
|
||||||
|
# Derive the test URL by swapping ONLY the database-name path segment (anchored on
|
||||||
|
# @host/ so the 'pelagia' inside the username is never touched).
|
||||||
|
TEST_URL=$(printf '%s' "$PROD_URL" | sed -E "s#(@[^/]+/)$PROD_DB([?]|\$)#\1$TEST_DB\2#")
|
||||||
|
|
||||||
|
if [ "$TEST_URL" = "$PROD_URL" ]; then
|
||||||
|
log "ERROR: failed to derive test URL (db name not found in connection string)"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Refreshing $TEST_DB from $PROD_DB ..."
|
||||||
|
# --clean --if-exists drops+recreates each object in place (first run on an empty
|
||||||
|
# DB is a no-op for the DROPs). --no-owner/--no-privileges keep it portable.
|
||||||
|
errfile=$(mktemp)
|
||||||
|
pg_dump --clean --if-exists --no-owner --no-privileges "$PROD_URL" \
|
||||||
|
| psql "$TEST_URL" >/dev/null 2>"$errfile"
|
||||||
|
|
||||||
|
prod_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" "$PROD_URL")
|
||||||
|
test_tables=$(psql -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';" "$TEST_URL")
|
||||||
|
|
||||||
|
if [ "$test_tables" = "$prod_tables" ] && [ "$test_tables" -gt 0 ]; then
|
||||||
|
log "Data copied. $TEST_DB has $test_tables public tables (prod has $prod_tables)."
|
||||||
|
rm -f "$errfile"
|
||||||
|
else
|
||||||
|
log "WARNING: table counts differ (test=$test_tables prod=$prod_tables). Recent errors:"
|
||||||
|
tail -8 "$errfile"
|
||||||
|
rm -f "$errfile"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The test DB now has PROD's schema, which may be behind master. Apply master's
|
||||||
|
# unreleased migrations so the code under test (staging + autofix) doesn't 500 on
|
||||||
|
# columns prod doesn't have yet (e.g. poDate). Uses a stable master checkout.
|
||||||
|
MIG_DIR=""
|
||||||
|
for d in "$HOME/pelagia-staging/App" "$HOME/pelagia-autofix/App"; do
|
||||||
|
[ -d "$d/prisma/migrations" ] && { MIG_DIR="$d"; break; }
|
||||||
|
done
|
||||||
|
if [ -n "$MIG_DIR" ]; then
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh" 2>/dev/null || true
|
||||||
|
log "Applying master migrations from $MIG_DIR ..."
|
||||||
|
if ( cd "$MIG_DIR" && DATABASE_URL="$TEST_URL" pnpm db:migrate:deploy ) >/tmp/migrate-test-db.log 2>&1; then
|
||||||
|
log "Migrations applied."
|
||||||
|
else
|
||||||
|
log "WARNING: migrate deploy failed; see /tmp/migrate-test-db.log"; tail -5 /tmp/migrate-test-db.log
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "No master checkout with migrations found; skipping migrate (test DB has prod schema only)."
|
||||||
|
fi
|
||||||
15
automation/staging-tunnel.cmd
Normal file
15
automation/staging-tunnel.cmd
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
@echo off
|
||||||
|
title Pelagia Staging Tunnel (localhost:3200)
|
||||||
|
echo ============================================================
|
||||||
|
echo Pelagia Portal - STAGING (internal dev only)
|
||||||
|
echo Tunneling pms1 port 3200 to http://localhost:3200
|
||||||
|
echo Keep this window OPEN while testing. Close it to disconnect.
|
||||||
|
echo ============================================================
|
||||||
|
echo.
|
||||||
|
echo Connecting... your browser will open in a few seconds.
|
||||||
|
REM Open the browser shortly after the tunnel comes up.
|
||||||
|
start "" cmd /c "ping -n 6 127.0.0.1 >nul & explorer http://localhost:3200"
|
||||||
|
ssh -i "%USERPROFILE%\.ssh\peliagia_portal_ubuntu22_ed25519" -o StrictHostKeyChecking=accept-new -N -L 3200:localhost:3200 shad0w@87.76.191.133
|
||||||
|
echo.
|
||||||
|
echo Tunnel closed. You can close this window.
|
||||||
|
pause
|
||||||
80
automation/staging-up.sh
Normal file
80
automation/staging-up.sh
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bring up / refresh the pms1 STAGING instance with the latest master, for smoke
|
||||||
|
# testing before tagging a release. Runs the app as pm2 process `ppms-staging` on
|
||||||
|
# port 3200, against the prod-mirror test DB (pelagia_test), in safe dev mode
|
||||||
|
# (no real emails/uploads). Separate from ~/pms (prod) and ~/pelagia-autofix.
|
||||||
|
#
|
||||||
|
# Usage on pms1: ~/issue-watcher/staging-up.sh
|
||||||
|
# Re-run any time to pull latest master and restart staging.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
DIR="$HOME/pelagia-staging"
|
||||||
|
PORT=3200
|
||||||
|
NAME="ppms-staging"
|
||||||
|
REPO_URL="http://127.0.0.1:3001/shad0w/pelagia-portal.git"
|
||||||
|
|
||||||
|
if [ ! -d "$DIR/.git" ]; then
|
||||||
|
echo "Cloning into $DIR ..."
|
||||||
|
git clone -q "$REPO_URL" "$DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$DIR"
|
||||||
|
git fetch origin -q
|
||||||
|
git checkout -f master -q 2>/dev/null || git checkout -fB master origin/master -q
|
||||||
|
git reset --hard origin/master
|
||||||
|
echo "Staging checkout: $(git log --oneline -1)"
|
||||||
|
|
||||||
|
# One-time env: test DB, dev mode, port 3200.
|
||||||
|
if [ ! -f "$DIR/App/.env" ]; then
|
||||||
|
PROD_URL=$(grep -E '^DATABASE_URL' "$HOME/pms/App/.env" | sed -E 's/^DATABASE_URL=//; s/^"//; s/"$//')
|
||||||
|
TEST_URL=$(printf '%s' "$PROD_URL" | sed -E "s#(@[^/]+/)pelagia([?]|\$)#\1pelagia_test\2#")
|
||||||
|
INV=$(grep -E '^NEXT_PUBLIC_INVENTORY_ENABLED' "$HOME/pms/App/.env" || echo 'NEXT_PUBLIC_INVENTORY_ENABLED=true')
|
||||||
|
SECRET=$(openssl rand -base64 32)
|
||||||
|
cat > "$DIR/App/.env" <<EOF
|
||||||
|
# pms1 STAGING -- latest master against the prod-mirror test DB, safe dev mode.
|
||||||
|
$INV
|
||||||
|
NEXTAUTH_SECRET="$SECRET"
|
||||||
|
NEXTAUTH_URL="http://localhost:$PORT"
|
||||||
|
AZURE_AD_CLIENT_ID="dev-placeholder"
|
||||||
|
AZURE_AD_CLIENT_SECRET="dev-placeholder"
|
||||||
|
AZURE_AD_TENANT_ID="dev-placeholder"
|
||||||
|
DATABASE_URL="$TEST_URL"
|
||||||
|
GST_SERVICE_URL="http://localhost:3003"
|
||||||
|
NEXT_PUBLIC_ENV_LABEL="INTERNAL DEV / STAGING - NOT PRODUCTION"
|
||||||
|
PORT=$PORT
|
||||||
|
EOF
|
||||||
|
chmod 600 "$DIR/App/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pm2-run wrapper so the dev server always gets nvm on PATH and the right port.
|
||||||
|
# Bind to 127.0.0.1 only -- staging is reachable solely via SSH tunnel
|
||||||
|
# (ssh -L 3200:localhost:3200 ...), never directly from the public internet.
|
||||||
|
cat > "$DIR/App/run-staging.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
export NVM_DIR="\$HOME/.nvm"; . "\$NVM_DIR/nvm.sh"
|
||||||
|
cd "$DIR/App"
|
||||||
|
exec pnpm dev -p $PORT -H 127.0.0.1
|
||||||
|
EOF
|
||||||
|
chmod +x "$DIR/App/run-staging.sh"
|
||||||
|
|
||||||
|
cd "$DIR/App"
|
||||||
|
echo "Installing deps..."; pnpm install --frozen-lockfile
|
||||||
|
echo "Generating Prisma client..."; pnpm db:generate
|
||||||
|
# Bring the test DB schema up to the code under test. The test DB mirrors prod,
|
||||||
|
# which may be behind master, so master's unreleased migrations (e.g. poDate)
|
||||||
|
# must be applied or the new code 500s on the missing columns.
|
||||||
|
echo "Applying pending migrations to the test DB..."; pnpm db:migrate:deploy
|
||||||
|
|
||||||
|
# Drop any FORGEJO_* the caller may carry (e.g. when invoked from the Forgejo
|
||||||
|
# Actions runner, whose ephemeral FORGEJO_TOKEN would otherwise be injected into
|
||||||
|
# the staging process). NOT --update-env on restart, for the same reason.
|
||||||
|
for v in $(env | grep -oE '^FORGEJO_[A-Z_]+' || true); do unset "$v"; done
|
||||||
|
if pm2 describe "$NAME" >/dev/null 2>&1; then
|
||||||
|
pm2 restart "$NAME"
|
||||||
|
else
|
||||||
|
pm2 start "$DIR/App/run-staging.sh" --name "$NAME" --interpreter bash
|
||||||
|
fi
|
||||||
|
pm2 save
|
||||||
|
echo "Staging '$NAME' is up on port $PORT ($(git -C "$DIR" log --oneline -1))"
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
"baseBranch": "master",
|
"baseBranch": "master",
|
||||||
"branchPrefix": "claude/issue-",
|
"branchPrefix": "claude/issue-",
|
||||||
"maxIssuesPerRun": 1,
|
"maxIssuesPerRun": 1,
|
||||||
|
"maxTriagePerRun": 3,
|
||||||
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
|
"claudeExe": "C:\\Users\\shad0w\\.local\\bin\\claude.exe",
|
||||||
"claudeMaxTurns": 150
|
"claudeMaxTurns": 150,
|
||||||
|
"triageMaxTurns": 80
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue