Compare commits

...
Sign in to create a new pull request.

57 commits

Author SHA1 Message Date
fbdc7b2235 Merge pull request 'docs(release): make v-prefixed tag requirement prominent' (#52) from docs/release-tag-warning into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #52
2026-06-20 21:15:43 +00:00
8ee077e548 Merge branch 'master' into docs/release-tag-warning
All checks were successful
PR checks / checks (pull_request) Successful in 33s
2026-06-20 21:14:46 +00:00
d9394e6afb docs(release): make the v-prefixed tag requirement prominent
All checks were successful
PR checks / checks (pull_request) Successful in 31s
deploy.yml only triggers on v* tags; bare semver tags (0.2.0/0.2.1/0.2.2) silently
do not deploy. Clarify: push the v* tag specifically (not 'master --tags').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:09:18 +05:30
4712fafb4b Merge pull request 'feat(companies): move add/edit from dialog to dedicated pages' (#49) from feat/company-form-pages into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m10s
Reviewed-on: #49
2026-06-20 20:38:10 +00:00
e388ec917e Merge branch 'master' into feat/company-form-pages
All checks were successful
PR checks / checks (pull_request) Successful in 31s
2026-06-20 20:37:37 +00:00
9d08ca1990 Merge pull request 'fix: On manager dashboard, overhaul approved spend card' (#51) from claude/issue-50 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #51
2026-06-20 20:35:53 +00:00
6137d11e5f test(companies): cover createCompany/updateCompany actions
All checks were successful
PR checks / checks (pull_request) Successful in 32s
Satisfies the contribution-policy test gate for the add/edit-page refactor.
Covers: createCompany returns the new id, code upper-casing, duplicate-code
rejection, updateCompany in-place edit, and manage_vessels_accounts gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 02:05:32 +05:30
Claude (auto-fix)
defd6e7a18 feat(dashboard): compact INR formatting for Total Approved Spend card
All checks were successful
PR checks / checks (pull_request) Successful in 31s
Overhaul the manager dashboard "Total Approved Spend" stat card per the
reporter's request:

- Swap the DollarSign lucide icon for IndianRupee (rupee symbol).
- Render the amount in the Indian short scale (lakh/crore) via a new
  `formatCompactINR` helper, e.g. ₹2 Cr, ₹49 L, ₹75 K, instead of the full
  ₹49,00,000.00.

`formatCompactINR` rounds to at most 2 decimals, trims trailing zeros, keeps
the ₹ prefix and sign. The DollarSign icon is retained for the Accounts
"Payment Queue Value" card; the precise `formatCurrency` is kept for tables.
Adds unit tests covering crore/lakh/thousand/sub-thousand, boundaries, zero,
string input, negatives and non-finite input.

Fixes #50

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 02:02:41 +05:30
bad67f66c4 feat(companies): move add/edit from dialog to dedicated pages
Some checks failed
PR checks / checks (pull_request) Failing after 3s
The company form outgrew the modal once the branding (logo/stamp) section
was added. Add/edit now live on their own routes:
- /admin/companies/new
- /admin/companies/[id]/edit

- createCompany returns the new id and the create flow lands on the edit
  page so logo/stamp can be uploaded immediately
- list "+ Add Company" is a link; row "Edit" navigates to the edit page
- branding is its own card on the edit page (independent uploads)
- list page no longer mints a presigned URL per company (moved to edit)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:45:59 +05:30
74d20cd452 Merge pull request 'feat(po): per-company logo, stamp & brand bar on exported POs' (#48) from feat/company-header-logo-branding into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #48
2026-06-20 20:01:46 +00:00
467f0ddea4 Merge branch 'master' into feat/company-header-logo-branding
All checks were successful
PR checks / checks (pull_request) Successful in 32s
2026-06-20 20:01:32 +00:00
64fefd15a8 Merge pull request 'feat(staging): auto-refresh staging on every push to master' (#46) from feat/staging-auto-refresh into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #46
2026-06-20 19:55:12 +00:00
1071cb226f feat(po): per-company logo, stamp & brand bar on exported POs
All checks were successful
PR checks / checks (pull_request) Successful in 31s
Companies can upload a logo and a stamp/seal (Admin → Companies → Edit →
Branding); both render on exported PDF and XLSX purchase orders. A fixed
brand-colour bar (#92D050, matching the sample PO) runs along the bottom of
every export.

- Company.logoKey / stampKey + migration
- buildCompanyAssetKey() deterministic storage keys (overwrite-in-place)
- uploadCompanyAsset / removeCompanyAsset server actions (≤4MB PNG/JPG/WebP,
  manage_vessels_accounts gated)
- CompanyBrandingUploader in the company edit dialog with live previews
- Export route embeds logo (top-left), stamp (signatory block) and brand bar
  in both ExcelJS and print-HTML paths
- Unit test (storage keys) + integration test (branding actions)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:17:23 +05:30
a0c6ccba3c Merge branch 'master' into feat/staging-auto-refresh
All checks were successful
PR checks / checks (pull_request) Successful in 30s
2026-06-20 19:39:45 +00:00
9f8297aa7e feat(staging): auto-refresh staging on every push to master
All checks were successful
PR checks / checks (pull_request) Successful in 30s
New .forgejo/workflows/staging.yml rebuilds ppms-staging to latest master on every
merge (push to master) on the host runner, so staging always mirrors the trunk;
concurrency-coalesced + workflow_dispatch. Also drops --update-env from staging-up.sh
(and unsets FORGEJO_*) so the runner's ephemeral token can't leak into ppms-staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 01:07:49 +05:30
783051933a Merge pull request 'fix: Add a PO line items units entry for months and year' (#45) from claude/issue-44 into master
Reviewed-on: #45
2026-06-20 19:05:08 +00:00
Claude (auto-fix)
e9e618fda8 feat(po): add week, month, year to line-item unit options
All checks were successful
PR checks / checks (pull_request) Successful in 31s
The PO line-items Unit of Measure dropdown only offered hr/day among
time-based units. Add week, month and year so durations beyond days can
be selected, as requested. UOM_OPTIONS is the single source of truth and
`unit` is validated as a free-form string, so no schema/validation change
is needed.

Fixes #44

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:32:39 +05:30
5bb3549142 Merge pull request 'fix(deploy): don't inject CI runner token into ppms' (#43) from fix/deploy-no-update-env into master
Reviewed-on: #43
2026-06-20 18:29:07 +00:00
2d6681014d fix(deploy): don't inject the CI runner token into ppms (drop --update-env)
All checks were successful
PR checks / checks (pull_request) Successful in 31s
The deploy job runs inside the Forgejo Actions runner, whose env includes an
ephemeral FORGEJO_TOKEN (per-job token, revoked when the job ends). 'pm2 restart
--update-env' injected it into ppms, where it shadowed the real PAT in .env
(Next.js won't override an already-set process.env var) — so the Report Issue
button 401'd once the job token expired. Plain restart keeps the daemon's clean env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:57:01 +05:30
1feb43186d Merge pull request 'fix(automation): triage owns routing for every portal issue' (#39) from fix/triage-owns-portal-routing into master
All checks were successful
PR checks / checks (pull_request) Successful in 30s
Reviewed-on: #39
2026-06-19 08:45:21 +00:00
794bbf8e7e Merge branch 'master' into fix/triage-owns-portal-routing
All checks were successful
PR checks / checks (pull_request) Successful in 31s
2026-06-19 08:41:45 +00:00
520b1527e0 feat(automation): triage classifies bug vs feature
Triage now writes CLAUDE_TRIAGE_TYPE.txt (bug|feature) and the watcher applies the
matching label to every triaged issue (additive). Previously bug/feature labels were
never applied by the pipeline. Also shows the type in the triage comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:10:48 +05:30
aec6d2971f fix(automation): triage owns routing for every portal issue
The Report Issue button (older deployed build) stamps claude-queue at creation, so
triage skipped those issues and they went straight to auto-fix (e.g. #37, a large
localization feature that should be interactive).

Triage now claims a portal issue until it carries a new `triaged` marker (or is
in progress/done) — claude-queue is no longer a skip reason. On routing to
interactive it strips the stray claude-queue; on claude-queue it adds triaged.
Manual queue still works for NON-portal issues (triage never claims those).

Resilient regardless of which button build is deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:00:44 +05:30
064e4ebf66 Merge pull request 'docs: retire Docs/ to the project wiki' (#38) from docs/retire-docs-to-wiki into master
Reviewed-on: #38
2026-06-19 08:29:01 +00:00
56a947747c @
docs: retire Docs/ to the project wiki

The design, architecture, and test docs under Docs/ have been migrated to the
Forgejo wiki (the living reference). Remove them here and leave a tombstone
Docs/README.md mapping each old file to its wiki page.

Also gitignore the nested wiki working clone (pelagia-portal.wiki/), which is a
separate repo checked out beside this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
2026-06-19 13:56:50 +05:30
0fe043e833 Merge pull request 'ci: PR policy enforcement (tests + docs) and checks' (#36) from ci/pr-checks into master
Reviewed-on: #36
2026-06-19 07:37:39 +00:00
8406397602 Merge branch 'master' into ci/pr-checks
All checks were successful
PR checks / checks (pull_request) Successful in 30s
2026-06-19 07:36:28 +00:00
938ff6df89 test+ci: green the test baseline and make type-check + unit tests hard gates
All checks were successful
PR checks / checks (pull_request) Successful in 30s
Green-lights the test suite so the PR checks can enforce it:
- Fix the NextAuth v5 auth() mock typing across all integration tests (cast to a
  simple async fn so mockResolvedValue accepts the session) — clears ~86 errors.
- Fix stale test values: intent 'resubmit'->'submit' / 'save'->'draft'; ParsedImportLine
  .description -> .name; approvepo -> approvePo; add missing beforeEach/beforeAll imports.
- permissions: MANAGER *can* process_payment (intentional since e1340b9) — update the
  stale assertion.
- po-import-parser: skip the Sample_PO.xlsx fixture tests when the file is absent (it
  lives outside the repo); synthetic-workbook tests still cover the parser.

type-check is now 0 errors and unit tests pass (167 passed, 13 skipped). pr-checks.yml
flips type-check (whole project) and unit tests to HARD gates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 13:03:54 +05:30
debac55a8a ci: enforce PR policy (tests-present + app type-check) and PR template
All checks were successful
PR checks / checks (pull_request) Successful in 39s
All changes now land via PR. New .forgejo/workflows/pr-checks.yml runs on every PR
to master and (1) fails code PRs that lack a test change, (2) blocks new app-code type
errors. Unit tests are advisory until the baseline is green; lint is omitted (it needs
an interactive ESLint migration). PR template carries the docs/tests checklist.

Also makes the autofix watcher require a test (issue-12 style) + doc updates in every
fix, so its PRs satisfy the new gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:49:32 +05:30
8a614878d2 Merge pull request 'docs: bring product docs up to date' (#35) from docs/sync-product-docs into master
Reviewed-on: #35
2026-06-19 07:17:36 +00:00
b0140cf7e1 Merge branch 'master' into docs/sync-product-docs 2026-06-19 07:17:23 +00:00
58a5a00594 docs: bring CLAUDE.md, README, Docs and CHANGELOG up to date with current product
Reflects this iteration's domain/feature changes across the docs set:
- Cost centre = Vessel only (labelled 'Cost Centre'); costCentreRef/Site removed
- Companies (multi-company invoicing) on POs and exports
- 3-level 6-digit accounting-code hierarchy; leaf-only PO selection
- Structured PO numbers COMPANY/VESSEL/ID/FY (ids from 9000)
- Compulsory payment date; editable poDate; export date = approval date
- Submitter vendor creation (unverified until proven); verifyVendor
- Import PO -> CLOSED with auto vendor/product creation
- Inventory flag; inventory added at approval; partial pay/receipt states
- Microsoft Entra SSO (nullable passwordHash); profile reachable by all roles
- README: roles, domain concepts, db:seed:prod, migrate-before-serve callout
- CHANGELOG: Added/Changed/Fixed for the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:43:24 +05:30
157b58daf5 Merge pull request 'fix: Approved this month for manager only shows POs that are currently in Approved state' (#34) from claude/issue-32 into master
Reviewed-on: #34
2026-06-19 07:10:18 +00:00
791e99f3fd Merge commit '859be8c8d0' into fix-pr34
# Conflicts:
#	App/app/(portal)/history/history-filters.tsx
2026-06-19 12:35:35 +05:30
859be8c8d0 Merge pull request 'fix: Suggestion - allow multiple statuses in the search for PO history' (#33) from claude/issue-31 into master
Reviewed-on: #33
2026-06-19 07:00:31 +00:00
b3e6f6181a Merge branch 'master' into claude/issue-31 2026-06-19 06:59:39 +00:00
7713601be7 Merge pull request 'fix: PO details: show all attachments, grouped by type' (#27) from claude/issue-10 into master
Reviewed-on: #27
2026-06-19 06:57:08 +00:00
f17df1ec6b docs: update design docs to the actual self-hosted architecture
The original design docs assumed Vercel + Supabase + GitHub Actions. Reality is a
single self-hosted pms1 server (Next.js under pm2, native PostgreSQL 16, Forgejo
Actions runner, Pangolin/Traefik tunnel).

- 02-architecture.md: CI/CD + Hosting rows, deployment diagram (section 10),
  CI/testing note, branch strategy, and secrets location.
- e2e-test-plan.md / e2e-test-framework.md: GitHub Actions -> Forgejo Actions.
- 03-open-questions.md: drop the Vercel-serverless aside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:12:04 +05:30
e31014d45c docs: document the issue-to-deploy pipeline, staging, and test DB
- App/README.md: add FORGEJO_*/NEXT_PUBLIC_ENV_LABEL env vars and an
  'Operations & Automation' section pointing to automation/README.md.
- App/CLAUDE.md: complete the env var list (AZURE_AD_*, FORGEJO_*, GST_SERVICE_URL,
  NEXT_PUBLIC_ENV_LABEL) and note the prod-mirror test DB used by autofix/staging.
- .env.example: document NEXT_PUBLIC_ENV_LABEL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:07:55 +05:30
Claude (auto-fix)
fdc3ebdac9 fix(dashboard): count all POs approved this month, not just current MGR_APPROVED
The manager dashboard "Approved This Month" card only counted POs whose
current status is MGR_APPROVED, so approvals that had already moved on to
payment, delivery, or closure dropped out of the count. Managers could not
see what happened to the POs they approved this month.

- Count every PO whose `approvedAt` falls in the current month across all
  post-approval statuses (MGR_APPROVED → ... → CLOSED). `approvedAt` is set
  once at approval and persists, so it is the correct anchor.
- Introduce a shared `POST_APPROVAL_STATUSES` constant (includes the
  previously-omitted PARTIALLY_CLOSED). This also fixes Total Approved Spend
  and the vessel/monthly breakdowns, which were silently dropping
  partially-received POs.
- Make the card a link into /history with an approval-date filter applied
  (?approvedFrom=<startOfMonth>) so a click shows the full set with each PO's
  current status, as requested.
- Add `approvedFrom`/`approvedTo` filtering to the history page, its filter
  UI, and the reports export route so the deep-link and exports stay in sync.

Scope note: the count remains org-wide, consistent with every other card on
the manager dashboard.

Adds an integration test covering the moved-on case and the date window.

Fixes #32
2026-06-19 12:07:53 +05:30
b472c149b4 feat(automation): lock staging to SSH tunnel + dev banner + desktop shortcut
- staging-up.sh binds the dev server to 127.0.0.1 (tunnel-only, no public access)
  and sets NEXT_PUBLIC_ENV_LABEL so the 'INTERNAL DEV / STAGING - NOT PRODUCTION'
  banner shows.
- staging-tunnel.cmd: Windows launcher that opens the SSH tunnel + browser
  (wired to a desktop shortcut).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:59:25 +05:30
b592358db0 feat(app): env-gated banner (EnvBanner) for non-prod environments
Renders a thin fixed banner only when NEXT_PUBLIC_ENV_LABEL is set; production
leaves it unset so nothing shows. Used to mark the staging instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:56:34 +05:30
Claude (auto-fix)
e94c7f99a3 feat(history): allow filtering PO history by multiple statuses
The PO history page previously allowed only a single status filter. This
enhances it to accept multiple statuses that are OR-ed together (e.g.
Closed + Approved shows all POs in either state), as requested.

- Status filter is now a multi-select checkbox dropdown that serialises
  selections as repeated `status` query params.
- History page and the reports export endpoint read all `status` values
  and query with `status: { in: [...] }` (OR semantics).
- Single-status and no-status cases remain unchanged.

Verified OR-query semantics against the test DB and confirmed both routes
compile and respond. type-check passes for the changed files.

Fixes #31

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:53:42 +05:30
4da39fe5d1 fix(automation): apply master migrations to the test DB
The test DB mirrors prod, which can be behind master, so the latest code 500s on
columns prod doesn't have yet (e.g. poDate from the optional-PO-date feature).

- staging-up.sh runs prisma migrate deploy after install.
- refresh-test-db.sh re-applies master migrations after each nightly data copy,
  so the running staging/autofix DB stays at the schema of the code under test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:51:59 +05:30
7daf3091bc feat(automation): staging-up.sh for pre-deploy smoke testing on pms1
Brings up pm2 'ppms-staging' on port 3200 from the latest master, against the
prod-mirror test DB in safe dev mode. Re-run to refresh to newer master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:40:06 +05:30
12e6d16061 feat(automation): test DB mirror + dev-server env for autofix verification
- automation/refresh-test-db.sh: daily pg_dump of prod (pelagia) into a throwaway
  mirror (pelagia_test) on pms1; cron at 03:30. ~10MB, refresh ~1s.
- Autofix clone ~/pelagia-autofix/App/.env points DATABASE_URL at pelagia_test in
  safe dev mode (no Resend/SSO secrets -> console email, local storage), port 3100.
- Fix prompt: Claude may run integration tests against the test DB and start a dev
  server on port 3100 ONLY; stop it by port (fuser -k 3100/tcp), never broad pkill
  (production also runs a next-server on 3000).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:49:15 +05:30
Claude (auto-fix)
4e6175153d fix(po): show all attachments grouped by type on PO details
All PO attachments are stored as PODocument rows whose lifecycle stage
(submission vs delivery) is encoded in the storageKey prefix. The PO
details screen previously listed them in a single flat "Attachments"
block, giving no indication of which were submission documents (invoice,
quotation) versus delivery receipts.

Add lib/attachments.ts to derive a user-facing group from the storageKey
prefix (submission / payment / delivery / other) and render each
non-empty group as a labelled subsection on the PO details screen, in
lifecycle order. Unknown prefixes fall back to an "Other" group so
nothing is ever hidden.

Fixes #10
2026-06-19 04:43:44 +05:30
3e711a171c feat(automation): port issue watcher to bash for pms1 (cron, 24/7)
- automation/claude-issue-watcher.sh: Linux port of the watcher (curl + jq +
  flock). Same triage + fix phases. On Linux the PS 5.1 encoding/array quirks
  don't apply, so it's simpler.
- Auth preflight: no-ops until Claude Code is signed in on the host (or an
  ANTHROPIC_API_KEY is set), so cron can be enabled before sign-in.
- Runs on pms1 under cron every 10 min; Windows scheduled task is disabled so the
  two machines don't race the Forgejo queue.
- .gitattributes pins *.sh to LF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:32:06 +05:30
080dafb473 feat(automation): add triage phase to issue watcher
Portal issues now file with only the 'portal' label. The watcher runs two phases:
  1. Triage — Claude reads each untriaged 'portal' issue (analysis only), posts a
     requirements-breakdown comment, and routes it to 'claude-queue' (auto-fixable)
     or 'interactive' (needs human steering).
  2. Fix — unchanged; processes 'claude-queue' issues into PRs.

The triage breakdown is posted without the bot marker so the fix stage reads it
back as refined requirements.

PS 5.1 fixes found while validating:
  - Send API bodies as UTF-8 bytes (Invoke-RestMethod mangled non-ASCII, e.g. the
    em-dash in Claude's breakdown, so Forgejo rejected the JSON)
  - Build the labels array body by hand (ConvertTo-Json unwraps a single-element
    array to a scalar, which Forgejo rejects)
  - Triage output via two plain files (label + markdown) instead of one JSON blob
    (embedded-newline markdown broke ConvertFrom-Json)
  - Read triage files as UTF-8; additive label POST + a guard so Set-IssueLabels
    can never wipe an issue's labels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:20:21 +05:30
23e5243442 Merge pull request 'fix: Allow attachments (incl. delivery receipt) at delivery confirmation' (#25) from claude/issue-9 into master
Reviewed-on: #25
2026-06-18 22:34:37 +00:00
64634ccb5e Merge branch 'master' into claude/issue-9 2026-06-18 22:34:18 +00:00
69901ba079 Merge pull request 'fix: Exported PO must include line item optional description' (#23) from claude/issue-8 into master
Reviewed-on: #23
2026-06-18 22:32:27 +00:00
Claude (auto-fix)
9adc93e54a fix(receipt): upsert Receipt record on repeat confirmations with notes
Partial-receipt flows call confirmReceipt multiple times. The nested
`create` on the Receipt relation threw a unique-constraint error on the
second call when both confirmations supplied notes, preventing any
delivery from completing and blocking attachment uploads.

Changed to `upsert` so subsequent confirmations update the existing
Receipt row's notes instead of failing.

Adds integration tests covering full receipt, partial receipt, the
upsert scenario (two confirmations each with notes), and permission guards.

Fixes #9
2026-06-19 04:01:26 +05:30
Claude (auto-fix)
d7be141589 fix(export): include optional line item description in PDF and XLSX exports
The POLineItem model has both a required `name` and an optional `description`
field. The export was only rendering `name` (with description as a fallback),
dropping the optional description entirely when a name was present.

- PDF: renders description in smaller italic text below the item name
- XLSX: appends description on a new line within the cell (wrapText enabled)
- Row height in XLSX now accounts for the extra line when description exists

Fixes #8

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 03:43:49 +05:30
600f637de2 Merge pull request 'fix: Closed PO list filters are wrong for manager and submitter' (#21) from claude/issue-6 into master
Reviewed-on: #21
2026-06-18 22:10:25 +00:00
464475f62c Merge pull request 'fix: Approved POs should show approval date as the PO date (screen + export)' (#22) from claude/issue-5 into master
Reviewed-on: #22
2026-06-18 22:10:06 +00:00
Claude (auto-fix)
5d3b45a3a4 fix(po-detail): show approval date as PO date on detail screen
The PO date field was only displayed when a submitter explicitly set it.
For approved POs without an explicit date, the approval date is now shown.
Precedence: submitter-set poDate → approvedAt → createdAt (matches the
export route which already used this logic).

Fixes #5
2026-06-19 03:39:19 +05:30
66 changed files with 2675 additions and 3000 deletions

View 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.
-->

View file

@ -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

View 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

View 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
View file

@ -0,0 +1,2 @@
# Shell scripts must keep LF endings — they run on Linux (pms1).
*.sh text eol=lf

5
.gitignore vendored
View file

@ -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/

View file

@ -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"

View file

@ -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 (AprMar) 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.

View file

@ -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.

View 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,
}}
/>
);
}

View file

@ -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")) {

View file

@ -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">

View 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>
);
}

View file

@ -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&apos;ll be taken to the edit page where you can upload a logo and stamp.</p>
</> )}
</div>
</div>
); );
} }

View 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 />;
}

View file

@ -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 */}

View file

@ -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">

View file

@ -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>

View file

@ -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: {

View file

@ -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 &amp; Stamp</div> <div class="sig-sub">Authorized Signatory &amp; 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>`;

View file

@ -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,

View file

@ -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>
); );
} }

View 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>
);
}

View file

@ -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) {

View file

@ -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>
)} )}

View file

@ -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
View 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 }]
: [];
});
}

View file

@ -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.

View file

@ -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"

View file

@ -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;

View file

@ -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

View file

@ -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 });

View 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);
});
});

View 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" });
});
});

View 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" });
});
});

View 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");
});
});

View file

@ -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);

View file

@ -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 } });

View file

@ -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)", () => {

View file

@ -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 });
}); });

View file

@ -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");
}); });

View file

@ -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 () => {

View file

@ -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");

View 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"]);
});
});

View file

@ -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", () => {

View file

@ -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);

View file

@ -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) ────────────────────────────────────────────

View 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");
});
});

View file

@ -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"));

View file

@ -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).

View file

@ -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 (H1H3) | Inter | 600700 | 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?

View file

@ -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).

View file

@ -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 | — |

View file

@ -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 612 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 productvendor 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 productsite pair)
- `ItemConsumption`: daily draw-down record (one row per productsitedate)
---
## 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)

View file

@ -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
View 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`.

View file

@ -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`.

View file

@ -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.

View file

@ -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 1619 — 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

View file

@ -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

View file

@ -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'

View 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

View 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

View 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
View 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))"

View file

@ -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
} }