Compare commits

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

39 commits

Author SHA1 Message Date
fa0e004691 Merge pull request 'fix(automation): scan all Claude PRs for review comments; drop author-based bot filter' (#131) from claude/busy-boyd-b16092 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #131
2026-06-24 10:28:16 +00:00
2fa382185f Merge pull request 'docs: changelog + PdfService README (reports, email-to-vendor, microservices)' (#130) from docs/changelog-microservices into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #130
2026-06-24 10:27:26 +00:00
8b56c79f5f docs: changelog + PdfService README for reports, email-to-vendor, microservices
All checks were successful
PR checks / checks (pull_request) Successful in 48s
PR checks / integration (pull_request) Successful in 31s
The repo CHANGELOG had fallen behind. Adds [Unreleased] entries for the recently
shipped work and a README for the previously undocumented PdfService.

Added (changelog):
- Reports — Purchasing spend analytics (cost centres + accounting codes).
- Email PO to vendor + the PdfService microservice (cached per PO).
- EpfoService + PdfService microservices and their release auto-deploy.
- Unsaved-changes prompt on PO create/edit (#18).
- Crew login-on-hire for management ranks.
- Delivery Locations (#19), T&C catalogue (#11), advance payment (#92).

Fixed (changelog):
- "Email to vendor" never rendered (auth middleware bounced the svc fetch) — #127.
- Reports charts all one colour (RSC client/server boundary) — #120.

New: PdfService/README.md (endpoints, token/origin security, env, app integration).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:53:05 +05:30
e1907e6aec fix(automation): scan all Claude PRs for review comments; drop author-based bot filter
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 32s
Two issues surfaced on first live run:

- The per-run cap truncated the PR list to the lowest-numbered PR, so a
  comment on a higher-numbered PR was never scanned. The cap now limits only
  how many PRs Claude RUNS on; comment-less PRs are skipped for free, so newer
  PRs are never crowded out.
- The bot posts as the repo owner's account, so excluding "the bot's own
  comments" by author also excluded the owner's legitimate review comments
  (and required a read:user token scope, which 403'd). Replaced with a guard
  that excludes only comments carrying the HANDLED_TAG marker -- robust even
  when the bot and the reviewer are the same account. The /user call is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:48:48 +05:30
70152b0a5e Merge pull request 'feat(automation): watcher that addresses claude-review: comments on Claude PRs' (#129) from claude/busy-boyd-b16092 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #129
2026-06-24 10:08:01 +00:00
7acd86e3dd feat(automation): watcher that addresses claude-review: comments on Claude PRs
All checks were successful
PR checks / checks (pull_request) Successful in 47s
PR checks / integration (pull_request) Successful in 32s
Sibling to claude-issue-watcher.sh: polls open Claude-raised PRs (head
branch under claude/, or labelled claude-pr) for review comments carrying
the marker `claude-review:` — in the PR conversation, review summaries, or
inline on-file comments — and runs headless Claude Code on the PR's own
branch to address them, pushing the follow-up commit(s) to the same branch.

- Authorization gate: only repo collaborators (write access) + the owner can
  trigger it; the bot's own comments are ignored.
- Idempotent: handled comments are tracked by a hidden marker on the bot's
  acknowledgements, so the 10-min poll never redoes a comment.
- Own clone (~/pelagia-pr-review), config, and lock so it never races the
  issue watcher. Token needs write:repository + write:issue.

Adds the script, an example config, .gitignore entries for the live
config/lock, and an automation/README.md section with deploy + cron steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:21:04 +05:30
262ae5830b Merge pull request 'feat(pdf): cache the PO PDF per vendor email (reuse copy, refresh 7-day link)' (#128) from fix/po-pdf-cache into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #128
2026-06-24 09:47:58 +00:00
a9fd927c1f feat(pdf): cache the PO PDF per vendor email, refresh only the link timer
All checks were successful
PR checks / checks (pull_request) Successful in 47s
PR checks / integration (pull_request) Successful in 31s
Previously every "Email to vendor" click re-rendered the PO via PdfService and
re-uploaded to R2 under a timestamped key — wasteful, and it orphaned a new
object each time.

Now the PDF is stored at a deterministic per-PO key (buildPoPdfKey →
po-pdf/<poId>/<slug>.pdf). On each send, statObject() checks for an existing
copy: if it exists and is at least as new as the PO's updatedAt, it's reused
(no re-render, no re-upload) and only a fresh presigned URL is minted —
refreshing the 7-day download timer. It re-renders only when there's no copy
yet or the PO changed since the cached one (so an edited PO never emails a
stale PDF).

- lib/storage.ts: buildPoPdfKey (deterministic) + statObject (HEAD/stat, no
  body transfer; null when absent).
- email-actions.ts: reuse-or-render decision keyed on updatedAt; always
  re-presign.
- Tests: +2 (reuse-on-second-send-only-refreshes-link, re-render-when-changed).
  email-vendor suite 8 green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:01:25 +05:30
7a4c1c7f62 Merge pull request 'fix(pdf): let PdfService reach the export route past auth middleware' (#127) from fix/pdf-export-middleware into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m30s
Reviewed-on: #127
2026-06-24 09:27:35 +00:00
d1af1e6b12 fix(pdf): let PdfService reach the PO export route past auth middleware
All checks were successful
PR checks / checks (pull_request) Successful in 49s
PR checks / integration (pull_request) Successful in 31s
"Email PO to vendor" (issue #14) relies on PdfService fetching
/api/po/<id>/export?...&svc=<token> WITHOUT a user session, authenticating
with a `svc` token that matches PDF_SERVICE_TOKEN. The route handler validates
that token, but the auth middleware runs first and its matcher doesn't exempt
the export route — so every unauthenticated fetch was redirected to /login
(307) and the svc bypass never executed. Net effect: the feature could never
render a real PDF on any deployed env, even with the service configured.

Fix: middleware now lets exactly `/api/po/<id>/export` through when its `svc`
query param matches `process.env.PDF_SERVICE_TOKEN` (the route handler still
re-validates it — defense in depth). Everything else stays auth-gated. The
match lives in a dependency-free, edge-safe, unit-tested helper
(lib/pdf-export-auth.ts); middleware already reads server env at runtime via
auth()/NEXTAUTH_SECRET, so reading PDF_SERVICE_TOKEN there is consistent.

Verified on a running build: correct svc + real PO -> 200, correct svc + bogus
PO -> 404 (handler ran), wrong/no svc -> 307 (still gated). 324 unit tests
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:55:40 +05:30
2fcb207add Merge pull request 'fix(reports): charts rendered one colour — RSC client/server boundary bug' (#120) from fix/reports-series-colors into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Deploy release to production / deploy (push) Successful in 1m35s
Reviewed-on: #120
2026-06-24 07:05:39 +00:00
34143b5e75 fix(reports): chart series all rendered one colour (RSC boundary bug)
Some checks failed
PR checks / checks (pull_request) Failing after 15s
PR checks / integration (pull_request) Successful in 41s
The comparison charts (and detail-page breakdown swatches) rendered every
series in recharts' default colour instead of the per-item palette.

Root cause: `SERIES_COLORS` was defined in `components/reports/charts.tsx`,
which is a "use client" module. The report **pages are server components** and
imported the palette from it. A plain value imported from a client module into
a server component is a client-reference proxy, not the real array — so
`SERIES_COLORS[i % SERIES_COLORS.length]` was `SERIES_COLORS[NaN]` → undefined,
every line got `stroke={undefined}`, and recharts fell back to #3182bd. (The
literal `strokeWidth={2}` still applied, which is why only the colour was wrong.
It passed jsdom tests because those import the array directly, not across the
RSC boundary.)

Fix: move the palette to a dependency-free shared module `lib/report-colors.ts`
(no "use client", no server-only imports) that resolves to the real array in
both server and client graphs. `charts.tsx` and all four report pages import it
from there. It can't live in `lib/reports.ts` (that imports Prisma `db`, which
must not enter the client bundle).

Verified in a real browser: line strokes now cycle the 10-colour palette
(#2563eb, #16a34a, …) instead of a uniform #3182bd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:31:40 +05:30
cf69292be3 Merge pull request 'test(staging): feature-level verification of closed issues + seeded test users' (#119) from feat/staging-issue-verification into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #119
2026-06-24 06:46:17 +00:00
a72e980558 test(staging): feature-level verification of closed issues + seeded test users
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 32s
Adds a Playwright suite (App/tests/staging/) that logs into the running staging
instance (ppms-staging, :3200) and verifies each closed portal issue is actually
fixed — feature level, driving the real UI, one spec per issue.

To make credential login possible against the prod-mirror pelagia_test (which only
holds real, mostly SSO-only users), prisma/seed-test-users.ts idempotently seeds one
known-password @pelagia.local user per role, and automation/refresh-test-db.sh runs
it after every daily refresh so the logins persist on staging.

Result against staging: 41 passed, 1 skipped (#10 — no attachment data on staging).
Two closed issues were found NOT fixed and are recorded as documented test.fail():
  - #13 Accounts "payments completed this month" card is absent.
  - #24/#40 logout tooltip still reads "Sign out" (pipeline test issues).

Docs/TESTING.md documents the suite, the seeded users, how to run it against
staging, and the full issue -> script mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:49:48 +05:30
ee8313e10c Merge pull request 'fix(reports): one colour per item in the comparison chart (yearly too)' (#118) from fix/reports-chart-colors into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #118
2026-06-24 06:18:13 +00:00
0e9d06fe71 fix(reports): colour the comparison chart per item in yearly mode too
Some checks failed
PR checks / checks (pull_request) Failing after 4s
PR checks / integration (pull_request) Successful in 30s
The monthly/weekly comparison already drew one colour per item (series =
items). Yearly mode instead coloured by financial year (series = FYs, items on
the x-axis), so multiple cost centres / accounting codes in the same yearly
graph shared colours. Unify all three granularities to series = items: the
x-axis is months / weeks / FYs and each item keeps its own distinct colour
(yearly becomes grouped bars per item rather than per year).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:47:12 +05:30
91349f7564 Merge pull request 'feat(reports): Purchasing spend analytics (Cost Centres + Accounting Codes)' (#117) from feat/reports into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #117
2026-06-24 06:03:28 +00:00
47ac2c7813 feat(reports): weekly granularity, custom compare, line-item allocation
All checks were successful
PR checks / checks (pull_request) Successful in 48s
PR checks / integration (pull_request) Successful in 31s
Picks up the three pieces deferred from the initial reports PR:

#3 Line-item account allocation — allocatePoSpend() splits each PO across the
accounting codes its line items carry (line accountId, falling back to the
PO-level account), proportionally so per-PO rows sum back to totalAmount. The
accounting-code report now attributes multi-account POs correctly. SpendRow
gains poId; poCount is now distinct POs, not row count.

#2 Custom "Add to graph" — tick rows on either index (SelectCheckbox links
write ?sel=id1,id2), then "Compare selected" (?cmp=1) shows a custom comparison
of just those entities. Fully server-rendered + shareable; export honours sel.

#1 Weekly granularity — a third Granularity that focuses one FY month and
buckets spend by week-of-month (W1–W5) from approvedAt, with a Month picker in
the toolbar. Real buckets (not the mockup's synthetic split).

All three are URL-driven like the rest, so no client fetching. Charts/KPIs/
detail trends all branch on the new mode.

Tests: +8 unit cases (allocation proportional/fallback/empty, weekly buckets,
sel parse/toggle, month + granularity parsing); fixture updated for poId/week.
Full unit suite 311 green; tsc clean. Smoke-tested weekly + custom-compare +
exports end-to-end (all 200). Docs + wiki updated to mark them implemented.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:25:05 +05:30
8c6bbd8304 feat(reports): Purchasing spend analytics (Cost Centres + Accounting Codes)
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
Implements the wiki "Reports Mockup" as a Reports → Purchasing sidebar section,
wired to real approved-PO spend.

Two report families, each index → drill/detail:
- Cost Centres (/reports/cost-centres) — spend compared across vessels; row
  opens a cost-centre report with a Top-accounting-codes breakdown re-pivotable
  by tier (Heading/Sub/Leaf) + Top-N.
- Accounting Codes (/reports/accounting-codes) — drills the Account tree
  (headings → sub → leaves) via ?parent=; a leaf opens its report broken down by
  cost centre (or, for a non-leaf, by sub-account).

Shared: a pinned filter toolbar (Granularity Monthly/Yearly, Financial Year,
Show Top5/Top10/Bottom5/All) whose values live in the URL query so the server
component re-renders — no client fetching. KPI tiles, recharts comparison/trend/
breakdown charts, per-row trend sparklines, and CSV export (/api/reports/spend).

- lib/reports.ts: the pure, unit-tested aggregation core. Spend = a PO once it
  reaches POST_APPROVAL_STATUSES, dated by approvedAt, valued at totalAmount
  (the dashboard's basis); Indian Apr–Mar FY; each PO's leaf accountId rolled up
  to parents. One query in getReportDataset(), everything else pure.
- Sidebar: new collapsible "Reports" section with a "Purchasing" subheading
  (subgroup support added to the Section model). Gated by view_analytics
  (Manager/SuperUser/Auditor/Admin); export by the same.

Deferred (documented): synthetic Weekly granularity, the "Add to graph" custom
multi-select, and line-item-level account allocation (v1 uses the PO-level
account). Sites are not cost centres — only vessels.

Tests: 11 unit cases for the aggregation core + 3 sidebar cases for the Reports
section. Full unit suite 303 green; tsc clean. Smoke-tested all routes end to
end against seed data (index/drill/detail/export 200; non-analytics role 307/403).

Wiki: "Reports Mockup" marked implemented; "Pages and Navigation" lists the new
routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:52:23 +05:30
a08ed68569 Merge pull request 'fix: Make GST captcha a popup' (#115) from claude/issue-114 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #115
2026-06-24 01:19:51 +00:00
56497a0d20 Merge pull request 'feat(po): prompt to save as draft when leaving with unsaved changes' (#116) from feat/po-draft-prompt into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #116
2026-06-24 01:14:20 +00:00
7d4ad6a9b8 feat(po): prompt to save as draft when leaving with unsaved changes
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 32s
Closes #18. Navigating away from a PO create/edit screen with unsaved
changes could silently lose in-progress work. The forms now track a dirty
flag and guard both navigation paths:

- Hard navigations (refresh / tab close / external link) → the browser's
  native "Leave site?" prompt via beforeunload.
- In-app navigations (sidebar / header / any internal link) → a capture-phase
  click interceptor opens a modal offering Save as draft / Discard changes /
  Stay on page. Save as draft runs the form's existing draft save (which
  redirects to the PO); Discard continues to the intended destination.

The guard (components/po/unsaved-changes-guard.tsx) is reusable and wired into
both new-po-form and edit-po-form. dirty is cleared before a successful submit
so saving never trips the prompt. SPA back-button (popstate) is left to
beforeunload only; the manager inline-edit panel is out of scope (saves in
place, no draft concept).

Tests: 7 new unit cases for the guard (intercept-when-dirty, no-op-when-clean,
external links pass through, Stay/Discard/Save actions, beforeunload arming).
Unit suite 296 green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 06:37:33 +05:30
Claude (auto-fix)
55ae1d46d0 fix(vendors): move GSTIN CAPTCHA into a popup
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
The GSTIN lookup rendered its CAPTCHA (image + 6-digit input + Verify /
New image) inline inside the Add/Edit Vendor dialog. AdminDialog has no
internal scroll and is vertically centred, so the taller form pushed its
footer (Cancel / Create Vendor / Save) off-screen and out of reach.

Extract the CAPTCHA into a dedicated popup (CaptchaPopup) overlaid on the
vendor form at z-[60] with an explicit Cancel button and a ✕ close
control. It handles Escape on the capture phase so dismissing the CAPTCHA
does not also close the underlying form. In-flight CAPTCHA errors now
show inside the popup (it stays open so the user can retry / get a new
image); the success line still lands on the main form. The form footer is
never displaced.

Adds a unit test covering popup open on Look up, Cancel closing only the
popup, and a successful verify populating the fields.

Fixes #114
2026-06-24 06:37:29 +05:30
21df005ab6 Merge pull request 'feat(sidebar): group Purchase Order links under Purchasing' (#113) from feat/sidebar-purchasing-section into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #113
2026-06-24 00:45:23 +00:00
502411afe6 Merge pull request 'chore(crewing): expand abbreviated rank names (+ seed ranks to prod)' (#111) from feat/expand-rank-names into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #111
2026-06-24 00:45:10 +00:00
2e8fd67805 test(sidebar): cover PO links moved under Purchasing + renames
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 31s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:10:55 +05:30
29118aa88e feat(sidebar): group Purchase Order links under Purchasing
Some checks failed
PR checks / checks (pull_request) Failing after 3s
PR checks / integration (pull_request) Successful in 31s
Move New PO, Closed POs, Import PO and History into the Purchasing
section and rename them: "New PO" to "New Purchase Order", "Import PO"
to "Import Purchase Order", "History" to "Purchase Order History".
Per-role visibility is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 06:08:49 +05:30
d25a600566 chore(crewing): expand abbreviated rank names in rank-data
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 31s
Fully expand the five abbreviated rank names so the canonical seed (which
upserts ranks by code and overwrites name) matches the names loaded into prod:
- PM → Project Manager
- Assistant PM → Assistant Project Manager
- Sr. Dredge Operator → Senior Dredge Operator
- Jr. Dredge Operator → Junior Dredge Operator
- Sr. Fabricator → Senior Fabricator

Hierarchy, codes, category, isSeafarer and grantsLogin are unchanged. (The
prod Rank table was seeded with these 19 ranks out-of-band; this keeps the
source of truth in sync so a future seed won't revert the names.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:39:14 +05:30
7245bb1962 Merge pull request 'fix: On new PO screen Vendors should have search' (#110) from claude/issue-109 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m30s
Reviewed-on: #110
2026-06-23 23:52:47 +00:00
c710fe5d73 Merge pull request 'feat(po): catalogue line items on approval + move /inventory/{items,vendors} ? /catalogue' (#108) from feat/catalog-on-approval into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #108
2026-06-23 23:47:49 +00:00
Claude (auto-fix)
c503f839e8 feat(po): make vendor field a searchable combobox
All checks were successful
PR checks / checks (pull_request) Successful in 46s
PR checks / integration (pull_request) Successful in 31s
The vendor field on the PO forms was a plain native <select>, forcing
users to scroll the full vendor list. Mirror the item-search UX with a
searchable combobox that filters by vendor name and formal code
(vendorId), case-insensitively. The vendor list is already client-side,
so this is a pure in-memory filter — no API or DB change.

New VendorSelect component (components/ui/vendor-select.tsx) is a
self-contained portal-rendered combobox posting a hidden vendorId input,
so it drops into all three PO forms unchanged on the server:
- po/new/new-po-form
- po/[id]/edit/edit-po-form
- approvals/[id]/manager-edit-po-form

Preserves the optional field, "No vendor selected" empty option, and the
"{name} (CODE)" / "(unverified)" label. Unverified vendors (null code)
remain findable by name. Adds unit tests for the filter logic and
component behaviour.

Fixes #109
2026-06-24 05:16:57 +05:30
2bdf3a6536 chore: rename e2e folder to catalogue + drop /inventory redirects
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
- tests/e2e/inventory → tests/e2e/catalogue (folder name only; playwright globs
  ./tests/e2e so nothing else changes).
- remove the next.config redirects from /inventory/{items,vendors}: the old
  routes are intentionally left free for a future feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:06:44 +05:30
d7b455ab7d refactor(routes): move /inventory/{items,vendors} → /catalogue/{items,vendors}
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 31s
Renames the product-catalogue pages (items + vendors, incl. their [id] detail
pages) out of /inventory into /catalogue. /inventory/cart is unchanged. All
internal links, redirects, revalidatePath calls, sidebar nav, and tests are
updated; next.config redirects keep old /inventory/{items,vendors}[/...] URLs
working (permanent) so existing bookmarks don't 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 05:04:29 +05:30
70f3230c36 feat(po): register line items in the product catalogue on approval
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
Previously a PO's free-text line items only became reusable catalogue products
(/inventory/items) on full payment (markPaid → syncProductCatalog). An approved-
but-unpaid PO's items weren't selectable for further POs yet.

- extract syncProductCatalog into lib/product-catalog.ts (shared).
- call it from approvePo so approved items are immediately catalogued (create
  product by name if unknown, link the line item, upsert last/per-vendor price);
  payment still re-syncs to refresh prices. Idempotent.
- test: approving a PO with a free-text line creates + links the product and
  records the per-vendor price.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:59:47 +05:30
85805754b5 Merge pull request 'feat(po): user-defined T&C categories + dynamic PO terms editor (#11 follow-up)' (#107) from feat/terms-dynamic-editor into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #107
2026-06-23 23:27:47 +00:00
3babfe26ef feat(po): user-defined T&C categories + dynamic PO terms editor (#11)
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 32s
Follow-up to the merged #11 PR (which shipped the enum-based catalogue): make
categories user-defined data and the PO T&C a dynamic editor.

- categories are a TermsCategory TABLE (not an enum) — admins add new ones;
- every PO T&C line is catalogued, incl. the previously-fixed boilerplate
  (seeded under a "General" category) and an "Others" bucket;
- the PO form is a dynamic editor: "+ Add term", pick a category, type/pick a
  clause (components/po/po-terms-editor.tsx), used by new/edit/manager-edit.

Migration: the already-released 20260624140000 migration is untouched; a new
20260624150000 FORWARD migration renames the enum, creates the table, migrates
existing enum clauses onto category rows, adds isDefault/sortOrder + the two
fixed lines under General, and adds PurchaseOrder.terms (JSON snapshot that
supersedes the legacy tc* columns for export/detail; old POs fall back to tc*).

Tests rewritten for category creation + catalogue/default helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 04:43:24 +05:30
fced7cc307 Merge pull request 'feat(po): admin-managed Terms & Conditions catalogue + PO dropdowns (#11)' (#106) from feat/terms-conditions-admin into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Successful in 1m34s
Reviewed-on: #106
2026-06-23 22:14:05 +00:00
6e8d05e34e Merge pull request 'fix: Paginate PO history' (#105) from claude/issue-104 into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #105
2026-06-23 22:10:54 +00:00
Claude (auto-fix)
5cefe8f7ed feat(history): paginate PO history with items-per-page control
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 32s
The /history page fetched a fixed first 200 POs in one flat table with no
way to page further and no control over page size. Replace that with real
pagination:

- page.tsx: read page/perPage from searchParams; clamp perPage to 25/50/100
  (default 25) and page to [1, totalPages] via a new shared resolvePagination
  helper. Swap the fixed take:200 for skip/take + a count() for totals.
  Replace the "first 200 results" notice with a footer ("Showing X-Y of N",
  Prev/Next, page indicator) that preserves all filters. Export PDF/CSV links
  stay on the full filtered set.
- history-filters.tsx: add a Per-page dropdown; changing it or any filter
  resets to page 1 while preserving perPage in the URL.
- lib/pagination.ts: dependency-free clamp/skip/take helper, unit-tested.

Verified: type-check clean, 272 unit tests pass (9 new), skip/take windows
and clamping checked against the test DB.

Fixes #104
2026-06-24 03:26:47 +05:30
111 changed files with 5260 additions and 566 deletions

4
.gitignore vendored
View file

@ -32,6 +32,10 @@ automation/watcher.config.json
automation/logs/
automation/.watcher.lock
# Claude PR-review-comment watcher (real token + lock stay local; shares logs/)
automation/pr-review-watcher.config.json
automation/.pr-review-watcher.lock
# OS
.DS_Store
Thumbs.db

1
App/.gitignore vendored
View file

@ -13,6 +13,7 @@
# Testing
/coverage
/playwright-report
/playwright-report-staging
/test-results
/blob-report

View file

@ -106,7 +106,20 @@ The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) rende
### Terms & Conditions catalogue (issue #11)
Same admin-list-feeds-PO-dropdown pattern as Delivery Locations. `TermsCondition` (`category: TermsCategory` enum + `text` + `isActive`) is an admin-managed clause library, managed at `/admin/terms` (gated by **`manage_terms`** — Manager + SuperUser + Admin; CRUD mirrors `/admin/delivery-locations`). The migration **seeds** the prior `TC_DEFAULTS` wording as the starting clauses. The five **named** PO T&C slots (Delivery / Dispatch / Inspection / Transit Insurance / Payment Terms — the `tc*` columns, mapped via `lib/terms.ts` `TC_FIELD_CATEGORY`) become a shared `<TermsField>` **combobox** (native `<input list>` + `<datalist>`) — type a one-off clause or pick a catalogued one — suggesting the active clauses of that category (`lib/terms-data.ts` `getActiveTermsByCategory`). **"Others" stays free text**, and the fixed boilerplate lines (`TC_FIXED_LINE` / `TC_FIXED_LINE_2`) are not catalogued. The `tc*` columns stay **free-text snapshots** (export/import unchanged); since the slot is a free-text combobox, any current/custom value is preserved as-is. No "work order" type — POs only (per the issue's steer).
Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**.
- **Models:** `TermsCategory` (`name` unique + `sortOrder` + `isActive`) and `TermsCondition` (`categoryId` FK + `text` + `isDefault` + `isActive` + `sortOrder`). Managed at `/admin/terms` (gated by **`manage_terms`** — Manager + SuperUser + Admin). The migration **seeds every standard PO T&C line** as a clause: the five named slots keep their wording, the previously-fixed boilerplate lines live under a **"General"** category, and an empty **"Others"** category is provided. `isDefault` clauses pre-fill new POs.
- **Admin** (`/admin/terms`): the Add/Edit clause form's category is a combobox — typing a new name **creates the category** ("add a new category along with the clause"). `isDefault` is a checkbox.
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
### Unsaved-changes prompt (issue #18)
The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `<UnsavedChangesGuard>` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and:
- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
- **In-app navigations** (sidebar / header / any internal `<a>`) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**.
`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept).
### PO Numbering (`lib/po-number.ts`)
@ -128,14 +141,33 @@ An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey``po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
### Inventory (feature-flagged)
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
### Product catalogue sync (`lib/product-catalog.ts`)
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/catalogue/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
### Import → Closed
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
### Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** subheading, so other domains can add report groups later). Gated by **`view_analytics`** (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an **index → drill/detail** pair:
- **Cost Centres** (`/reports/cost-centres`) — spend compared across **vessels** (the PO cost centre). Row → **`/reports/cost-centres/[id]`** detail: trend + a **Top accounting codes** breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N.
- **Accounting Codes** (`/reports/accounting-codes`) — drills the `Account` tree (headings → sub-headings → leaves) via a `?parent=` query; leaf rows open **`/reports/accounting-codes/[id]`**: trend + breakdown **by cost centre** (or, for a non-leaf, by sub-account).
**Spend definition** (`lib/reports.ts`, the pure/unit-tested core): a PO counts once it reaches `POST_APPROVAL_STATUSES`, dated by `approvedAt`, valued at the full `totalAmount` — the same basis as the dashboard tiles. FY is the Indian **AprMar** year. `getReportDataset()` does one query pass; everything else is pure functions over it. **`allocatePoSpend()`** splits each PO across the accounting codes its **line items** carry (line `accountId`, falling back to the PO-level account), **proportionally** so the per-PO rows always sum back to `totalAmount` — so multi-account POs are attributed correctly in the accounting-code report. `poCount` is **distinct POs** (a multi-account PO yields several rows). Account spend rolls leaf descendants up via `buildAccountIndex().leavesUnder`.
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
Sites are **not** cost centres (only vessels are).
### Crewing (feature-flagged)
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:

View file

@ -7,7 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
import { distanceKm, formatDistance } from "@/lib/geo";
import { ToggleProductButton, EditProductButton } from "../product-form";
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
import { SiteSelect } from "@/components/inventory/site-select";
import type { Metadata } from "next";

View file

@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
export function ProductsTable({
products,
canManage,
detailBase = "/inventory/items",
detailBase = "/catalogue/items",
}: {
products: ProductRow[];
canManage: boolean;

View file

@ -5,11 +5,12 @@ import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { TermsCategory } from "@prisma/client";
const schema = z.object({
category: z.nativeEnum(TermsCategory),
// A category NAME — picked from the existing list or typed to create a new one.
categoryName: z.string().trim().min(1, "Category is required"),
text: z.string().trim().min(1, "Clause text is required"),
isDefault: z.boolean().default(false),
});
type Result = { ok: true } | { error: string };
@ -22,14 +23,40 @@ async function guard(): Promise<{ ok: true } | { error: string }> {
return { ok: true };
}
function parse(formData: FormData) {
return schema.safeParse({
categoryName: formData.get("categoryName"),
text: formData.get("text"),
isDefault: formData.get("isDefault") === "on" || formData.get("isDefault") === "true",
});
}
// Find a category by name (case-insensitive), creating it (appended to the end)
// if it doesn't exist — this is how new categories are added "along with clauses".
async function ensureCategory(name: string): Promise<string> {
const existing = await db.termsCategory.findFirst({
where: { name: { equals: name, mode: "insensitive" } },
select: { id: true },
});
if (existing) return existing.id;
const max = await db.termsCategory.aggregate({ _max: { sortOrder: true } });
const created = await db.termsCategory.create({
data: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 },
});
return created.id;
}
export async function createTerm(formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
await db.termsCondition.create({ data: { category: parsed.data.category, text: parsed.data.text } });
const categoryId = await ensureCategory(parsed.data.categoryName);
await db.termsCondition.create({
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
});
revalidatePath("/admin/terms");
return { ok: true };
}
@ -38,10 +65,14 @@ export async function updateTerm(id: string, formData: FormData): Promise<Result
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
await db.termsCondition.update({ where: { id }, data: { category: parsed.data.category, text: parsed.data.text } });
const categoryId = await ensureCategory(parsed.data.categoryName);
await db.termsCondition.update({
where: { id },
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
});
revalidatePath("/admin/terms");
return { ok: true };
}
@ -61,7 +92,7 @@ export async function deleteTerm(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
// Safe to delete: POs keep their T&C as text snapshots, so no PO references this row.
// Safe to delete: POs keep their T&C as a JSON snapshot, so no PO references this row.
await db.termsCondition.delete({ where: { id } });
revalidatePath("/admin/terms");
return { ok: true };

View file

@ -12,16 +12,22 @@ export default async function TermsPage() {
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
const terms = await db.termsCondition.findMany({
orderBy: [{ category: "asc" }, { isActive: "desc" }, { createdAt: "asc" }],
});
const [terms, categories] = await Promise.all([
db.termsCondition.findMany({
orderBy: [{ category: { sortOrder: "asc" } }, { isActive: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
include: { category: { select: { name: true } } },
}),
db.termsCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }], select: { name: true } }),
]);
return (
<TermsTable
categoryNames={categories.map((c) => c.name)}
terms={terms.map((t) => ({
id: t.id,
category: t.category,
categoryName: t.category.name,
text: t.text,
isDefault: t.isDefault,
isActive: t.isActive,
}))}
/>

View file

@ -2,41 +2,53 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { TermsCategory } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { TERMS_CATEGORIES, TERMS_CATEGORY_LABEL } from "@/lib/terms";
import { createTerm, updateTerm } from "./actions";
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";
export type TermRow = {
id: string;
category: TermsCategory;
categoryName: string;
text: string;
isDefault: boolean;
isActive: boolean;
};
function Fields({ term }: { term?: TermRow }) {
function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
<select name="category" defaultValue={term?.category ?? ""} required className={INPUT}>
<option value="" disabled>Select a category</option>
{TERMS_CATEGORIES.map((c) => (
<option key={c} value={c}>{TERMS_CATEGORY_LABEL[c]}</option>
<input
name="categoryName"
list="tc-category-list"
defaultValue={term?.categoryName ?? ""}
required
autoComplete="off"
className={INPUT}
placeholder="Pick a category or type a new one…"
/>
<datalist id="tc-category-list">
{categoryNames.map((c) => (
<option key={c} value={c} />
))}
</select>
</datalist>
<p className="mt-1 text-xs text-neutral-400">Type a new name to create a category.</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label>
<textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isDefault" defaultChecked={term?.isDefault ?? false} className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" />
Pre-add to new POs by default
</label>
</div>
);
}
export function AddTermButton() {
export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
@ -56,7 +68,7 @@ export function AddTermButton() {
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields />
<Fields categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
@ -70,10 +82,12 @@ export function AddTermButton() {
export function EditTermButton({
term,
categoryNames,
open: controlledOpen,
onOpenChange,
}: {
term: TermRow;
categoryNames: string[];
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
@ -96,7 +110,7 @@ export function EditTermButton({
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields term={term} />
<Fields term={term} categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>

View file

@ -6,13 +6,12 @@ import { TableControls, SortableTh } from "@/components/ui/table-controls";
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TERMS_CATEGORY_LABEL } from "@/lib/terms";
import { AddTermButton, EditTermButton, type TermRow } from "./terms-form";
import { deleteTerm, toggleTermActive } from "./actions";
const CHIPS = ["Active", "Inactive"];
function TermActionsMenu({ term }: { term: TermRow }) {
function TermActionsMenu({ term, categoryNames }: { term: TermRow; categoryNames: string[] }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
@ -28,7 +27,7 @@ function TermActionsMenu({ term }: { term: TermRow }) {
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditTermButton term={term} open={editOpen} onOpenChange={setEditOpen} />
<EditTermButton term={term} categoryNames={categoryNames} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
@ -41,8 +40,8 @@ function TermActionsMenu({ term }: { term: TermRow }) {
title={term.isActive ? "Deactivate clause?" : "Activate clause?"}
description={
term.isActive
? "It will no longer appear in the PO Terms & Conditions dropdowns."
: "It will appear in the PO Terms & Conditions dropdowns again."
? "It will no longer be suggested in the PO Terms & Conditions editor."
: "It will be suggested in the PO Terms & Conditions editor again."
}
confirmLabel={term.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleTermActive(term.id)}
@ -51,12 +50,12 @@ function TermActionsMenu({ term }: { term: TermRow }) {
);
}
export function TermsTable({ terms }: { terms: TermRow[] }) {
export function TermsTable({ terms, categoryNames }: { terms: TermRow[]; categoryNames: string[] }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<TermRow>({
rows: terms,
defaultSortKey: "category",
searchText: (t) => [TERMS_CATEGORY_LABEL[t.category], t.text, t.isActive ? "active" : "inactive"].join(" "),
defaultSortKey: "categoryName",
searchText: (t) => [t.categoryName, t.text, t.isActive ? "active" : "inactive"].join(" "),
chipMatch: (t, chip) => {
if (chip.toLowerCase() === "active") return t.isActive;
if (chip.toLowerCase() === "inactive") return !t.isActive;
@ -64,7 +63,7 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
},
sortValue: (t, key) => {
if (key === "isActive") return t.isActive ? "Active" : "Inactive";
if (key === "category") return TERMS_CATEGORY_LABEL[t.category];
if (key === "isDefault") return t.isDefault ? "Yes" : "No";
const val = t[key as keyof TermRow];
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
},
@ -75,9 +74,9 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Terms &amp; Conditions</h1>
<p className="text-sm text-neutral-500 mt-0.5">Clauses that populate the PO Terms &amp; Conditions dropdowns</p>
<p className="text-sm text-neutral-500 mt-0.5">Categories &amp; clauses that populate the PO Terms &amp; Conditions editor</p>
</div>
<AddTermButton />
<AddTermButton categoryNames={categoryNames} />
</div>
<TableControls
@ -93,8 +92,9 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<SortableTh sortKey="category" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Category</SortableTh>
<SortableTh sortKey="categoryName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Category</SortableTh>
<SortableTh sortKey="text" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Clause</SortableTh>
<SortableTh sortKey="isDefault" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Default</SortableTh>
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof TermRow)}>Status</SortableTh>
<th className="px-4 py-3 w-10"></th>
</tr>
@ -102,15 +102,18 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
<tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
No clauses yet. Add one to populate the PO Terms &amp; Conditions dropdowns.
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
No clauses yet. Add one to populate the PO Terms &amp; Conditions editor.
</td>
</tr>
)}
{filtered.map((term) => (
<tr key={term.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900 whitespace-nowrap">{TERMS_CATEGORY_LABEL[term.category]}</td>
<td className="px-4 py-3 font-medium text-neutral-900 whitespace-nowrap">{term.categoryName}</td>
<td className="px-4 py-3 text-neutral-600 max-w-xl whitespace-pre-wrap">{term.text}</td>
<td className="px-4 py-3">
{term.isDefault ? <span className="text-xs font-medium text-primary-700">Default</span> : <span className="text-neutral-300"></span>}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
term.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
@ -119,7 +122,7 @@ export function TermsTable({ terms }: { terms: TermRow[] }) {
</span>
</td>
<td className="px-4 py-3">
<TermActionsMenu term={term} />
<TermActionsMenu term={term} categoryNames={categoryNames} />
</td>
</tr>
))}

View file

@ -95,7 +95,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
});
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath("/catalogue/vendors");
return { ok: true };
}
@ -108,7 +108,7 @@ export async function verifyVendor(vendorId: string): Promise<ActionResult> {
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath("/catalogue/vendors");
revalidatePath(`/admin/vendors/${vendorId}`);
return { ok: true };
}

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2 } from "lucide-react";
import { AdminDialog } from "@/components/ui/admin-dialog";
@ -113,6 +113,44 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
);
}
// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
// handles Escape on the capture phase so closing it does NOT also close the underlying form.
function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
}
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
<h3 className="text-sm font-semibold text-neutral-900">GSTIN CAPTCHA</h3>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
>
</button>
</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
@ -149,13 +187,19 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
});
const data: GstResult & { error?: string } = await res.json();
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
// Keep the popup open on error so the user sees it in context and can retry / get a new image.
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
setName(data.tradeName || data.legalName);
setAddress(data.address);
if (data.pincode) setPincode(data.pincode);
setGstSuccess(`${data.legalName}${data.status} since ${data.registrationDate}`);
setCaptchaStep("idle");
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
}
// Close the CAPTCHA popup without touching the vendor form fields.
function closeCaptcha() {
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
}
return (
@ -183,31 +227,46 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
{captchaStep === "loading" ? "Loading…" : "Look up"}
</button>
</div>
{captchaStep === "ready" && captchaB64 && (
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
<div className="flex gap-2 items-center">
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
placeholder="6 digits"
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
/>
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
Verify
</button>
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
New image
</button>
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
{captchaStep === "loading" ? (
<p className="py-4 text-center text-sm text-neutral-500">Loading CAPTCHA</p>
) : (
<div className="space-y-3">
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
{captchaB64 && (
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
)}
<div className="flex gap-2 items-center">
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
placeholder="6 digits"
disabled={captchaStep === "verifying"}
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
/>
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
</button>
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
New image
</button>
</div>
{gstError && <p className="text-xs text-danger-600">{gstError}</p>}
<div className="flex justify-end border-t border-neutral-100 pt-3">
<button type="button" onClick={closeCaptcha}
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
</div>
</div>
</div>
)}
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying</p>}
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
)}
</CaptchaPopup>
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
{captchaStep === "idle" && gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
</div>

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { approvePoSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -84,6 +85,12 @@ export async function approvePo({
revalidatePath(`/admin/sites/${siteId}`);
}
// Register the line items in the product catalogue (/catalogue/items) on
// approval, so an approved PO's items are immediately reusable in further POs.
// Idempotent; payment re-syncs to refresh prices on the final figures.
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
revalidatePath("/catalogue/items");
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",

View file

@ -4,14 +4,14 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { managerEditPo } from "./manager-po-edit-actions";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
import type { LineItemInput } from "@/lib/validations/po";
import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
type SerializedLineItem = {
id: string;
@ -43,7 +43,8 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
}
const INPUT =
@ -56,12 +57,13 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
}
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory }: Props) {
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const extPo = po as typeof po & {
piQuotationNo?: string | null; piQuotationDate?: Date | null;
@ -103,6 +105,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
});
data.set("termsJson", JSON.stringify(terms));
const result = await managerEditPo(po.id, data);
if ("error" in result) {
@ -242,14 +245,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
<label className={LABEL}>Vendor</label>
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
</section>
{/* Line Items */}
@ -263,39 +259,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
{/* Terms & Conditions */}
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms &amp; Conditions</h3>
<div className="space-y-2.5">
<div className="rounded-lg bg-amber-100 border border-amber-200 px-3 py-2 text-xs text-amber-700 select-none">
<span className="font-semibold">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right">{n}.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800">{label}</label>
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={(extPo[key] ?? TC_DEFAULTS[key]) as string}
className={INPUT}
/>
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-xs font-semibold text-amber-700 text-right mt-2">7.</span>
<label className="w-44 shrink-0 text-xs font-semibold text-amber-800 mt-2">Others</label>
<textarea
name="tcOthers"
rows={2}
defaultValue={(extPo.tcOthers ?? TC_DEFAULTS.tcOthers) as string}
className={INPUT}
/>
</div>
</div>
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
</section>
{error && (

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { revalidatePath } from "next/cache";
export async function managerEditPo(
@ -68,6 +69,10 @@ export async function managerEditPo(
}
const data = parsed.data;
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
const newTotal = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
@ -130,6 +135,7 @@ export async function managerEditPo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
totalAmount: newTotal,
lineItems: {
deleteMany: {},

View file

@ -7,7 +7,8 @@ import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
@ -62,7 +63,9 @@ export default async function ApprovalDetailPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -104,7 +107,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
/>
</div>

View file

@ -26,7 +26,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
const { id } = await params;
const { site: siteId } = await searchParams;
const baseHref = `/inventory/items/${id}`;
const baseHref = `/catalogue/items/${id}`;
const [product, sites] = await Promise.all([
db.product.findUnique({
@ -85,7 +85,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
<div className="max-w-6xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
<Link href="/catalogue/items" className="hover:text-neutral-700">Items</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{product.name}</span>
</div>

View file

@ -108,7 +108,7 @@ export function ItemsTable({
value={currentSiteId ?? ""}
onChange={(e) => {
const id = e.target.value;
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/items");
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
}}
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
>
@ -254,7 +254,7 @@ export function ItemsTable({
<td className="px-12 py-2.5">
<div className="flex items-center gap-2">
<Link
href={`/inventory/vendors/${vendor.vendorId}`}
href={`/catalogue/vendors/${vendor.vendorId}`}
onClick={(e) => e.stopPropagation()}
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
>

View file

@ -20,7 +20,7 @@ export default async function InventoryItemsPage() {
},
});
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/items
const canManage = hasPermission(session.user.role, "manage_products");
return (

View file

@ -48,7 +48,7 @@ export default async function InventoryVendorDetailPage({ params }: Props) {
<div className="max-w-5xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
<Link href="/catalogue/vendors" className="hover:text-neutral-700">Vendors</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{vendor.name}</span>
</div>

View file

@ -68,7 +68,7 @@ export function VendorsTable({
value={currentSiteId ?? ""}
onChange={(e) => {
const id = e.target.value;
router.push(id ? `/inventory/vendors?siteId=${id}` : "/inventory/vendors");
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
}}
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
>
@ -149,7 +149,7 @@ export function VendorsTable({
<tr key={vendor.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
{vendor.name}
</Link>
{vendor.vendorId && (

View file

@ -19,12 +19,18 @@ const STATUSES = [
interface Props {
vessels: { id: string; name: string }[];
perPageOptions: number[];
defaultPerPage: number;
}
export function HistoryFilters({ vessels }: Props) {
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
const router = useRouter();
const sp = useSearchParams();
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
? Number(sp.get("perPage"))
: defaultPerPage;
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
@ -50,7 +56,8 @@ export function HistoryFilters({ vessels }: Props) {
);
}
function apply() {
// Changing any filter resets to page 1; perPage is preserved across applies.
function buildParams(nextPerPage: number) {
const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo);
@ -58,12 +65,24 @@ export function HistoryFilters({ vessels }: Props) {
if (approvedTo) params.set("approvedTo", approvedTo);
if (vesselId) params.set("vesselId", vesselId);
for (const s of statuses) params.append("status", s);
router.push(`/history?${params.toString()}`);
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
return params;
}
function apply() {
router.push(`/history?${buildParams(perPage).toString()}`);
}
function changePerPage(next: number) {
router.push(`/history?${buildParams(next).toString()}`);
}
function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
router.push("/history");
const params = new URLSearchParams();
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
const qs = params.toString();
router.push(qs ? `/history?${qs}` : "/history");
}
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
@ -139,6 +158,13 @@ export function HistoryFilters({ vessels }: Props) {
Clear
</button>
)}
<div className="ml-auto flex items-center gap-2">
<label htmlFor="perPage" className="text-xs font-medium text-neutral-600">Per page</label>
<select id="perPage" value={perPage} onChange={(e) => changePerPage(Number(e.target.value))}
className="rounded-lg border border-neutral-300 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20">
{perPageOptions.map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
</div>
</div>
);

View file

@ -6,12 +6,16 @@ import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters";
import { resolvePagination } from "@/lib/pagination";
import { Suspense } from "react";
import type { Metadata } from "next";
import type { POStatus } from "@prisma/client";
export const metadata: Metadata = { title: "History" };
const PER_PAGE_OPTIONS = [25, 50, 100];
const DEFAULT_PER_PAGE = 25;
interface Props {
searchParams: Promise<{
dateFrom?: string;
@ -20,6 +24,8 @@ interface Props {
approvedTo?: string;
vesselId?: string;
status?: string | string[];
page?: string;
perPage?: string;
}>;
}
@ -36,7 +42,8 @@ export default async function HistoryPage({ searchParams }: Props) {
redirect("/dashboard");
}
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
await searchParams;
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
if (dateFrom || dateTo) {
@ -63,16 +70,45 @@ export default async function HistoryPage({ searchParams }: Props) {
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
const total = await db.purchaseOrder.count({ where });
const { perPage, page, totalPages, skip, take } = resolvePagination({
perPageParam,
pageParam,
total,
options: PER_PAGE_OPTIONS,
defaultPerPage: DEFAULT_PER_PAGE,
});
const [orders, vessels] = await Promise.all([
db.purchaseOrder.findMany({
where,
include: { submitter: true, vessel: true, account: true },
orderBy: { createdAt: "desc" },
take: 200,
skip,
take,
}),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Shared filter params for the pagination footer links (everything except `page`).
const pageParams = new URLSearchParams();
if (dateFrom) pageParams.set("dateFrom", dateFrom);
if (dateTo) pageParams.set("dateTo", dateTo);
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
if (approvedTo) pageParams.set("approvedTo", approvedTo);
if (vesselId) pageParams.set("vesselId", vesselId);
for (const s of statuses) pageParams.append("status", s);
pageParams.set("perPage", String(perPage));
const pageHref = (p: number) => {
const params = new URLSearchParams(pageParams);
params.set("page", String(p));
return `/history?${params.toString()}`;
};
const firstRow = total === 0 ? 0 : skip + 1;
const lastRow = skip + orders.length;
const exportParams = new URLSearchParams({ format: "csv" });
if (dateFrom) exportParams.set("dateFrom", dateFrom);
if (dateTo) exportParams.set("dateTo", dateTo);
@ -104,7 +140,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div>
<Suspense>
<HistoryFilters vessels={vessels} />
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
</Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -149,8 +185,41 @@ export default async function HistoryPage({ searchParams }: Props) {
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
)}
</div>
{orders.length === 200 && (
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results refine filters to narrow results.</p>
{total > 0 && (
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
<span>
Showing {firstRow}{lastRow} of {total}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={pageHref(page - 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Previous
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Previous
</span>
)}
<span className="text-neutral-500">
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link
href={pageHref(page + 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Next
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Next
</span>
)}
</div>
</div>
)}
</div>
);

View file

@ -46,8 +46,8 @@ export function CartView() {
<p className="text-neutral-500 font-medium">Your cart is empty</p>
<p className="text-sm text-neutral-400 mt-1 mb-6">Browse Items or Vendors to add line items</p>
<div className="flex gap-3 justify-center">
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
<Link href="/inventory/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
<Link href="/catalogue/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
</div>
</div>
);
@ -108,7 +108,7 @@ export function CartView() {
<div className="flex items-center justify-between">
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
<div className="flex gap-3">
<Link href="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
+ Add more items
</Link>
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">

View file

@ -4,107 +4,12 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine";
import { processPaymentSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
type ActionResult = { ok: true } | { error: string };
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
// Sync product catalog after payment is confirmed:
// - Auto-create products for unlinked line items (matched by name or brand new)
// - Upsert per-vendor prices for all items
async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
const session = await auth();

View file

@ -3,6 +3,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -71,6 +72,11 @@ export async function updatePo(
}
const data = parsed.data;
// Dynamic T&C rows (issue #11) — JSON snapshot superseding the tc* columns.
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
0
@ -156,6 +162,7 @@ export async function updatePo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
totalAmount: total,
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
submittedAt: shouldSubmit ? new Date() : po.submittedAt,

View file

@ -7,11 +7,12 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
const INPUT_CLS =
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
@ -44,11 +45,12 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
managerNoteAuthor?: string | null;
}
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, managerNoteAuthor }: Props) {
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
po.lineItems.map((li) => ({
@ -67,6 +69,9 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
const canSubmit = po.status === "DRAFT";
const canResubmit = po.status === "EDITS_REQUESTED";
@ -77,6 +82,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const form = document.getElementById("edit-po-form") as HTMLFormElement;
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -93,6 +99,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
setError(result.error);
setSubmitting(null);
} else {
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
}
@ -113,7 +120,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
const extPo = po;
return (
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
{canResubmit && (
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
<p className="text-sm font-medium text-warning-700">
@ -177,7 +184,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={setDefaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
groups={accounts}
placeholder="Search accounting code…"
required
@ -243,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor
items={lineItems}
onChange={setLineItems}
onChange={(v) => { setLineItems(v); markDirty(); }}
multiAccount={multiAccount}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
@ -253,55 +260,14 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
{/* Vendor */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
</section>
{/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms &amp; Conditions</h2>
<div className="space-y-3">
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right">{n}.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
<TermsField
field={name}
options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []}
current={extPo[key] ?? TC_DEFAULTS[key]}
className={INPUT_CLS}
/>
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
<textarea
name="tcOthers"
rows={2}
defaultValue={extPo.tcOthers ?? ""}
className={INPUT_CLS}
/>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">8.</span> {TC_FIXED_LINE_2}
</div>
</div>
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
</section>
{error && (
@ -330,6 +296,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
</button>
)}
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("save")}
saving={submitting === "save"}
/>
</form>
);
}

View file

@ -4,7 +4,8 @@ import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next";
@ -52,7 +53,9 @@ export default async function EditPoPage({ params }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -79,7 +82,8 @@ export default async function EditPoPage({ params }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
managerNoteAuthor={noteAction?.actor.name ?? null}
/>
</div>

View file

@ -2,7 +2,7 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildStorageKey, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage";
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
type Result = { ok: true; mailto: string; to: string } | { error: string };
@ -47,13 +47,20 @@ export async function prepareVendorEmail(poId: string): Promise<Result> {
return { error: "PDF emailing is not configured on this environment." };
}
// Render → store → presigned link.
// Render → store → presigned link. The PDF is cached at a deterministic
// per-PO key: if a copy already exists and is at least as new as the PO's last
// change, reuse it and only mint a fresh presigned URL (refreshing the 7-day
// timer). Re-render only when there's no copy yet or the PO changed since.
let link: string;
try {
const pdf = await renderPoPdf(poId);
const slug = po.poNumber.replace(/\//g, "-");
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
await uploadBuffer(key, pdf, "application/pdf");
const key = buildPoPdfKey(poId, `${slug}.pdf`);
const cached = await statObject(key);
const isFresh = cached !== null && cached.lastModified >= po.updatedAt;
if (!isFresh) {
const pdf = await renderPoPdf(poId);
await uploadBuffer(key, pdf, "application/pdf");
}
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
} catch (e) {
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };

View file

@ -140,7 +140,7 @@ export async function confirmReceipt({
if (newStatus === "CLOSED" && po.vendorId) {
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath("/catalogue/vendors");
}
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });

View file

@ -189,7 +189,7 @@ export async function importPo(
if (resolvedVendorId) {
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
revalidatePath("/admin/vendors");
revalidatePath("/inventory/vendors");
revalidatePath("/catalogue/vendors");
}
revalidatePath("/history");

View file

@ -4,6 +4,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { requirePermission } from "@/lib/permissions";
import { createPoSchema } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { generatePoNumber } from "@/lib/po-number";
import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache";
@ -77,6 +78,11 @@ export async function createPo(
}
const data = parsed.data;
// Dynamic T&C rows (issue #11) — a JSON snapshot superseding the tc* columns.
let termsRaw: unknown = [];
try { termsRaw = JSON.parse((formData.get("termsJson") as string) || "[]"); } catch { /* ignore */ }
const terms = parsePoTerms(termsRaw);
// totalAmount = grand total including GST
const total = data.lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
@ -108,6 +114,7 @@ export async function createPo(
tcTransitInsurance: data.tcTransitInsurance ?? null,
tcPaymentTerms: data.tcPaymentTerms ?? null,
tcOthers: data.tcOthers ?? null,
terms,
submitterId: session.user.id,
submittedAt: intent === "submit" ? new Date() : null,
lineItems: {

View file

@ -7,12 +7,13 @@ import type { Vendor } from "@prisma/client";
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { FileUploader } from "@/components/po/file-uploader";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { VendorSelect } from "@/components/ui/vendor-select";
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
import { TermsField } from "@/components/po/terms-field";
import { TC_FIELD_CATEGORY, type TermsByCategory } from "@/lib/terms";
import { PoTermsEditor } from "@/components/po/po-terms-editor";
import { UnsavedChangesGuard } from "@/components/po/unsaved-changes-guard";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
import { uploadAndLinkFiles } from "@/lib/upload-files";
import type { LineItemInput } from "@/lib/validations/po";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
export type VesselOption = { id: string; code: string; name: string };
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
@ -29,24 +30,27 @@ interface Props {
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsByCategory: TermsByCategory;
termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
}
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsByCategory, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
const router = useRouter();
const [lineItems, setLineItems] = useState<LineItemInput[]>(
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
);
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
const [files, setFiles] = useState<File[]>([]);
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
const [error, setError] = useState("");
const [multiAccount, setMultiAccount] = useState(false);
const [defaultAccountId, setDefaultAccountId] = useState("");
const [terms, setTerms] = useState<PoTerm[]>(defaultTerms);
const [dirty, setDirty] = useState(false);
const markDirty = () => setDirty(true);
async function handleSubmit(intent: "draft" | "submit") {
setSubmitting(intent);
@ -54,6 +58,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
const form = document.getElementById("po-form") as HTMLFormElement;
const data = new FormData(form);
data.set("intent", intent);
data.set("termsJson", JSON.stringify(terms));
lineItems.forEach((item, i) => {
data.set(`lineItems[${i}].name`, item.name);
data.set(`lineItems[${i}].description`, item.description ?? "");
@ -80,11 +85,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
return;
}
}
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
return (
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
{/* Order Information */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
@ -141,7 +147,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={setDefaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
groups={accounts}
placeholder="Search accounting code…"
required
@ -213,7 +219,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
<LineItemsEditor
items={lineItems}
onChange={setLineItems}
onChange={(v) => { setLineItems(v); markDirty(); }}
multiAccount={multiAccount}
accounts={accounts}
defaultAccountId={defaultAccountId || undefined}
@ -227,57 +233,21 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
Vendor (optional can be added later)
</label>
<select
name="vendorId"
value={vendorId}
onChange={(e) => setVendorId(e.target.value)}
className={INPUT_CLS}
>
<option value="">No vendor selected</option>
{vendors.map((v) => (
<option key={v.id} value={v.id}>
{v.name} {v.vendorId ? `(${v.vendorId})` : "(unverified)"}
</option>
))}
</select>
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
</div>
</section>
{/* Terms & Conditions */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms &amp; Conditions</h2>
<div className="space-y-3">
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">1.</span> {TC_FIXED_LINE}
</div>
{([
{ n: 2, label: "Delivery", name: "tcDelivery", key: "tcDelivery" },
{ n: 3, label: "Dispatch Instructions", name: "tcDispatch", key: "tcDispatch" },
{ n: 4, label: "Inspection", name: "tcInspection", key: "tcInspection" },
{ n: 5, label: "Transit Insurance", name: "tcTransitInsurance", key: "tcTransitInsurance" },
{ n: 6, label: "Payment Terms", name: "tcPaymentTerms", key: "tcPaymentTerms" },
] as const).map(({ n, label, name, key }) => (
<div key={name} className="flex items-center gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right">{n}.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700">{label}</label>
<TermsField field={name} options={termsByCategory[TC_FIELD_CATEGORY[name]] ?? []} current={TC_DEFAULTS[key]} className={INPUT_CLS} />
</div>
))}
<div className="flex items-start gap-3">
<span className="w-5 shrink-0 text-sm font-medium text-neutral-500 text-right mt-2.5">7.</span>
<label className="w-44 shrink-0 text-sm font-medium text-neutral-700 mt-2.5">Others</label>
<textarea name="tcOthers" rows={2} defaultValue="" className={INPUT_CLS} />
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
<span className="font-medium text-neutral-600">8.</span> {TC_FIXED_LINE_2}
</div>
</div>
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms &amp; Conditions</h2>
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause. Manage the catalogue under Administration Terms &amp; Conditions.</p>
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
</section>
{/* Attachments */}
<section className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
</section>
{error && (
@ -302,6 +272,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
</button>
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("draft")}
saving={submitting === "draft"}
/>
</form>
);
}

View file

@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -62,7 +62,7 @@ export default async function NewPoPage({ searchParams }: Props) {
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsByCategory = await getActiveTermsByCategory();
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
return (
<div className="max-w-6xl">
@ -78,7 +78,8 @@ export default async function NewPoPage({ searchParams }: Props) {
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsByCategory={termsByCategory}
termsCatalogue={termsCatalogue}
defaultTerms={defaultTerms}
initialLineItems={initialLineItems}
initialVendorId={initialVendorId}
initialVesselId={initialVesselId}

View file

@ -0,0 +1,194 @@
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
accountNodeSpend,
accountNodeWeekly,
costCentresForAccount,
childBreakdown,
parseGranularity,
resolveFy,
resolveMonth,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Accounting Code — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const tierBadgeCls: Record<string, string> = {
Heading: "bg-primary-50 text-primary-700",
"Sub-heading": "bg-violet-50 text-violet-700",
Leaf: "bg-neutral-100 text-neutral-600",
};
export default async function AccountingCodeDetail({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ fy?: string; gran?: string; month?: string; break?: string; topn?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const { id } = await params;
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const node = idx.byId.get(id);
if (!node) notFound();
const gran = parseGranularity(sp.gran);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const unit = yearly ? "year" : weekly ? "week" : "month";
const leaf = idx.isLeaf(id);
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
const breakMode = leaf ? "cc" : sp.break === "cc" ? "cc" : "children";
const spend = accountNodeSpend(ds, idx, id, fy);
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const series = yearly
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: spend.fyTotals[i] }))
: weekly
? WEEK_LABELS.map((w, i) => ({ label: w, value: accountNodeWeekly(ds, idx, id, fy, month)[i] }))
: FY_MONTHS.map((m, i) => ({ label: m, value: spend.months[i] }));
const total = sum(series.map((s) => s.value));
const avg = series.length ? total / series.length : 0;
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
const nf = ds.fys.length;
const yoy = nf >= 2 && spend.fyTotals[nf - 2] ? ((spend.fyTotals[nf - 1] - spend.fyTotals[nf - 2]) / spend.fyTotals[nf - 2]) * 100 : 0;
const childTier = idx.childrenOf(id)[0]?.tier ?? "Sub-heading";
const breakdown = (breakMode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy)).slice(0, topn);
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
const breakLabel = breakMode === "cc" ? "Cost centre" : childTier;
const breakTitle = breakMode === "cc" ? "Top cost centres" : "Composition by sub-account";
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base = `/reports/accounting-codes/${id}`;
const q = (extra: Record<string, string>) => {
const p = new URLSearchParams({ fy: String(fy), gran });
if (weekly) p.set("month", String(month));
for (const [k, v] of Object.entries(extra)) p.set(k, v);
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=accounting-code-detail&id=${id}&fy=${fy}&gran=${gran}&break=${breakMode}`;
const path = idx.pathTo(id);
const trail = [
{ label: "Accounting Codes", href: `/reports/accounting-codes?fy=${fy}&gran=${gran}` },
...path.map((a, i) => ({
label: `${a.code} · ${a.name}`,
href: i < path.length - 1 ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${a.id}` : undefined,
})),
];
return (
<div>
<ReportBreadcrumb trail={trail} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
<Link
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to Accounting Codes
</Link>
<ReportTitle
title={`${node.code} · ${node.name}`}
subtitle={`Aggregates all spend under this ${node.tier.toLowerCase()} · ${periodLabel}`}
badge={<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${tierBadgeCls[node.tier]}`}>{node.tier}</span>}
/>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-neutral-900">{breakTitle}</p>
<div className="flex flex-wrap items-center gap-3">
{!leaf && (
<SegLink
label="Break down by"
options={[{ value: "children", label: `${childTier}s` }, { value: "cc", label: "Cost centres" }]}
current={breakMode}
hrefFor={(v) => q({ break: v, topn: sp.topn ?? "5" })}
/>
)}
<SegLink
label="Top"
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
hrefFor={(v) => q({ break: breakMode, topn: v })}
/>
</div>
</div>
{breakdown.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">No spend to break down for {periodLabel}.</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<BreakdownChart data={breakdown} />
</div>
<div className="lg:col-span-2">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="py-2">{breakLabel}</th>
<th className="py-2 text-right">Spend</th>
<th className="py-2 text-right">%</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{breakdown.map((b, i) => (
<tr key={b.id}>
<td className="py-2">
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{b.label}
</td>
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,220 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight, BarChart3 } from "lucide-react";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
accountLevelRows,
accountNodeSpend,
accountNodeWeekly,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
SCOPE_LABELS,
type NodeSpend,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Accounting Codes — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const tierBadgeCls: Record<string, string> = {
Heading: "bg-primary-50 text-primary-700",
"Sub-heading": "bg-violet-50 text-violet-700",
Leaf: "bg-neutral-100 text-neutral-600",
};
export default async function AccountingCodesReport({
searchParams,
}: {
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; parent?: string; sel?: string; cmp?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.gran);
const scope = parseScope(sp.scope);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const sel = parseSel(sp.sel);
const cmp = sp.cmp === "1" && sel.length > 0;
const parent = sp.parent && idx.byId.has(sp.parent) ? sp.parent : null;
const parentNode = parent ? idx.byId.get(parent)! : null;
const rankOf = (r: NodeSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
const sparkOf = (r: NodeSpend) => (yearly ? r.fyTotals : weekly ? accountNodeWeekly(ds, idx, r.node.id, fy, month) : r.months);
const ranked = cmp
? sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }))
: accountLevelRows(ds, idx, parent, fy);
ranked.sort((a, b) => rankOf(b) - rankOf(a));
const shown = cmp ? ranked : applyScope(ranked, scope);
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
const childTier = shown[0]?.node.tier ?? "Heading";
const top = shown[0];
const nf = ds.fys.length;
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
// One distinct colour per accounting code (series) in every granularity; the
// x-axis is months / weeks / financial years.
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
const row: Record<string, string | number> = { x: lab };
shown.forEach((r) => (row[r.node.code] = sparkOf(r)[i]));
return row;
});
const series: Series[] = shown.map((r, i) => ({ key: r.node.code, color: colored(i) }));
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base: Record<string, string | undefined> = {
fy: String(fy),
gran: gran === "monthly" ? undefined : gran,
scope: scope === "top5" ? undefined : scope,
month: weekly ? String(month) : undefined,
};
const qs = (extra: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...base, ...extra })) if (v) p.set(k, v);
const s = p.toString();
return s ? `?${s}` : "";
};
const linkWith = (parentId: string | null) => `/reports/accounting-codes${qs({ parent: parentId ?? undefined, sel: sel.join(",") || undefined })}`;
const detailHref = (id: string) => `/reports/accounting-codes/${id}${qs({ scope: undefined, parent: undefined })}`;
const selHref = (id: string) => {
const next = toggleSel(sel, id);
return `/reports/accounting-codes${qs({ parent: cmp ? undefined : parent ?? undefined, sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
};
const rowHref = (r: NodeSpend) => (idx.isLeaf(r.node.id) ? detailHref(r.node.id) : linkWith(r.node.id));
const exportHref = `/api/reports/spend?dim=accounting-code&fy=${fy}&gran=${gran}&scope=${scope}${parent && !cmp ? `&parent=${parent}` : ""}${cmp ? `&sel=${sel.join(",")}` : ""}`;
const trail = [{ label: "Accounting Codes", href: parent || cmp ? linkWith(null) : undefined }];
if (parentNode && !cmp) {
idx.pathTo(parentNode.id).forEach((a, i, arr) => trail.push({ label: `${a.code} · ${a.name}`, href: i < arr.length - 1 ? linkWith(a.id) : undefined }));
}
return (
<div>
<ReportBreadcrumb trail={trail} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
scope={cmp ? undefined : scope}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
{cmp ? (
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to browse
</Link>
) : (
<>
{parentNode && (
<Link
href={linkWith(parentNode.parentId ?? null)}
className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
>
Back to {parentNode.parentId ? idx.byId.get(parentNode.parentId)!.name : "Accounting Codes"}
</Link>
)}
{sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ parent: parent ?? undefined })} />}
</>
)}
<ReportTitle
title={cmp ? "Custom comparison" : parentNode ? `${parentNode.code} · ${parentNode.name}` : "Accounting Codes"}
subtitle={
cmp
? `Comparing ${shown.length} selected accounting codes. Untick a row to remove it.`
: parentNode
? `Comparing the ${childTier.toLowerCase()}s of ${parentNode.name}. Tick to graph, or click to ${childTier === "Leaf" ? "open its report" : "drill deeper"}.`
: "Comparing top-level headings. Tick to graph, or click a heading to drill in."
}
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
<Kpi label={cmp ? "Selected" : `${childTier}s`} value={String(shown.length)} sub={cmp ? "in this graph" : `${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top ? top.node.code : "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? `Spend by ${childTier.toLowerCase()} — year over year` : weekly ? `Weekly spend by ${childTier.toLowerCase()}` : `Monthly spend by ${childTier.toLowerCase()}`}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
const leaf = idx.isLeaf(r.node.id);
const inner = (
<>
<span className="w-14 shrink-0 font-mono text-xs text-neutral-500">{r.node.code}</span>
<span className="flex-1 truncate text-sm font-medium text-neutral-900 group-hover:text-primary-700">{r.node.name}</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${tierBadgeCls[r.node.tier]}`}>{r.node.tier}</span>
<Sparkline values={sparkOf(r)} width={80} height={24} />
<span className="w-28 text-right font-medium tabular-nums text-sm">{formatCurrency(value)}</span>
<div className="hidden w-12 text-right tabular-nums text-xs text-neutral-500 md:block">{pct.toFixed(0)}%</div>
{!cmp && (leaf ? <BarChart3 className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" /> : <ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 group-hover:text-primary-500" />)}
</>
);
return (
<div key={r.node.id} className="group flex items-center gap-3 border-b border-neutral-100 px-5 py-3 last:border-0 hover:bg-primary-50/40">
<SelectCheckbox checked={sel.includes(r.node.id)} href={selHref(r.node.id)} />
{cmp ? (
<div className="flex flex-1 items-center gap-3 min-w-0">{inner}</div>
) : (
<Link href={rowHref(r)} className="flex flex-1 items-center gap-3 min-w-0">{inner}</Link>
)}
</div>
);
})}
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,162 @@
import { auth } from "@/auth";
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
topAccountsForCostCentre,
parseGranularity,
resolveFy,
resolveMonth,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
type Tier,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { TrendChart, BreakdownChart } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SegLink } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Cost Centre — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const TIERS: Tier[] = ["Heading", "Sub-heading", "Leaf"];
export default async function CostCentreDetail({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ fy?: string; gran?: string; month?: string; tier?: string; topn?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const { id } = await params;
const sp = await searchParams;
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.gran);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const unit = yearly ? "year" : weekly ? "week" : "month";
const tier: Tier = TIERS.includes(sp.tier as Tier) ? (sp.tier as Tier) : "Leaf";
const topn = sp.topn === "10" ? 10 : sp.topn === "all" ? 9999 : 5;
const row = costCentreRows(ds, fy).find((r) => r.id === id);
if (!row) notFound();
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const series = yearly
? ds.fys.map((y, i) => ({ label: fyLabel(y), value: row.fyTotals[i] }))
: weekly
? WEEK_LABELS.map((w, i) => ({ label: w, value: costCentreWeekly(ds, id, fy, month)[i] }))
: FY_MONTHS.map((m, i) => ({ label: m, value: row.months[i] }));
const total = sum(series.map((s) => s.value));
const avg = series.length ? total / series.length : 0;
const peak = series.reduce((best, s) => (s.value > best.value ? s : best), series[0] ?? { label: "—", value: 0 });
const nf = ds.fys.length;
const yoy = nf >= 2 && row.fyTotals[nf - 2] ? ((row.fyTotals[nf - 1] - row.fyTotals[nf - 2]) / row.fyTotals[nf - 2]) * 100 : 0;
const breakdown = topAccountsForCostCentre(ds, idx, id, fy, tier).slice(0, topn);
const breakTotal = sum(breakdown.map((b) => b.value)) || 1;
const periodLabel = yearly ? `${ds.fys.length} FYs` : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
const base = `/reports/cost-centres/${id}`;
const q = (extra: Record<string, string>) => {
const p = new URLSearchParams({ fy: String(fy), gran });
if (weekly) p.set("month", String(month));
for (const [k, v] of Object.entries(extra)) p.set(k, v);
return `${base}?${p.toString()}`;
};
const exportHref = `/api/reports/spend?dim=cost-centre-detail&id=${id}&fy=${fy}&gran=${gran}&tier=${tier}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres", href: `/reports/cost-centres?fy=${fy}&gran=${gran}` }, { label: row.name }]} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to Cost Centres
</Link>
<ReportTitle title={row.name} subtitle={`Approved spend · ${periodLabel}`} />
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(total)} sub={periodLabel} />
<Kpi label={`Avg / ${unit}`} value={formatCompactINR(avg)} />
<Kpi label={`Peak ${unit}`} value={peak.label} sub={formatCompactINR(peak.value)} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<p className="mb-4 text-sm font-semibold text-neutral-900">Spend trend</p>
<TrendChart kind={yearly ? "bar" : "line"} data={series} />
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-semibold text-neutral-900">Top accounting codes</p>
<div className="flex flex-wrap items-center gap-3">
<SegLink label="Tier" options={TIERS.map((t) => ({ value: t, label: t }))} current={tier} hrefFor={(v) => q({ tier: v, topn: sp.topn ?? "5" })} />
<SegLink
label="Top"
options={[{ value: "5", label: "5" }, { value: "10", label: "10" }, { value: "all", label: "All" }]}
current={sp.topn === "10" ? "10" : sp.topn === "all" ? "all" : "5"}
hrefFor={(v) => q({ tier, topn: v })}
/>
</div>
</div>
{breakdown.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-400">No spend at this tier for {periodLabel}.</p>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<BreakdownChart data={breakdown} />
</div>
<div className="lg:col-span-2">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="py-2">{tier}</th>
<th className="py-2 text-right">Spend</th>
<th className="py-2 text-right">%</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{breakdown.map((b, i) => (
<tr key={b.id}>
<td className="py-2">
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-sm align-middle" style={{ background: SERIES_COLORS[i % SERIES_COLORS.length] }} />
{b.label}
</td>
<td className="py-2 text-right font-medium tabular-nums">{formatCurrency(b.value)}</td>
<td className="py-2 text-right tabular-nums text-neutral-500">{((b.value / breakTotal) * 100).toFixed(0)}%</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import type { Metadata } from "next";
import { ChevronRight } from "lucide-react";
import { hasPermission } from "@/lib/permissions";
import { formatCurrency, formatCompactINR } from "@/lib/utils";
import {
getReportDataset,
costCentreRows,
costCentreWeekly,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
fyLabel,
FY_MONTHS,
WEEK_LABELS,
SCOPE_LABELS,
type CostCentreSpend,
} from "@/lib/reports";
import { ReportsToolbar } from "@/components/reports/reports-toolbar";
import { ComparisonChart, Sparkline, type Series } from "@/components/reports/charts";
import { SERIES_COLORS } from "@/lib/report-colors";
import { Kpi, KpiStrip } from "@/components/reports/kpi";
import { ReportBreadcrumb, ReportTitle, SelectCheckbox, CompareBar } from "@/components/reports/report-header";
export const metadata: Metadata = { title: "Cost Centres — Reports" };
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
export default async function CostCentresReport({
searchParams,
}: {
searchParams: Promise<{ fy?: string; gran?: string; scope?: string; month?: string; sel?: string; cmp?: string }>;
}) {
const session = await auth();
if (!session?.user) return null;
if (!hasPermission(session.user.role, "view_analytics")) redirect("/dashboard");
const sp = await searchParams;
const ds = await getReportDataset();
const gran = parseGranularity(sp.gran);
const scope = parseScope(sp.scope);
const fy = resolveFy(ds, sp.fy);
const yearly = gran === "yearly";
const weekly = gran === "weekly";
const month = resolveMonth(ds, fy, sp.month);
const sel = parseSel(sp.sel);
const cmp = sp.cmp === "1" && sel.length > 0;
const ranked = costCentreRows(ds, fy);
const rankOf = (r: CostCentreSpend) => (yearly ? sum(r.fyTotals) : weekly ? r.months[month] : r.total);
ranked.sort((a, b) => rankOf(b) - rankOf(a));
const shown = cmp ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
const grand = shown.reduce((s, r) => s + rankOf(r), 0);
const top = shown[0];
const sparkOf = (r: CostCentreSpend) => (yearly ? r.fyTotals : weekly ? costCentreWeekly(ds, r.id, fy, month) : r.months);
const nf = ds.fys.length;
const curT = nf >= 1 ? shown.reduce((s, r) => s + r.fyTotals[nf - 1], 0) : 0;
const prevT = nf >= 2 ? shown.reduce((s, r) => s + r.fyTotals[nf - 2], 0) : 0;
const yoy = prevT ? ((curT - prevT) / prevT) * 100 : 0;
// Chart data — one distinct colour per item (series) in every granularity; the
// x-axis is months / weeks / financial years. (Yearly is grouped bars per item,
// not per FY, so each cost centre keeps its own colour.)
const colored = (i: number) => SERIES_COLORS[i % SERIES_COLORS.length];
const chartLabels = yearly ? ds.fys.map(fyLabel) : weekly ? [...WEEK_LABELS] : [...FY_MONTHS];
const chartData: Record<string, string | number>[] = chartLabels.map((lab, i) => {
const row: Record<string, string | number> = { x: lab };
shown.forEach((r) => (row[r.name] = sparkOf(r)[i]));
return row;
});
const series: Series[] = shown.map((r, i) => ({ key: r.name, color: colored(i) }));
const monthLabel = (i: number) => `${FY_MONTHS[i]} '${String((fy + (i >= 9 ? 1 : 0)) % 100).padStart(2, "0")}`;
const periodLabel = yearly ? ds.fys.map(fyLabel).join(" · ") : weekly ? `${monthLabel(month)} · ${fyLabel(fy)}` : fyLabel(fy);
// Query-string helpers (preserve current filters).
const baseParams: Record<string, string | undefined> = {
fy: String(fy),
gran: gran === "monthly" ? undefined : gran,
scope: scope === "top5" ? undefined : scope,
month: weekly ? String(month) : undefined,
};
const qs = (extra: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ ...baseParams, ...extra })) if (v) p.set(k, v);
const s = p.toString();
return s ? `?${s}` : "";
};
const selHref = (id: string) => {
const next = toggleSel(sel, id);
return `/reports/cost-centres${qs({ sel: next.join(",") || undefined, cmp: cmp && next.length ? "1" : undefined })}`;
};
const detailHref = (id: string) => `/reports/cost-centres/${id}${qs({ scope: undefined })}`;
const exportHref = `/api/reports/spend?dim=cost-centre&fy=${fy}&gran=${gran}&scope=${scope}${cmp ? `&sel=${sel.join(",")}` : ""}`;
return (
<div>
<ReportBreadcrumb trail={[{ label: "Cost Centres" }]} />
<ReportsToolbar
fys={ds.fys}
fy={fy}
gran={gran}
scope={cmp ? undefined : scope}
month={month}
monthOptions={FY_MONTHS.map((_, i) => ({ value: i, label: monthLabel(i) }))}
exportHref={exportHref}
/>
{cmp ? (
<Link href={qs({ sel: sel.join(",") })} className="mb-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
Back to browse
</Link>
) : (
sel.length > 0 && <CompareBar count={sel.length} compareHref={qs({ sel: sel.join(","), cmp: "1" })} clearHref={qs({ sel: undefined, cmp: undefined })} />
)}
<ReportTitle
title={cmp ? "Custom comparison" : "Cost Centres"}
subtitle={
cmp
? `Comparing ${shown.length} selected cost centres. Untick a row to remove it.`
: "Approved spend compared across cost centres (vessels). Tick rows to graph together, or click a row for its report."
}
/>
{grand === 0 ? (
<div className="rounded-lg border border-dashed border-neutral-300 bg-white p-10 text-center text-sm text-neutral-500">
No approved spend recorded for {periodLabel} yet.
</div>
) : (
<>
<KpiStrip>
<Kpi label="Total spend" value={formatCompactINR(grand)} sub={periodLabel} />
<Kpi label="Cost centres" value={String(shown.length)} sub={cmp ? "selected" : `${SCOPE_LABELS[scope]} shown`} />
<Kpi label="Highest spender" value={top?.name ?? "—"} sub={top ? formatCompactINR(rankOf(top)) : ""} />
<Kpi label="YoY change" value={`${yoy >= 0 ? "+" : ""}${yoy.toFixed(1)}%`} sub="vs prior FY" delta={yoy} />
</KpiStrip>
<div className="mb-6 rounded-lg border border-neutral-200 bg-white p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-neutral-900">
{yearly ? "Spend by cost centre — year over year" : weekly ? "Weekly spend by cost centre" : "Monthly spend by cost centre"}
</p>
<span className="text-xs text-neutral-400">{periodLabel}</span>
</div>
<ComparisonChart kind={yearly ? "bars" : "lines"} data={chartData} xKey="x" series={series} />
</div>
<div className="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
<tr>
<th className="px-5 py-3">Cost Centre</th>
<th className="px-5 py-3">Trend</th>
<th className="px-5 py-3 text-right">Total Spend</th>
<th className="px-5 py-3 text-right">% of Shown</th>
<th className="px-5 py-3 text-right">POs</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{shown.map((r) => {
const value = rankOf(r);
const pct = grand ? (value / grand) * 100 : 0;
return (
<tr key={r.id} className="group hover:bg-primary-50/40">
<td className="px-5 py-3">
<div className="flex items-center gap-3">
<SelectCheckbox checked={sel.includes(r.id)} href={selHref(r.id)} />
<Link href={detailHref(r.id)} className="block font-medium text-neutral-900 group-hover:text-primary-700">
{r.name}
<span className="ml-2 text-xs font-normal text-neutral-400">{r.code}</span>
</Link>
</div>
</td>
<td className="px-5 py-3">
<Sparkline values={sparkOf(r)} />
</td>
<td className="px-5 py-3 text-right font-medium tabular-nums">{formatCurrency(value)}</td>
<td className="px-5 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-neutral-100">
<div className="h-full rounded-full bg-primary-600" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className="w-10 text-right tabular-nums text-neutral-500">{pct.toFixed(0)}%</span>
</div>
</td>
<td className="px-5 py-3 text-right tabular-nums text-neutral-500">{r.poCount}</td>
<td className="px-5 py-3 text-right">
<Link href={detailHref(r.id)}>
<ChevronRight className="inline h-4 w-4 text-neutral-300 group-hover:text-primary-500" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</div>
);
}

View file

@ -3,6 +3,7 @@ import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size";
@ -182,15 +183,21 @@ export async function GET(request: NextRequest, { params }: Props) {
const reqDate = fmtDate(ext.requisitionDate);
const delivery = ext.placeOfDelivery ?? "";
const tcLines: [number, string, string][] = [
[1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
[4, "INSPECTION", ext.tcInspection ?? TC_DEFAULTS.tcInspection],
[5, "TRANSIT INSURANCE", ext.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance],
[6, "PAYMENT TERMS", ext.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms],
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
];
// T&C (issue #11): prefer the dynamic snapshot (po.terms) when present; older
// POs fall back to the legacy tc* columns + the fixed boilerplate lines.
const dynamicTerms = parsePoTerms((po as { terms?: unknown }).terms);
const tcLines: [number, string, string][] =
dynamicTerms.length > 0
? dynamicTerms.map((t, i) => [i + 1, (t.category || "").toUpperCase(), t.text] as [number, string, string])
: [
[1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
[4, "INSPECTION", ext.tcInspection ?? TC_DEFAULTS.tcInspection],
[5, "TRANSIT INSURANCE", ext.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance],
[6, "PAYMENT TERMS", ext.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms],
...(ext.tcOthers ? [[7, "OTHERS", ext.tcOthers] as [number, string, string]] : []),
];
const vendorAddr = [
po.vendor?.address,

View file

@ -0,0 +1,97 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import {
getReportDataset,
buildAccountIndex,
costCentreRows,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
accountNodeSpend,
applyScope,
parseScope,
parseGranularity,
parseSel,
resolveFy,
fyLabel,
type Tier,
} from "@/lib/reports";
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
const cell = (v: string | number) => {
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
function csv(headers: string[], rows: (string | number)[][]): string {
return [headers, ...rows].map((r) => r.map(cell).join(",")).join("\n");
}
function file(name: string, body: string) {
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${name}-${Date.now()}.csv"`,
},
});
}
// CSV export for the Reports → Purchasing views. The `dim` query param mirrors
// the page the user is on, so the download matches what's on screen.
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "view_analytics")) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const sp = req.nextUrl.searchParams;
const dim = sp.get("dim") ?? "cost-centre";
const ds = await getReportDataset();
const idx = buildAccountIndex(ds.accounts);
const gran = parseGranularity(sp.get("gran") ?? undefined);
const scope = parseScope(sp.get("scope") ?? undefined);
const fy = resolveFy(ds, sp.get("fy") ?? undefined);
const yearly = gran === "yearly";
const fyCols = ds.fys.map(fyLabel);
const sel = parseSel(sp.get("sel") ?? undefined);
if (dim === "cost-centre") {
const ranked = costCentreRows(ds, fy).sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const picked = sel.length ? ranked.filter((r) => sel.includes(r.id)) : applyScope(ranked, scope);
const rows = picked.map((r) => [r.code, r.name, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-cost-centre-spend", csv(["Code", "Cost Centre", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "accounting-code") {
let ranked;
if (sel.length) {
ranked = sel.filter((id) => idx.byId.has(id)).map((id) => ({ node: idx.byId.get(id)!, ...accountNodeSpend(ds, idx, id, fy) }));
} else {
const parent = sp.get("parent");
const parentId = parent && idx.byId.has(parent) ? parent : null;
ranked = accountLevelRows(ds, idx, parentId, fy);
}
ranked.sort((a, b) => (yearly ? sum(b.fyTotals) - sum(a.fyTotals) : b.total - a.total));
const picked = sel.length ? ranked : applyScope(ranked, scope);
const rows = picked.map((r) => [r.node.code, r.node.name, r.node.tier, ...r.fyTotals, r.total, r.poCount]);
return file("pelagia-accounting-code-spend", csv(["Code", "Name", "Tier", ...fyCols, `${fyLabel(fy)} Total`, "POs"], rows));
}
if (dim === "cost-centre-detail") {
const id = sp.get("id") ?? "";
const tier = (["Heading", "Sub-heading", "Leaf"] as Tier[]).includes(sp.get("tier") as Tier) ? (sp.get("tier") as Tier) : "Leaf";
const rows = topAccountsForCostCentre(ds, idx, id, fy, tier).map((b) => [b.label, b.value]);
return file("pelagia-cost-centre-detail", csv([tier, `Spend (${fyLabel(fy)})`], rows));
}
if (dim === "accounting-code-detail") {
const id = sp.get("id") ?? "";
const leaf = idx.isLeaf(id);
const mode = leaf || sp.get("break") === "cc" ? "cc" : "children";
const bd = mode === "cc" ? costCentresForAccount(ds, idx, id, fy) : childBreakdown(ds, idx, id, fy);
const rows = bd.map((b) => [b.label, b.value]);
return file("pelagia-accounting-code-detail", csv([mode === "cc" ? "Cost centre" : "Sub-account", `Spend (${fyLabel(fy)})`], rows));
}
return NextResponse.json({ error: "Unknown report dimension" }, { status: 400 });
}

View file

@ -56,36 +56,50 @@ const HISTORY_ROLES: Role[] = [
const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
{ href: "/history", label: "History", icon: History, roles: HISTORY_ROLES },
{ href: "/profile", label: "My Profile", icon: UserCircle },
];
// ── Purchasing section ────────────────────────────────────────────────────────
// Purchase Order actions (create / browse / import / history)
const PURCHASING_PO: NavItem[] = [
{ href: "/po/new", label: "New Purchase Order", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import Purchase Order", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/history", label: "Purchase Order History", icon: History, roles: HISTORY_ROLES },
];
// Staff browsing items (product catalogue + cart for PO creation)
const PURCHASING_STAFF: NavItem[] = [
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
];
// Manager catalogue management — Sites conditionally shown
// Admin does not use Purchasing; their links live under Administration
const PURCHASING_MGMT: NavItem[] = [
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
...(INVENTORY_ENABLED
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
: []),
];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Reports section ───────────────────────────────────────────────────────────
// Spend analytics, gated by `view_analytics` (Manager / SuperUser / Auditor /
// Admin). Links are grouped under a "Purchasing" subheading so other domains
// (e.g. Crewing) can hang their own report groups here later.
const REPORTS_ROLES: Role[] = ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const REPORTS_PURCHASING: NavItem[] = [
{ href: "/reports/cost-centres", label: "Cost Centres", icon: Ship, roles: REPORTS_ROLES },
{ href: "/reports/accounting-codes", label: "Accounting Codes", icon: Building2, roles: REPORTS_ROLES },
];
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
@ -130,10 +144,14 @@ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
];
interface NavGroup {
label?: string; // optional subheading shown above the group's links
items: NavItem[];
}
interface Section {
id: string;
label: string;
items: NavItem[];
groups: NavGroup[];
}
function isItemActive(href: string, pathname: string) {
@ -144,22 +162,29 @@ export function Sidebar({ userRole }: { userRole: Role }) {
const pathname = usePathname();
const isAdmin = userRole === "ADMIN";
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole);
const visibleMain = NAV_ITEMS.filter(visible);
const visiblePurchasing = PURCHASING_ITEMS.filter(visible);
const visibleReports = REPORTS_PURCHASING.filter(visible);
const visibleCrewing = CREWING_ITEMS.filter(visible);
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter(visible);
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
// Headed, collapsible sections (the main links above sit outside any section).
// A section holds one or more groups; a group can carry an optional subheading.
const sections: Section[] = [
{ id: "purchasing", label: "Purchasing", items: visiblePurchasing },
{ id: "crewing", label: "Crewing", items: visibleCrewing },
{ id: "administration", label: "Administration", items: adminItems },
].filter((s) => s.items.length > 0);
{ id: "purchasing", label: "Purchasing", groups: [{ items: visiblePurchasing }] },
{ id: "reports", label: "Reports", groups: [{ label: "Purchasing", items: visibleReports }] },
{ id: "crewing", label: "Crewing", groups: [{ items: visibleCrewing }] },
{ id: "administration", label: "Administration", groups: [{ items: adminItems }] },
]
.map((s) => ({ ...s, groups: s.groups.filter((g) => g.items.length > 0) }))
.filter((s) => s.groups.length > 0);
const sectionItems = (s: Section) => s.groups.flatMap((g) => g.items);
// The section (if any) that holds the currently active route.
const activeSectionId =
sections.find((s) => s.items.some((i) => isItemActive(i.href, pathname)))?.id ?? null;
sections.find((s) => sectionItems(s).some((i) => isItemActive(i.href, pathname)))?.id ?? null;
// Single-open accordion, collapsed by default. Auto-expand the section that
// contains the active route so the user is never stranded on a hidden link.
@ -201,8 +226,17 @@ export function Sidebar({ userRole }: { userRole: Role }) {
/>
{isOpen && (
<div id={regionId} className="space-y-0.5">
{section.items.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
{section.groups.map((group, gi) => (
<div key={group.label ?? gi} className="space-y-0.5">
{group.label && (
<p className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-300">
{group.label}
</p>
)}
{group.items.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</div>
))}
</div>
)}

View file

@ -9,6 +9,7 @@ import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments";
import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client";
@ -38,6 +39,7 @@ type PoWithRelations = {
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
terms?: import("@prisma/client").Prisma.JsonValue;
createdAt: Date;
submittedAt: Date | null;
approvedAt: Date | null;
@ -459,31 +461,41 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
/>
</div>
{/* Terms & Conditions */}
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
<li className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">1.</span>
<span>{TC_FIXED_LINE}</span>
</li>
{([
{ n: 2, label: "DELIVERY", value: po.tcDelivery },
{ n: 3, label: "DISPATCH INSTRUCTIONS", value: po.tcDispatch },
{ n: 4, label: "INSPECTION", value: po.tcInspection },
{ n: 5, label: "TRANSIT INSURANCE", value: po.tcTransitInsurance },
{ n: 6, label: "PAYMENT TERMS", value: po.tcPaymentTerms },
{ n: 7, label: "OTHERS", value: po.tcOthers },
] as const).filter(({ value }) => value).map(({ n, label, value }) => (
<li key={n} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{n}.</span>
<span><span className="font-medium">{label}:</span> {value}</span>
</li>
))}
</ol>
</div>
)}
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
{(() => {
const saved = parsePoTerms(po.terms);
const rows: { label: string; text: string }[] =
saved.length > 0
? saved.map((t) => ({ label: (t.category || "").toUpperCase(), text: t.text }))
: [
{ label: "", text: TC_FIXED_LINE },
...([
["DELIVERY", po.tcDelivery],
["DISPATCH INSTRUCTIONS", po.tcDispatch],
["INSPECTION", po.tcInspection],
["TRANSIT INSURANCE", po.tcTransitInsurance],
["PAYMENT TERMS", po.tcPaymentTerms],
["OTHERS", po.tcOthers],
] as const)
.filter(([, value]) => value)
.map(([label, value]) => ({ label, text: value as string })),
];
// Only the fixed line and nothing else → treat as "no T&C" (legacy empty PO).
if (saved.length === 0 && rows.length <= 1) return null;
return (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
{rows.map((r, i) => (
<li key={i} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span>
<span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</span>
</li>
))}
</ol>
</div>
);
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.length > 0 && (

View file

@ -0,0 +1,100 @@
"use client";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
/**
* Dynamic PO Terms & Conditions editor (issue #11). A list of rows, each a
* category + a clause; "+ Add term" appends a row. Both fields are comboboxes
* (native <input list>) so you can pick a catalogued category/clause or type a
* new one-off value. Controlled by the parent form, which serialises `value`
* into the submitted FormData (`termsJson`).
*/
export function PoTermsEditor({
value,
onChange,
catalogue,
accent = "neutral",
}: {
value: PoTerm[];
onChange: (v: PoTerm[]) => void;
catalogue: CatalogueCategory[];
// The manager-edit form uses an amber theme; everything else neutral.
accent?: "neutral" | "amber";
}) {
const input =
accent === "amber"
? "w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
: "w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
function update(i: number, patch: Partial<PoTerm>) {
onChange(value.map((row, idx) => (idx === i ? { ...row, ...patch } : row)));
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
function add() {
onChange([...value, { category: "", text: "" }]);
}
const clausesFor = (categoryName: string) =>
catalogue.find((c) => c.name.toLowerCase() === categoryName.trim().toLowerCase())?.clauses ?? [];
return (
<div className="space-y-2">
{/* Shared category suggestions */}
<datalist id="po-terms-categories">
{catalogue.map((c) => (
<option key={c.id} value={c.name} />
))}
</datalist>
{value.length === 0 && (
<p className="text-sm text-neutral-400">No terms added. Use + Add term below.</p>
)}
{value.map((row, i) => (
<div key={i} className="flex flex-col gap-2 sm:flex-row sm:items-start">
<input
aria-label="Category"
list="po-terms-categories"
value={row.category}
onChange={(e) => update(i, { category: e.target.value })}
placeholder="Category"
autoComplete="off"
className={`${input} sm:w-56`}
/>
<input
aria-label="Clause"
list={`po-terms-clauses-${i}`}
value={row.text}
onChange={(e) => update(i, { text: e.target.value })}
placeholder="Type a clause or pick one…"
autoComplete="off"
className={input}
/>
<datalist id={`po-terms-clauses-${i}`}>
{clausesFor(row.category).map((c) => (
<option key={c} value={c} />
))}
</datalist>
<button
type="button"
onClick={() => remove(i)}
aria-label="Remove term"
className="shrink-0 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-500 hover:bg-neutral-50"
>
</button>
</div>
))}
<button
type="button"
onClick={add}
className="mt-1 rounded-lg border border-dashed border-neutral-300 px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
+ Add term
</button>
</div>
);
}

View file

@ -1,42 +0,0 @@
"use client";
/**
* A single PO Terms & Conditions slot (issue #11) a combobox: type a one-off
* clause OR pick a catalogued one. Implemented as a native <input list> +
* <datalist> so it stays free-text (custom wording per PO) while suggesting the
* admin-managed clauses for this category, and submits via plain FormData.
*
* `options` are the active clause texts (suggestions). `current` is the PO's
* existing/default value for this slot; it's just the input's initial value, so
* a value not in the catalogue is preserved as-is.
*/
export function TermsField({
field,
options,
current,
className,
}: {
field: string;
options: string[];
current?: string | null;
className?: string;
}) {
const listId = `terms-list-${field}`;
return (
<>
<input
name={field}
list={listId}
defaultValue={current ?? ""}
autoComplete="off"
placeholder="Type a clause or pick one…"
className={className}
/>
<datalist id={listId}>
{options.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</>
);
}

View file

@ -0,0 +1,121 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
interface Props {
/** Arm the guard — true once the form has unsaved changes. */
enabled: boolean;
/** Persist the in-progress PO as a draft. Should navigate away on success. */
onSaveDraft: () => void;
/** True while the draft save is in flight (drives the button label/disable). */
saving: boolean;
}
// Warns the user before they leave a PO form with unsaved changes (issue #18).
// Two paths are covered:
// • Hard navigations (refresh, tab close, external links) → the browser's own
// "Leave site?" prompt (browsers can't render custom buttons here, so the
// save-as-draft option isn't offered on this path).
// • In-app navigations (sidebar / header / any internal <a>) → intercepted and
// replaced with our own modal offering Save as draft / Discard / Stay.
export function UnsavedChangesGuard({ enabled, onSaveDraft, saving }: Props) {
const router = useRouter();
const [pendingHref, setPendingHref] = useState<string | null>(null);
// Listeners are attached once; read `enabled` through a ref so they always see
// the latest value without re-binding on every keystroke.
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
useEffect(() => {
function onBeforeUnload(e: BeforeUnloadEvent) {
if (!enabledRef.current) return;
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onClick(e: MouseEvent) {
if (!enabledRef.current || e.defaultPrevented) return;
// Ignore non-primary clicks and modifier-clicks (new tab / download etc.).
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const anchor = (e.target as HTMLElement | null)?.closest("a");
const href = anchor?.getAttribute("href");
if (!anchor || !href || href.startsWith("#")) return;
if (anchor.hasAttribute("download")) return;
if (anchor.target && anchor.target !== "_self") return;
const url = new URL(href, window.location.href);
// External origin → let the browser's beforeunload prompt handle it.
if (url.origin !== window.location.origin) return;
// Same page (e.g. a no-op link) → nothing to guard.
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
setPendingHref(url.pathname + url.search + url.hash);
}
// Capture phase so we run before Next's <Link> click handler.
document.addEventListener("click", onClick, true);
return () => document.removeEventListener("click", onClick, true);
}, []);
const discard = useCallback(() => {
const href = pendingHref;
setPendingHref(null);
enabledRef.current = false; // let this navigation through
if (href) router.push(href);
}, [pendingHref, router]);
const stay = useCallback(() => {
if (saving) return;
setPendingHref(null);
}, [saving]);
function saveDraft() {
// Close the prompt so any inline save error is visible; the save action
// navigates to the PO on success.
setPendingHref(null);
onSaveDraft();
}
return (
<AdminDialog title="Unsaved changes" open={pendingHref !== null} onClose={stay}>
<div className="space-y-4">
<p className="text-sm text-neutral-600">
You have unsaved changes on this purchase order. Save it as a draft before leaving, or discard your changes?
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={stay}
disabled={saving}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 sm:order-1"
>
Stay on page
</button>
<button
type="button"
onClick={discard}
disabled={saving}
className="rounded-lg border border-danger-200 bg-white px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60 sm:order-2"
>
Discard changes
</button>
<button
type="button"
onClick={saveDraft}
disabled={saving}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors sm:order-3"
>
{saving ? "Saving…" : "Save as draft"}
</button>
</div>
</div>
</AdminDialog>
);
}

View file

@ -0,0 +1,150 @@
"use client";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
CartesianGrid,
Cell,
} from "recharts";
import { SERIES_COLORS } from "@/lib/report-colors";
// Re-exported for back-compat; new server-component code should import the
// palette from "@/lib/report-colors" directly (see that file for why).
export { SERIES_COLORS };
/** Compact Indian-currency formatter for axis ticks / tooltips (₹..K / ₹..L / ₹..Cr). */
export function formatINRShort(n: number): string {
const a = Math.abs(n);
if (a >= 1_00_00_000) return `${(n / 1_00_00_000).toFixed(1)}Cr`;
if (a >= 1_00_000) return `${(n / 1_00_000).toFixed(1)}L`;
if (a >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
return `${n.toFixed(0)}`;
}
function fullINR(n: number): string {
return n.toLocaleString("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
}
export interface Series {
key: string;
color: string;
}
interface ComparisonProps {
kind: "lines" | "bars";
data: Record<string, string | number>[];
xKey: string;
series: Series[];
height?: number;
}
/** Multi-series comparison: monthly trend lines, or year-over-year grouped bars. */
export function ComparisonChart({ kind, data, xKey, series, height = 340 }: ComparisonProps) {
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
return (
<ResponsiveContainer width="100%" height={height}>
{kind === "lines" ? (
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey={xKey} {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} />
<Legend wrapperStyle={{ fontSize: 11 }} iconType="plainline" />
{series.map((s) => (
<Line key={s.key} type="monotone" dataKey={s.key} stroke={s.color} strokeWidth={2} dot={{ r: 2 }} activeDot={{ r: 5 }} />
))}
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey={xKey} {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number, name) => [fullINR(Number(v)), name]} cursor={{ fill: "#f5f5f5" }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{series.map((s) => (
<Bar key={s.key} dataKey={s.key} fill={s.color} radius={[3, 3, 0, 0]} />
))}
</BarChart>
)}
</ResponsiveContainer>
);
}
interface TrendProps {
kind: "line" | "bar";
data: { label: string; value: number }[];
height?: number;
}
/** Single-series spend trend (monthly line or yearly bar). */
export function TrendChart({ kind, data, height = 300 }: TrendProps) {
const axis = { tick: { fontSize: 11, fill: "#737373" }, tickLine: false, axisLine: false } as const;
return (
<ResponsiveContainer width="100%" height={height}>
{kind === "line" ? (
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="label" {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} />
<Line type="monotone" dataKey="value" stroke="#2563eb" strokeWidth={2} dot={{ r: 3 }} fill="rgba(37,99,235,0.08)" />
</LineChart>
) : (
<BarChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" vertical={false} />
<XAxis dataKey="label" {...axis} />
<YAxis tickFormatter={formatINRShort} width={56} {...axis} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} />
</BarChart>
)}
</ResponsiveContainer>
);
}
/** Horizontal top-N breakdown bars (each bar its own colour). */
export function BreakdownChart({ data, height = 300 }: { data: { label: string; value: number }[]; height?: number }) {
const trimmed = data.map((d) => ({ ...d, short: d.label.length > 22 ? d.label.slice(0, 21) + "…" : d.label }));
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart layout="vertical" data={trimmed} margin={{ top: 4, right: 16, bottom: 4, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" horizontal={false} />
<XAxis type="number" tickFormatter={formatINRShort} tick={{ fontSize: 11, fill: "#737373" }} tickLine={false} axisLine={false} />
<YAxis type="category" dataKey="short" width={140} tick={{ fontSize: 11, fill: "#525252" }} tickLine={false} axisLine={false} />
<Tooltip formatter={(v: number) => [fullINR(Number(v)), "Spend"]} cursor={{ fill: "#f5f5f5" }} />
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{trimmed.map((_, i) => (
<Cell key={i} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
/** Tiny inline trend sparkline (plain SVG — no chart library needed per row). */
export function Sparkline({ values, width = 90, height = 28 }: { values: number[]; width?: number; height?: number }) {
if (values.length < 2) return <svg width={width} height={height} />;
const max = Math.max(...values);
const min = Math.min(...values);
const pad = 3;
const span = max - min || 1;
const pts = values.map((v, i) => {
const x = pad + (i / (values.length - 1)) * (width - 2 * pad);
const y = height - pad - ((v - min) / span) * (height - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const last = pts[pts.length - 1].split(",");
return (
<svg width={width} height={height} className="overflow-visible">
<polyline points={pts.join(" ")} fill="none" stroke="#2563eb" strokeWidth={1.5} />
<circle cx={last[0]} cy={last[1]} r={2} fill="#2563eb" />
</svg>
);
}

View file

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
// Presentational KPI tile (server component — no interactivity). `delta` colours
// the sub-line green/red for positive/negative changes (e.g. YoY).
export function Kpi({
label,
value,
sub,
delta,
}: {
label: string;
value: string;
sub?: string;
delta?: number;
}) {
const subColor = delta === undefined ? "text-neutral-400" : delta >= 0 ? "text-green-600" : "text-red-600";
return (
<div className="rounded-lg border border-neutral-200 bg-white p-4">
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">{label}</p>
<p className="mt-1.5 text-xl font-semibold text-neutral-900">{value}</p>
<p className={cn("mt-0.5 text-xs", subColor)}>{sub ?? " "}</p>
</div>
);
}
export function KpiStrip({ children }: { children: React.ReactNode }) {
return <div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">{children}</div>;
}

View file

@ -0,0 +1,103 @@
import Link from "next/link";
import { ChevronRight, Check } from "lucide-react";
// Reports breadcrumb: always rooted at "Reports", then the section and any
// drill/detail crumbs. A crumb with an href is a link; the last is the current.
export function ReportBreadcrumb({ trail }: { trail: { label: string; href?: string }[] }) {
return (
<nav className="mb-4 flex flex-wrap items-center gap-2 text-sm text-neutral-500">
<span>Reports</span>
{trail.map((t, i) => (
<span key={i} className="flex items-center gap-2">
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
{t.href ? (
<Link href={t.href} className="hover:text-neutral-800">{t.label}</Link>
) : (
<span className="font-medium text-neutral-900">{t.label}</span>
)}
</span>
))}
</nav>
);
}
// Server-rendered segmented control: each option is a link that re-renders the
// page with the new value in the query string (used for tier / break-down / top-N).
export function SegLink({
label,
options,
current,
hrefFor,
}: {
label: string;
options: { value: string; label: string }[];
current: string;
hrefFor: (v: string) => string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">{label}</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-xs">
{options.map((o) => (
<Link
key={o.value}
href={hrefFor(o.value)}
className={
"rounded-md px-2.5 py-1 font-medium " +
(o.value === current ? "bg-primary-600 text-white" : "text-neutral-500 hover:text-neutral-800")
}
>
{o.label}
</Link>
))}
</div>
</div>
);
}
// A checkbox rendered as a navigation link — toggles this row's id in the
// `?sel=` custom-comparison selection (keeps the report fully server-rendered).
export function SelectCheckbox({ checked, href, title }: { checked: boolean; href: string; title?: string }) {
return (
<Link
href={href}
title={title ?? "Select to graph"}
scroll={false}
className={
"flex h-4 w-4 shrink-0 items-center justify-center rounded border " +
(checked ? "border-primary-600 bg-primary-600 text-white" : "border-neutral-300 bg-white hover:border-primary-500")
}
>
{checked && <Check className="h-3 w-3" />}
</Link>
);
}
// Sticky banner shown while rows are selected: jump to the custom comparison or clear.
export function CompareBar({ count, compareHref, clearHref }: { count: number; compareHref: string; clearHref: string }) {
return (
<div className="mb-4 flex items-center justify-between rounded-lg border border-primary-200 bg-primary-50 px-4 py-2.5">
<span className="text-sm font-medium text-primary-800">{count} selected</span>
<div className="flex items-center gap-2">
<Link href={compareHref} className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700">
Compare selected
</Link>
<Link href={clearHref} className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50">
Clear
</Link>
</div>
</div>
);
}
export function ReportTitle({ title, subtitle, badge }: { title: string; subtitle?: string; badge?: React.ReactNode }) {
return (
<div className="mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{title}</h1>
{badge}
</div>
{subtitle && <p className="mt-1 text-sm text-neutral-500">{subtitle}</p>}
</div>
);
}

View file

@ -0,0 +1,119 @@
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { fyLabel, SCOPE_LABELS, type Granularity, type ScopeMode } from "@/lib/reports";
interface Props {
fys: number[];
fy: number;
gran: Granularity;
/** Pass a scope to render the Top/Bottom-N "Show" control (index pages only). */
scope?: ScopeMode;
/** Weekly mode: the selected FY-month index + the 12 month options. */
month?: number;
monthOptions?: { value: number; label: string }[];
exportHref: string;
}
const GRANS: Granularity[] = ["weekly", "monthly", "yearly"];
// Pinned filter toolbar shared by the report pages. Each control writes its value
// into the URL query string (preserving the rest) so the server component
// re-renders the report for the new filters — no client-side data fetching.
export function ReportsToolbar({ fys, fy, gran, scope, month, monthOptions, exportHref }: Props) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
function update(patch: Record<string, string | null>) {
const q = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(patch)) {
if (v === null || v === "") q.delete(k);
else q.set(k, v);
}
const qs = q.toString();
router.push(qs ? `${pathname}?${qs}` : pathname);
}
const yearly = gran === "yearly";
const weekly = gran === "weekly";
return (
<div className="sticky top-0 z-20 -mx-4 mb-6 border-b border-neutral-200 bg-neutral-50/95 px-4 py-3 backdrop-blur md:-mx-6 md:px-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Granularity</span>
<div className="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
{GRANS.map((g) => (
<button
key={g}
onClick={() => update({ gran: g === "monthly" ? null : g })}
className={cn(
"rounded-md px-3 py-1 font-medium capitalize transition-colors",
gran === g ? "bg-primary-600 text-white shadow-sm" : "text-neutral-500 hover:text-neutral-800"
)}
>
{g}
</button>
))}
</div>
</div>
{!yearly && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Financial Year</span>
<select
value={fy}
onChange={(e) => update({ fy: e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{[...fys].reverse().map((y) => (
<option key={y} value={y}>{fyLabel(y)}</option>
))}
</select>
</label>
)}
{weekly && monthOptions && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Month</span>
<select
value={month}
onChange={(e) => update({ month: e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{monthOptions.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</label>
)}
{scope && (
<label className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Show</span>
<select
value={scope}
onChange={(e) => update({ scope: e.target.value === "top5" ? null : e.target.value })}
className="rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 focus:border-primary-600 focus:outline-none"
>
{(Object.keys(SCOPE_LABELS) as ScopeMode[]).map((s) => (
<option key={s} value={s}>{SCOPE_LABELS[s]}</option>
))}
</select>
</label>
)}
<a
href={exportHref}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
>
<Download className="h-4 w-4" />
Export
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import { useState, useRef, useEffect, useLayoutEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Search, X } from "lucide-react";
export type VendorOption = { id: string; name: string; vendorId: string | null };
/**
* Filter vendors by a free-text query, matching case-insensitively against the
* vendor name and the formal code (`vendorId`). An empty/whitespace query
* returns the full list unchanged.
*/
export function filterVendors<T extends VendorOption>(vendors: T[], query: string): T[] {
const q = query.trim().toLowerCase();
if (!q) return vendors;
return vendors.filter(
(v) =>
v.name.toLowerCase().includes(q) ||
(v.vendorId ? v.vendorId.toLowerCase().includes(q) : false)
);
}
/** Label shown for a vendor: "{name} (CODE)" when verified, "{name} (unverified)" otherwise. */
export function vendorLabel(v: VendorOption): string {
return `${v.name} ${v.vendorId ? `(${v.vendorId})` : "(unverified)"}`;
}
interface Props {
name: string;
vendors: VendorOption[];
/** Initial selected vendor id (uncontrolled — the component owns its state). */
initialValue?: string;
placeholder?: string;
/** Optional callback when the selection changes. */
onChange?: (value: string) => void;
}
export function VendorSelect({
name,
vendors,
initialValue = "",
placeholder = "No vendor selected",
onChange,
}: Props) {
const [value, setValue] = useState(initialValue);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const [portalStyle, setPortalStyle] = useState<React.CSSProperties>({});
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const updatePortalPos = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setPortalStyle({
position: "fixed",
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
}, []);
useLayoutEffect(() => {
if (!open) return;
updatePortalPos();
}, [open, updatePortalPos]);
useEffect(() => {
if (!open) return;
window.addEventListener("scroll", updatePortalPos, true);
window.addEventListener("resize", updatePortalPos);
return () => {
window.removeEventListener("scroll", updatePortalPos, true);
window.removeEventListener("resize", updatePortalPos);
};
}, [open, updatePortalPos]);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); setQuery(""); }
}
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("keydown", handleKey);
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("keydown", handleKey);
document.removeEventListener("mousedown", handleClick);
};
}, [open]);
useEffect(() => {
if (open) searchRef.current?.focus();
}, [open]);
const selected = vendors.find((v) => v.id === value);
const selectedLabel = selected ? vendorLabel(selected) : "";
const filtered = filterVendors(vendors, query);
const select = useCallback((id: string) => {
setValue(id);
onChange?.(id);
setOpen(false);
setQuery("");
}, [onChange]);
const handleClear = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setValue("");
onChange?.("");
}, [onChange]);
const dropdownPanel = (
<div
style={portalStyle}
className="rounded-lg border border-neutral-200 bg-white shadow-xl"
>
{/* Search input */}
<div className="flex items-center gap-2 p-2 border-b border-neutral-100">
<Search className="h-4 w-4 text-neutral-400 shrink-0" />
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or code…"
className="flex-1 text-sm outline-none placeholder:text-neutral-400"
/>
{query && (
<button type="button" onClick={() => setQuery("")} className="text-neutral-300 hover:text-neutral-500">
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Options list */}
<div className="max-h-72 overflow-y-auto overscroll-contain [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-300">
{/* "No vendor selected" empty option — always available */}
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); select(""); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === "" ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-500"}`}
>
No vendor selected
</button>
{filtered.length === 0 ? (
<p className="px-3 py-5 text-sm text-center text-neutral-400">No vendors match &ldquo;{query}&rdquo;</p>
) : (
filtered.map((v) => (
<button
key={v.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); select(v.id); }}
className={`w-full text-left flex items-baseline gap-2.5 px-3 py-2 text-sm hover:bg-primary-50 transition-colors
${value === v.id ? "bg-primary-50 text-primary-700 font-medium" : "text-neutral-800"}`}
>
<span className="flex-1 leading-snug">{v.name}</span>
<span className="font-mono text-xs text-neutral-400 shrink-0">
{v.vendorId ?? "unverified"}
</span>
</button>
))
)}
</div>
</div>
);
return (
<div ref={containerRef} className="relative w-full">
<input type="hidden" name={name} value={value} />
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-2 rounded-lg border
${open ? "border-primary-500 ring-2 ring-primary-500/20" : "border-neutral-300"}
bg-white text-left transition-colors px-3 py-2.5 text-sm
focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20`}
>
<span className={`truncate flex-1 min-w-0 ${selectedLabel ? "text-neutral-900" : "text-neutral-400"}`}>
{selectedLabel || placeholder}
</span>
<span className="flex items-center gap-1 shrink-0">
{value && (
<span role="button" tabIndex={0} onClick={handleClear}
onKeyDown={(e) => e.key === "Enter" && handleClear(e as unknown as React.MouseEvent)}
className="text-neutral-300 hover:text-neutral-500 transition-colors">
<X className="h-4 w-4" />
</span>
)}
<ChevronDown className={`text-neutral-400 transition-transform h-4 w-4 ${open ? "rotate-180" : ""}`} />
</span>
</button>
{open && mounted && createPortal(dropdownPanel, document.body)}
</div>
);
}

47
App/lib/pagination.ts Normal file
View file

@ -0,0 +1,47 @@
// Shared, dependency-free pagination math used by list pages (e.g. PO History).
// Keeps page-size validation and out-of-range page clamping in one testable place.
export interface PaginationInput {
/** Raw `perPage` value from the query string (may be undefined / invalid). */
perPageParam?: string | number;
/** Raw `page` value from the query string (may be undefined / invalid). */
pageParam?: string | number;
/** Total number of rows matching the current filter. */
total: number;
/** Allowed page sizes; anything else falls back to `defaultPerPage`. */
options: number[];
defaultPerPage: number;
}
export interface Pagination {
perPage: number;
page: number;
totalPages: number;
/** Rows to skip for the current page (Prisma `skip`). */
skip: number;
/** Rows to take for the current page (Prisma `take`). */
take: number;
}
/**
* Resolve a safe page size and (1-based) page number from untrusted query
* params. `perPage` is clamped to the allowed `options`; `page` is clamped to
* `[1, totalPages]` so an out-of-range or non-numeric page never paginates past
* the last page.
*/
export function resolvePagination({
perPageParam,
pageParam,
total,
options,
defaultPerPage,
}: PaginationInput): Pagination {
const perPage = options.includes(Number(perPageParam)) ? Number(perPageParam) : defaultPerPage;
const totalPages = Math.max(1, Math.ceil(total / perPage));
const requested = Number(pageParam);
const page = Math.min(
Math.max(1, Number.isFinite(requested) && requested > 0 ? Math.floor(requested) : 1),
totalPages,
);
return { perPage, page, totalPages, skip: (page - 1) * perPage, take: perPage };
}

View file

@ -0,0 +1,24 @@
// Service-token auth for the PO export route, shared by the auth middleware and
// (conceptually) the export route handler.
//
// PdfService ("Email PO to vendor", issue #14) fetches `/api/po/<id>/export`
// WITHOUT a user session, authenticating with a `svc` query param that must equal
// PDF_SERVICE_TOKEN. The route handler validates that token, but the auth
// middleware runs first and would otherwise redirect the unauthenticated request
// to /login — so the middleware uses this to let exactly that one route through
// when the token matches.
//
// Kept dependency-free so it's safe to import into the Edge middleware and easy to
// unit-test. `token` is `process.env.PDF_SERVICE_TOKEN` (undefined when the PDF
// service isn't configured → always denied).
const EXPORT_PATH = /^\/api\/po\/[^/]+\/export\/?$/;
export function isPdfExportServiceRequest(
pathname: string,
svc: string | null | undefined,
token: string | undefined
): boolean {
if (!token || !svc) return false;
if (svc !== token) return false;
return EXPORT_PATH.test(pathname);
}

105
App/lib/product-catalog.ts Normal file
View file

@ -0,0 +1,105 @@
import { db } from "@/lib/db";
/**
* Product catalogue sync registers a PO's line items as reusable `Product`s
* (the `/catalogue/items` catalogue) and keeps last/per-vendor prices fresh:
* - line items with no `productId` are matched to an existing product by name,
* or a brand-new product is created, and the line item is linked back;
* - `lastPrice`/`lastVendorId` and the per-vendor price are upserted.
*
* Called at **approval** (so approved items are immediately reusable in further
* POs) and again at **payment** (to refresh prices on the final figures). The
* function is idempotent re-running matches the same product by name/id.
*/
function nameToCode(name: string): string {
const slug = name.toUpperCase()
.replace(/[^A-Z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.substring(0, 20);
return `${slug}-${Date.now().toString(36).toUpperCase().slice(-5)}`;
}
export async function syncProductCatalog(
poId: string,
lineItems: { id: string; name: string; unitPrice: { toNumber(): number } | number; productId: string | null }[],
vendorId: string | null,
actorId: string
) {
const updatedProductIds: string[] = [];
for (const li of lineItems) {
const unitPrice = typeof li.unitPrice === "number" ? li.unitPrice : li.unitPrice.toNumber();
let productId = li.productId;
let priceChanged = false;
if (!productId) {
// Try to find an existing product by name (case-insensitive)
const existing = await db.product.findFirst({
where: { name: { equals: li.name, mode: "insensitive" }, isActive: true },
select: { id: true, lastPrice: true },
});
if (existing) {
productId = existing.id;
priceChanged = Number(existing.lastPrice ?? 0) !== unitPrice;
} else {
// Create a new product — first-time registration, not a price update
const code = nameToCode(li.name);
try {
const created = await db.product.create({
data: { code, name: li.name, lastPrice: unitPrice, lastVendorId: vendorId },
});
productId = created.id;
} catch {
// Code collision (extremely unlikely) — add extra entropy
const created = await db.product.create({
data: {
code: `${code}-${Math.random().toString(36).slice(2, 5).toUpperCase()}`,
name: li.name,
lastPrice: unitPrice,
lastVendorId: vendorId,
},
});
productId = created.id;
}
}
// Link the line item to the product for future reference
await db.pOLineItem.update({ where: { id: li.id }, data: { productId } });
} else {
const current = await db.product.findUnique({
where: { id: productId },
select: { lastPrice: true },
});
priceChanged = !current || Number(current.lastPrice ?? 0) !== unitPrice;
}
// Always update lastPrice / lastVendorId on the product
await db.product.update({
where: { id: productId },
data: { lastPrice: unitPrice, lastVendorId: vendorId ?? undefined },
});
// Upsert per-vendor price if PO has a vendor
if (vendorId) {
await db.productVendorPrice.upsert({
where: { productId_vendorId: { productId, vendorId } },
update: { price: unitPrice },
create: { productId, vendorId, price: unitPrice },
});
}
if (priceChanged) updatedProductIds.push(productId);
}
if (updatedProductIds.length > 0) {
await db.pOAction.create({
data: {
actionType: "PRODUCT_PRICE_UPDATED",
actorId,
poId,
metadata: { updatedProductIds },
},
});
}
}

21
App/lib/report-colors.ts Normal file
View file

@ -0,0 +1,21 @@
// Shared categorical palette for the Reports charts + table swatches.
//
// This is a plain, dependency-free module (NO "use client", no server-only
// imports) so it can be imported by BOTH the server-component report pages and
// the client chart components and resolve to the real array in each. It must NOT
// live in a "use client" module: a plain value imported from a client module
// into a server component becomes a client-reference proxy (not the array), so
// `SERIES_COLORS[i]` would silently be `undefined` and every series would fall
// back to recharts' default colour.
export const SERIES_COLORS = [
"#2563eb",
"#16a34a",
"#9333ea",
"#ea580c",
"#0891b2",
"#dc2626",
"#ca8a04",
"#4f46e5",
"#0d9488",
"#db2777",
];

352
App/lib/reports.ts Normal file
View file

@ -0,0 +1,352 @@
import { db } from "@/lib/db";
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
/**
* Spend reporting (Reports Purchasing). Aggregates approved purchase-order
* spend across two dimensions:
* Cost centres the PO's vessel (`PurchaseOrder.vesselId`).
* Accounting codes the self-referential `Account` tree (Heading
* Sub-heading Leaf); each PO's `accountId` is a leaf, rolled up to parents.
*
* "Spend" = a PO that has reached manager approval (`POST_APPROVAL_STATUSES`),
* dated by `approvedAt` and valued at the full `totalAmount` the same
* definition the dashboard's spend tiles use. Financial year is the Indian
* AprMar year. The heavy lifting is a single query in `getReportDataset()`;
* everything below is pure functions over that dataset so they're unit-testable.
*/
export const FY_MONTHS = ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar"] as const;
/** Indian FY start year for a date (AprMar): JanMar belong to the prior year. */
export function fyStartYear(d: Date): number {
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
}
/** "FY 202526" for start year 2025. */
export function fyLabel(start: number): string {
return `FY ${start}${String((start + 1) % 100).padStart(2, "0")}`;
}
/** Month index within the FY: Apr=0 … Mar=11. */
export function fyMonthIndex(d: Date): number {
return (d.getMonth() - 3 + 12) % 12;
}
/** Week-of-month bucket: 04 (W1W5) from the day of the month. */
export function weekOfMonth(d: Date): number {
return Math.min(4, Math.floor((d.getDate() - 1) / 7));
}
export const WEEK_LABELS = ["W1", "W2", "W3", "W4", "W5"] as const;
export type Tier = "Heading" | "Sub-heading" | "Leaf";
export interface CostCentre {
id: string;
code: string;
name: string;
}
export interface AccountNode {
id: string;
code: string;
name: string;
parentId: string | null;
tier: Tier;
}
/** One row per (PO, accounting code). Multi-account POs yield several rows. */
export interface SpendRow {
poId: string;
vesselId: string;
accountId: string;
amount: number;
fy: number;
month: number; // 011 within the FY (Apr=0)
week: number; // 04 within the calendar month
}
/**
* Split a PO's spend across the accounting codes its line items carry, so the
* accounting-code report attributes multi-account POs correctly. The PO's
* `totalAmount` is allocated **proportionally** to each line's account share
* (line `accountId`, falling back to the PO-level account), so the per-PO rows
* always sum back to `totalAmount` exactly. With no line items (or zero line
* value) the whole amount lands on the PO-level account.
*/
export function allocatePoSpend(
po: { id: string; vesselId: string; accountId: string; amount: number; fy: number; month: number; week: number },
lines: { accountId: string | null; amount: number }[]
): SpendRow[] {
const base = { poId: po.id, vesselId: po.vesselId, fy: po.fy, month: po.month, week: po.week };
const byAccount = new Map<string, number>();
let lineTotal = 0;
for (const l of lines) {
const key = l.accountId ?? po.accountId;
byAccount.set(key, (byAccount.get(key) ?? 0) + l.amount);
lineTotal += l.amount;
}
if (byAccount.size === 0 || lineTotal <= 0) {
return [{ ...base, accountId: po.accountId, amount: po.amount }];
}
return [...byAccount.entries()].map(([accountId, share]) => ({ ...base, accountId, amount: po.amount * (share / lineTotal) }));
}
export interface ReportDataset {
rows: SpendRow[];
vessels: CostCentre[];
accounts: AccountNode[];
fys: number[]; // ascending FYs that have spend (falls back to the current FY)
}
/** Pull every approved PO and the cost-centre / accounting-code reference data. */
export async function getReportDataset(): Promise<ReportDataset> {
const [pos, vessels, accounts] = await Promise.all([
db.purchaseOrder.findMany({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
select: {
id: true,
vesselId: true,
accountId: true,
totalAmount: true,
approvedAt: true,
lineItems: { select: { accountId: true, totalPrice: true } },
},
}),
db.vessel.findMany({ select: { id: true, code: true, name: true }, orderBy: { name: "asc" } }),
db.account.findMany({ select: { id: true, code: true, name: true, parentId: true } }),
]);
const childCount = new Map<string, number>();
for (const a of accounts) if (a.parentId) childCount.set(a.parentId, (childCount.get(a.parentId) ?? 0) + 1);
const accountNodes: AccountNode[] = accounts.map((a) => ({
id: a.id,
code: a.code,
name: a.name,
parentId: a.parentId,
tier: a.parentId === null ? "Heading" : (childCount.get(a.id) ?? 0) > 0 ? "Sub-heading" : "Leaf",
}));
const rows: SpendRow[] = [];
for (const po of pos) {
if (!po.approvedAt) continue;
const meta = {
id: po.id,
vesselId: po.vesselId,
accountId: po.accountId,
amount: Number(po.totalAmount),
fy: fyStartYear(po.approvedAt),
month: fyMonthIndex(po.approvedAt),
week: weekOfMonth(po.approvedAt),
};
rows.push(...allocatePoSpend(meta, po.lineItems.map((l) => ({ accountId: l.accountId, amount: Number(l.totalPrice) }))));
}
const fySet = new Set(rows.map((r) => r.fy));
const fys = fySet.size ? [...fySet].sort((a, b) => a - b) : [fyStartYear(new Date())];
return { rows, vessels, accounts: accountNodes, fys };
}
// ── Account tree helpers ───────────────────────────────────────────────────
export interface AccountIndex {
byId: Map<string, AccountNode>;
childrenOf: (parentId: string | null) => AccountNode[];
leavesUnder: (id: string) => Set<string>;
isLeaf: (id: string) => boolean;
pathTo: (id: string) => AccountNode[];
}
export function buildAccountIndex(accounts: AccountNode[]): AccountIndex {
const byId = new Map(accounts.map((a) => [a.id, a]));
const kids = new Map<string | null, AccountNode[]>();
for (const a of accounts) {
const k = a.parentId;
if (!kids.has(k)) kids.set(k, []);
kids.get(k)!.push(a);
}
const childrenOf = (parentId: string | null) => kids.get(parentId) ?? [];
const isLeaf = (id: string) => childrenOf(id).length === 0;
const leafCache = new Map<string, Set<string>>();
function leavesUnder(id: string): Set<string> {
const cached = leafCache.get(id);
if (cached) return cached;
const out = new Set<string>();
const children = childrenOf(id);
if (children.length === 0) out.add(id);
else for (const c of children) for (const lf of leavesUnder(c.id)) out.add(lf);
leafCache.set(id, out);
return out;
}
function pathTo(id: string): AccountNode[] {
const node = byId.get(id);
if (!node) return [];
return node.parentId ? [...pathTo(node.parentId), node] : [node];
}
return { byId, childrenOf, leavesUnder, isLeaf, pathTo };
}
// ── Aggregations ───────────────────────────────────────────────────────────
export interface CostCentreSpend {
id: string;
code: string;
name: string;
total: number; // selected FY
months: number[]; // 12 (AprMar) of the selected FY
poCount: number; // selected FY
fyTotals: number[]; // aligned to ds.fys
}
export function costCentreRows(ds: ReportDataset, fy: number): CostCentreSpend[] {
const idx = new Map<string, CostCentreSpend>();
const poSets = new Map<string, Set<string>>(); // distinct POs per vessel in the selected FY
for (const v of ds.vessels) {
idx.set(v.id, { id: v.id, code: v.code, name: v.name, total: 0, months: Array(12).fill(0), poCount: 0, fyTotals: Array(ds.fys.length).fill(0) });
poSets.set(v.id, new Set());
}
for (const r of ds.rows) {
const row = idx.get(r.vesselId);
if (!row) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) row.fyTotals[fi] += r.amount;
if (r.fy === fy) {
row.months[r.month] += r.amount;
row.total += r.amount;
poSets.get(r.vesselId)!.add(r.poId);
}
}
for (const [id, set] of poSets) idx.get(id)!.poCount = set.size;
return [...idx.values()];
}
/** Weekly buckets (W1W5) of one FY month for a cost centre. */
export function costCentreWeekly(ds: ReportDataset, vesselId: string, fy: number, month: number): number[] {
const weeks = Array(5).fill(0);
for (const r of ds.rows) if (r.vesselId === vesselId && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
return weeks;
}
/** Spend for an account node (rolls leaf descendants up) in a FY: total + 12 months + per-FY totals. */
export function accountNodeSpend(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number) {
const leaves = idx.leavesUnder(nodeId);
const months = Array(12).fill(0);
const fyTotals = Array(ds.fys.length).fill(0);
const poSet = new Set<string>();
let total = 0;
for (const r of ds.rows) {
if (!leaves.has(r.accountId)) continue;
const fi = ds.fys.indexOf(r.fy);
if (fi >= 0) fyTotals[fi] += r.amount;
if (r.fy === fy) {
months[r.month] += r.amount;
total += r.amount;
poSet.add(r.poId);
}
}
return { total, months, fyTotals, poCount: poSet.size };
}
/** Weekly buckets (W1W5) of one FY month for an account node (rolls leaves up). */
export function accountNodeWeekly(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number, month: number): number[] {
const leaves = idx.leavesUnder(nodeId);
const weeks = Array(5).fill(0);
for (const r of ds.rows) if (leaves.has(r.accountId) && r.fy === fy && r.month === month) weeks[r.week] += r.amount;
return weeks;
}
export interface NodeSpend {
node: AccountNode;
total: number;
months: number[];
fyTotals: number[];
poCount: number;
}
/** The accounting-code nodes to compare at a drill level (children of `parentId`; null = top headings). */
export function accountLevelRows(ds: ReportDataset, idx: AccountIndex, parentId: string | null, fy: number): NodeSpend[] {
return idx.childrenOf(parentId).map((node) => ({ node, ...accountNodeSpend(ds, idx, node.id, fy) }));
}
export interface Breakdown {
id: string;
label: string;
value: number;
}
/** For a cost centre detail: spend on each accounting code of `tier`, this FY. */
export function topAccountsForCostCentre(ds: ReportDataset, idx: AccountIndex, vesselId: string, fy: number, tier: Tier): Breakdown[] {
return ds.accounts
.filter((a) => a.tier === tier)
.map((a) => {
const leaves = idx.leavesUnder(a.id);
let value = 0;
for (const r of ds.rows) if (r.fy === fy && r.vesselId === vesselId && leaves.has(r.accountId)) value += r.amount;
return { id: a.id, label: `${a.code} · ${a.name}`, value };
})
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For an account-node detail: which cost centres drive its spend, this FY. */
export function costCentresForAccount(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
const leaves = idx.leavesUnder(nodeId);
const byVessel = new Map<string, number>();
for (const r of ds.rows) if (r.fy === fy && leaves.has(r.accountId)) byVessel.set(r.vesselId, (byVessel.get(r.vesselId) ?? 0) + r.amount);
return ds.vessels
.map((v) => ({ id: v.id, label: v.name, value: byVessel.get(v.id) ?? 0 }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
/** For a non-leaf account-node detail: spend split across its direct children, this FY. */
export function childBreakdown(ds: ReportDataset, idx: AccountIndex, nodeId: string, fy: number): Breakdown[] {
return idx
.childrenOf(nodeId)
.map((c) => ({ id: c.id, label: `${c.code} · ${c.name}`, value: accountNodeSpend(ds, idx, c.id, fy).total }))
.filter((b) => b.value > 0)
.sort((a, b) => b.value - a.value);
}
// ── Scope (Top N / Bottom N) ───────────────────────────────────────────────
export type ScopeMode = "top5" | "top10" | "bottom5" | "all";
export const SCOPE_LABELS: Record<ScopeMode, string> = { top5: "Top 5", top10: "Top 10", bottom5: "Bottom 5", all: "All" };
/** Apply a Top/Bottom-N scope to rows already sorted by spend descending. */
export function applyScope<T>(sortedDesc: T[], scope: ScopeMode): T[] {
if (scope === "top5") return sortedDesc.slice(0, 5);
if (scope === "top10") return sortedDesc.slice(0, 10);
if (scope === "bottom5") return sortedDesc.slice(-5).reverse();
return sortedDesc;
}
export function parseScope(v: string | undefined): ScopeMode {
return v === "top10" || v === "bottom5" || v === "all" ? v : "top5";
}
export type Granularity = "yearly" | "monthly" | "weekly";
export function parseGranularity(v: string | undefined): Granularity {
return v === "yearly" || v === "weekly" ? v : "monthly";
}
/** Resolve the selected FY from a query param against the available FYs (default: latest). */
export function resolveFy(ds: ReportDataset, v: string | undefined): number {
const n = v ? Number(v) : NaN;
if (Number.isFinite(n) && ds.fys.includes(n)) return n;
return ds.fys[ds.fys.length - 1];
}
/** Resolve the FY-month index (011) for weekly mode (default: latest month with spend, else 0). */
export function resolveMonth(ds: ReportDataset, fy: number, v: string | undefined): number {
const n = v ? Number(v) : NaN;
if (Number.isFinite(n) && n >= 0 && n <= 11) return n;
let last = 0;
for (const r of ds.rows) if (r.fy === fy && r.month > last) last = r.month;
return last;
}
/** Parse the `?sel=id1,id2` custom-comparison selection into an ordered, de-duped id list. */
export function parseSel(v: string | undefined): string[] {
if (!v) return [];
const seen = new Set<string>();
for (const id of v.split(",")) if (id.trim()) seen.add(id.trim());
return [...seen];
}
/** Toggle an id within a selection list (for the checkbox links). */
export function toggleSel(sel: string[], id: string): string[] {
return sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id];
}

View file

@ -59,6 +59,16 @@ export function buildSignatureKey(userId: string, ext: string): string {
return `signatures/${userId}.${ext}`;
}
/**
* Deterministic key for a PO's rendered PDF (one object per PO, no timestamp) so
* "Email to vendor" can reuse a previously rendered copy instead of re-rendering
* and re-uploading on every send (see `prepareVendorEmail`).
*/
export function buildPoPdfKey(poId: string, fileName: string): string {
const safe = fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
return `po-pdf/${poId}/${safe}`;
}
/**
* Storage key for a company branding asset (logo or stamp/seal).
* Deterministic per company+type so a re-upload overwrites the previous file.
@ -106,6 +116,36 @@ export async function uploadBuffer(
}
}
/**
* Lightweight existence/metadata check for a stored object (no body transfer).
* Returns `{ lastModified }` when the object exists, or `null` when it doesn't.
* Used to reuse a cached PO PDF when it's still current.
*/
export async function statObject(key: string): Promise<{ lastModified: Date } | null> {
try {
if (isDev) {
const fs = await import("fs/promises");
const path = await import("path");
const filePath = path.join(process.cwd(), ".dev-uploads", ...key.split("/"));
const s = await fs.stat(filePath);
return { lastModified: s.mtime };
}
const { S3Client, HeadObjectCommand } = await import("@aws-sdk/client-s3");
const s3 = new S3Client({
region: "auto",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const r = await s3.send(new HeadObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: key }));
return { lastModified: r.LastModified ?? new Date(0) };
} catch {
return null; // missing object (404/NotFound) or any access error → treat as absent
}
}
/**
* Fetch a stored file as a Buffer (server-side).
*/

View file

@ -1,14 +1,28 @@
import { db } from "@/lib/db";
import type { TermsByCategory } from "@/lib/terms";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
/** Active T&C clause texts grouped by category, for the PO form dropdowns (#11). */
export async function getActiveTermsByCategory(): Promise<TermsByCategory> {
const rows = await db.termsCondition.findMany({
/** Active categories (ordered) each with their active clause texts — for the PO T&C editor (#11). */
export async function getTermsCatalogue(): Promise<CatalogueCategory[]> {
const cats = await db.termsCategory.findMany({
where: { isActive: true },
orderBy: [{ category: "asc" }, { createdAt: "asc" }],
select: { category: true, text: true },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
include: {
clauses: {
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { text: true },
},
},
});
const map: TermsByCategory = {};
for (const r of rows) (map[r.category] ??= []).push(r.text);
return map;
return cats.map((c) => ({ id: c.id, name: c.name, clauses: c.clauses.map((x) => x.text) }));
}
/** The default T&C set pre-filled on a NEW PO — every active isDefault clause, ordered. */
export async function getDefaultPoTerms(): Promise<PoTerm[]> {
const rows = await db.termsCondition.findMany({
where: { isDefault: true, isActive: true, category: { isActive: true } },
orderBy: [{ category: { sortOrder: "asc" } }, { sortOrder: "asc" }],
select: { text: true, category: { select: { name: true } } },
});
return rows.map((r) => ({ category: r.category.name, text: r.text }));
}

View file

@ -1,36 +1,50 @@
/**
* Terms & Conditions catalogue (issue #11) admin-managed clauses that populate
* the PO's named T&C dropdowns. Each clause has a category matching one of the
* PO's tc* slots; the chosen clause is stored as a text snapshot in that column.
* Terms & Conditions catalogue (issue #11) admin-managed categories + clauses
* that feed the PO's dynamic T&C editor. Categories are user-defined data.
*/
import type { TermsCategory } from "@prisma/client";
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
// The five catalogued slots (the PO's "Others" stays free text; the fixed
// boilerplate lines are not catalogued). Order = display order.
export const TERMS_CATEGORIES: TermsCategory[] = [
"DELIVERY",
"DISPATCH",
"INSPECTION",
"TRANSIT_INSURANCE",
"PAYMENT_TERMS",
];
// One chosen T&C row on a PO (stored as a JSON snapshot in PurchaseOrder.terms).
export type PoTerm = { category: string; text: string };
export const TERMS_CATEGORY_LABEL: Record<TermsCategory, string> = {
DELIVERY: "Delivery",
DISPATCH: "Dispatch Instructions",
INSPECTION: "Inspection",
TRANSIT_INSURANCE: "Transit Insurance",
PAYMENT_TERMS: "Payment Terms",
// A catalogue category with its active clause texts — passed to the PO editor.
export type CatalogueCategory = { id: string; name: string; clauses: string[] };
// Legacy PO (no `terms` JSON yet) → editable rows, mapping the old tc* columns +
// the previously-fixed boilerplate lines onto the new category model, in the
// original document order. Used to seed the editor when editing an old PO.
type LegacyTc = {
tcDelivery?: string | null;
tcDispatch?: string | null;
tcInspection?: string | null;
tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null;
tcOthers?: string | null;
};
export function legacyPoTerms(po: LegacyTc): PoTerm[] {
const rows: PoTerm[] = [
{ category: "General", text: TC_FIXED_LINE },
{ category: "Delivery", text: po.tcDelivery ?? TC_DEFAULTS.tcDelivery },
{ category: "Dispatch Instructions", text: po.tcDispatch ?? TC_DEFAULTS.tcDispatch },
{ category: "Inspection", text: po.tcInspection ?? TC_DEFAULTS.tcInspection },
{ category: "Transit Insurance", text: po.tcTransitInsurance ?? TC_DEFAULTS.tcTransitInsurance },
{ category: "Payment Terms", text: po.tcPaymentTerms ?? TC_DEFAULTS.tcPaymentTerms },
{ category: "Others", text: po.tcOthers ?? "" },
{ category: "General", text: TC_FIXED_LINE_2 },
];
return rows.filter((r) => r.text.trim().length > 0);
}
// PO tc* form field ⇄ catalogue category.
export const TC_FIELD_CATEGORY: Record<string, TermsCategory> = {
tcDelivery: "DELIVERY",
tcDispatch: "DISPATCH",
tcInspection: "INSPECTION",
tcTransitInsurance: "TRANSIT_INSURANCE",
tcPaymentTerms: "PAYMENT_TERMS",
};
// Server → client shape: active clause texts grouped by category.
export type TermsByCategory = Partial<Record<TermsCategory, string[]>>;
/** Coerce an unknown (DB JSON / parsed form value) into a clean PoTerm[]. */
export function parsePoTerms(value: unknown): PoTerm[] {
if (!Array.isArray(value)) return [];
const out: PoTerm[] = [];
for (const row of value) {
if (!row || typeof row !== "object") continue;
const category = String((row as Record<string, unknown>).category ?? "").trim();
const text = String((row as Record<string, unknown>).text ?? "").trim();
// A row needs at least some text to be meaningful; category may be blank.
if (text) out.push({ category, text });
}
return out;
}

View file

@ -1,11 +1,20 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
export default auth((req) => {
const isAuthenticated = !!req.auth;
const pathname = req.nextUrl.pathname;
const isLoginPage = pathname === "/login";
// PdfService fetches the PO export page unauthenticated, using a `svc` token
// that matches PDF_SERVICE_TOKEN (the route handler re-validates it). Let that
// one route through so the service token isn't bounced to /login by the gate
// below. Everything else stays auth-protected.
if (isPdfExportServiceRequest(pathname, req.nextUrl.searchParams.get("svc"), process.env.PDF_SERVICE_TOKEN)) {
return NextResponse.next();
}
if (!isAuthenticated && !isLoginPage) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);

View file

@ -0,0 +1,43 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright config for verifying closed issues against a RUNNING staging instance
* (pm2 `ppms-staging`, port 3200 on pms1), reached over an SSH tunnel:
*
* ssh -N -L 3200:localhost:3200 shad0w@<pms1>
* PLAYWRIGHT_BASE_URL=http://localhost:3200 \
* pnpm exec playwright test --config playwright.staging.config.ts
*
* Unlike playwright.config.ts this does NOT start a local dev server it drives the
* already-deployed staging build. Login uses the seeded `@pelagia.local` test users
* (prisma/seed-test-users.ts), so no production credentials are required.
*
* Staging runs `next dev`, so the first hit on a route compiles on demand and can be
* slow timeouts are deliberately generous and workers default to 1 to keep the
* shared staging DB state predictable across specs.
*/
export default defineConfig({
testDir: "./tests/staging",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report-staging" }]],
timeout: 90_000,
expect: { timeout: 20_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3200",
trace: "retain-on-failure",
navigationTimeout: 45_000,
actionTimeout: 20_000,
},
projects: [
{
name: "chromium",
// Use a system browser channel (Google Chrome) so the suite does not depend on
// the bundled chrome-headless-shell download. Override with PW_CHANNEL=msedge
// if Chrome is unavailable. Both ship on a standard Windows install.
use: { ...devices["Desktop Chrome"], channel: process.env.PW_CHANNEL ?? "chrome" },
},
],
});

View file

@ -0,0 +1,66 @@
-- Rework Terms & Conditions (issue #11 follow-up): the fixed TermsCategory ENUM
-- becomes a user-defined TermsCategory TABLE; clauses gain isDefault/sortOrder;
-- PurchaseOrder gains a JSON `terms` snapshot. Existing enum-based clauses are
-- migrated onto the new category rows. Forward migration (the original
-- 20260624140000 migration is already released and stays untouched).
-- Free the "TermsCategory" name (a table and an enum type cannot coexist).
ALTER TYPE "TermsCategory" RENAME TO "TermsCategory_old";
-- New category table.
CREATE TABLE "TermsCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCategory_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "TermsCategory_name_key" ON "TermsCategory"("name");
INSERT INTO "TermsCategory" ("id", "name", "sortOrder", "updatedAt") VALUES
('tcat_general', 'General', 0, CURRENT_TIMESTAMP),
('tcat_delivery', 'Delivery', 1, CURRENT_TIMESTAMP),
('tcat_dispatch', 'Dispatch Instructions', 2, CURRENT_TIMESTAMP),
('tcat_inspect', 'Inspection', 3, CURRENT_TIMESTAMP),
('tcat_transit', 'Transit Insurance', 4, CURRENT_TIMESTAMP),
('tcat_payment', 'Payment Terms', 5, CURRENT_TIMESTAMP),
('tcat_others', 'Others', 6, CURRENT_TIMESTAMP);
-- New clause columns.
ALTER TABLE "TermsCondition" ADD COLUMN "categoryId" TEXT;
ALTER TABLE "TermsCondition" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "TermsCondition" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Migrate existing clauses from the enum onto category rows.
UPDATE "TermsCondition" SET "categoryId" = CASE "category"::text
WHEN 'DELIVERY' THEN 'tcat_delivery'
WHEN 'DISPATCH' THEN 'tcat_dispatch'
WHEN 'INSPECTION' THEN 'tcat_inspect'
WHEN 'TRANSIT_INSURANCE' THEN 'tcat_transit'
WHEN 'PAYMENT_TERMS' THEN 'tcat_payment'
END;
-- The original seed clauses become the PO defaults.
UPDATE "TermsCondition" SET "isDefault" = true
WHERE "id" IN ('tcseed_delivery','tcseed_dispatch','tcseed_inspect','tcseed_transit','tcseed_payment');
-- Drop the old enum column + type now that data is migrated.
DROP INDEX "TermsCondition_category_idx";
ALTER TABLE "TermsCondition" DROP COLUMN "category";
DROP TYPE "TermsCategory_old";
-- Enforce the relation.
ALTER TABLE "TermsCondition" ALTER COLUMN "categoryId" SET NOT NULL;
CREATE INDEX "TermsCondition_categoryId_idx" ON "TermsCondition"("categoryId");
ALTER TABLE "TermsCondition" ADD CONSTRAINT "TermsCondition_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "TermsCategory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Seed the previously-fixed boilerplate lines as default "General" clauses.
INSERT INTO "TermsCondition" ("id", "categoryId", "text", "isDefault", "sortOrder", "updatedAt") VALUES
('tcc_fixed1', 'tcat_general', 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.', true, 0, CURRENT_TIMESTAMP),
('tcc_fixed2', 'tcat_general', 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.', true, 1, CURRENT_TIMESTAMP);
-- Dynamic T&C snapshot on the PO.
ALTER TABLE "PurchaseOrder" ADD COLUMN "terms" JSONB;

View file

@ -22,8 +22,8 @@ export type RankEntry = {
export const RANKS: RankEntry[] = [
// ── Management (portal logins) ──────────────────────────────────────────────
{ code: "PM", name: "PM", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "APM", name: "Assistant PM", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "PM", name: "Project Manager", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "APM", name: "Assistant Project Manager", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
{ code: "SIC", name: "Site In-charge", parentCode: "APM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
// ── Shore support (no login, no seafarer docs) ──────────────────────────────
@ -34,15 +34,15 @@ export const RANKS: RankEntry[] = [
// ── Operational crew (seafarers) ────────────────────────────────────────────
{ code: "DIC", name: "Dredger In-charge", parentCode: "SIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SDO", name: "Sr. Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SDO", name: "Senior Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLS", name: "Pipeline Supervisor", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "PLA", name: "Pipeline Assistant", parentCode: "PLS", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "JDO", name: "Jr. Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "JDO", name: "Junior Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ERO", name: "Engine Room Operator", parentCode: "JDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "DH", name: "Deck Hand", parentCode: "ERO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "TR", name: "Trainee", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "MB", name: "Mess Boy", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "ELE", name: "Electrician", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SFB", name: "Sr. Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "SFB", name: "Senior Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
{ code: "FW", name: "Fabricator / Welder", parentCode: "SFB", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
];

View file

@ -410,30 +410,36 @@ model DeliveryLocation {
@@index([companyId])
}
// Admin-managed Terms & Conditions clauses (issue #11). Each clause belongs to a
// category matching one of the PO's named T&C slots; the PO form turns those slots
// into dropdowns sourced from the active clauses of that category. The PO keeps
// the chosen clause as a text snapshot in its tc* columns (point-in-time
// document), so editing/removing a clause never rewrites historical POs. Managed
// by manage_terms. ("Others" stays free text; the fixed boilerplate lines —
// TC_FIXED_LINE / TC_FIXED_LINE_2 — are not catalogued.)
enum TermsCategory {
DELIVERY
DISPATCH
INSPECTION
TRANSIT_INSURANCE
PAYMENT_TERMS
// Admin-managed Terms & Conditions catalogue (issue #11). Categories are
// user-defined data (not a fixed set) — admins add new ones — and every PO T&C
// line is a catalogued clause, including the standard "fixed" lines (seeded under
// a "General" category) and the "Others" bucket. The PO form is a dynamic editor:
// add rows, pick a category, type/pick a clause. The chosen rows are stored as a
// JSON snapshot on PurchaseOrder.terms, so editing/removing a clause never
// rewrites historical POs. Managed by manage_terms.
model TermsCategory {
id String @id @default(cuid())
name String @unique
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clauses TermsCondition[]
}
model TermsCondition {
id String @id @default(cuid())
category TermsCategory
text String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
categoryId String
category TermsCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
text String
// Pre-added to a new PO's default T&C set (reproduces the old standard wording).
isDefault Boolean @default(false)
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([categoryId])
}
model Account {
@ -578,6 +584,10 @@ model PurchaseOrder {
tcTransitInsurance String?
tcPaymentTerms String?
tcOthers String?
// Dynamic T&C snapshot (issue #11): [{ category, text }] chosen on the PO form.
// When present it supersedes the legacy tc* columns for display/export; null on
// pre-feature POs (which still render from tc* + the fixed boilerplate lines).
terms Json?
poDate DateTime?
submittedAt DateTime?
approvedAt DateTime?

View file

@ -0,0 +1,89 @@
/**
* Seed deterministic, credential-capable TEST USERS into a database.
*
* Why this exists
* ---------------
* `pelagia_test` (the staging / autofix DB) is a daily mirror of production, so it
* only contains real `@pelagiamarine.com` users most are SSO-only (no password)
* and none have a password we know. That makes it impossible to log into the
* staging instance (port 3200) with the credentials provider to run end-to-end
* feature tests.
*
* This script upserts one **known-password** user per `Role` (using the throwaway
* `@pelagia.local` domain, which never exists in prod, so there is zero collision
* with real accounts). Credentials intentionally mirror
* `tests/e2e/helpers/login.ts` so the same Playwright specs run locally and against
* staging unchanged.
*
* Safety
* ------
* - Idempotent: upsert keyed on the (unique) email; re-running only refreshes the
* password hash / role / isActive.
* - `employeeId` uses a `TEST-*` prefix so it can never clash with a real
* production employee id carried over by the mirror.
* - Only ever creates the `@pelagia.local` users below it touches no prod rows.
*
* Usage
* -----
* DATABASE_URL="postgresql://.../pelagia_test" pnpm tsx prisma/seed-test-users.ts
*
* It is wired into `automation/refresh-test-db.sh` so these accounts are recreated
* automatically after every daily refresh of `pelagia_test`.
*/
import { PrismaClient, Role } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
/**
* One login per role/flow that the closed-issue feature tests exercise.
* Passwords match tests/e2e/helpers/login.ts (do not change one without the other).
*/
const TEST_USERS: Array<{
employeeId: string;
email: string;
name: string;
password: string;
role: Role;
}> = [
{ employeeId: "TEST-TECH", email: "tech@pelagia.local", name: "Test Technical", password: "tech1234", role: Role.TECHNICAL },
{ employeeId: "TEST-MANNING",email: "manning@pelagia.local", name: "Test Manning", password: "manning1234", role: Role.MANNING },
{ employeeId: "TEST-ACCT", email: "accounts@pelagia.local", name: "Test Accounts", password: "accounts1234", role: Role.ACCOUNTS },
{ employeeId: "TEST-MGR", email: "manager@pelagia.local", name: "Test Manager", password: "manager1234", role: Role.MANAGER },
{ employeeId: "TEST-SUPER", email: "superuser@pelagia.local", name: "Test Superuser", password: "super1234", role: Role.SUPERUSER },
{ employeeId: "TEST-AUDIT", email: "auditor@pelagia.local", name: "Test Auditor", password: "audit1234", role: Role.AUDITOR },
{ employeeId: "TEST-ADMIN", email: "admin@pelagia.local", name: "Test Admin", password: "admin1234", role: Role.ADMIN },
{ employeeId: "TEST-SITE", email: "site@pelagia.local", name: "Test Site Staff", password: "site1234", role: Role.SITE_STAFF},
];
async function main() {
console.log(`Seeding ${TEST_USERS.length} test users...`);
for (const u of TEST_USERS) {
const passwordHash = await bcrypt.hash(u.password, 12);
await prisma.user.upsert({
where: { email: u.email },
// Keep an existing test account in sync (refresh the hash / role / active flag)
// but never overwrite its employeeId once created.
update: { name: u.name, passwordHash, role: u.role, isActive: true },
create: {
employeeId: u.employeeId,
email: u.email,
name: u.name,
passwordHash,
role: u.role,
isActive: true,
},
});
console.log(`${u.email.padEnd(28)} ${u.role}`);
}
console.log("Test users ready.");
}
main()
.catch((e) => {
console.error("seed-test-users failed:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View file

@ -4,9 +4,9 @@
* - After adding an item to the cart, the badge count on the cart icon increases
*
* Feature 15 Inventory item & vendor detail pages
* - Clicking an item on /inventory/items navigates to /inventory/items/[id]
* - Clicking an item on /catalogue/items navigates to /catalogue/items/[id]
* - The item detail shows name, price, vendor info
* - /inventory/vendors/[id] shows vendor details
* - /catalogue/vendors/[id] shows vendor details
*
* Created: 2026-05-17
*/
@ -52,7 +52,7 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
await login(page, USERS.TECH);
// Navigate to inventory items
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
const rows = page.locator("tbody tr");
@ -97,15 +97,15 @@ test.describe("Feature 14 — Cart header icon with badge", () => {
});
test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
test("US-15a: clicking an item row navigates to /inventory/items/[id]", async ({
test("US-15a: clicking an item row navigates to /catalogue/items/[id]", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
// Look for a direct link to an item detail page
const itemLink = page.locator("a[href*='/inventory/items/']").first();
const itemLink = page.locator("a[href*='/catalogue/items/']").first();
if (await itemLink.isVisible()) {
await itemLink.click();
await expect(page).toHaveURL(/\/inventory\/items\/.+/);
@ -150,14 +150,14 @@ test.describe("Feature 15 — Inventory item & vendor detail pages", () => {
console.log(`✓ Item detail page loaded: ${page.url()}`);
});
test("US-15b: /inventory/vendors/[id] shows vendor details for TECHNICAL user", async ({
test("US-15b: /catalogue/vendors/[id] shows vendor details for TECHNICAL user", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/vendors");
await page.goto("/catalogue/vendors");
await page.waitForLoadState("networkidle");
const vendorLink = page.locator("a[href*='/inventory/vendors/']").first();
const vendorLink = page.locator("a[href*='/catalogue/vendors/']").first();
if (await vendorLink.isVisible()) {
await vendorLink.click();
await expect(page).toHaveURL(/\/inventory\/vendors\/.+/);

View file

@ -1,6 +1,6 @@
/**
* User stories covered: Feature 12 Cheapest & Closest tags
* - TECHNICAL user on /inventory/items sees Cheapest or Closest tags on item rows
* - TECHNICAL user on /catalogue/items sees Cheapest or Closest tags on item rows
* when a site is selected (tags are independent of sort order)
*
* Feature 13 Auto-sort by distance when site selected
@ -17,11 +17,11 @@ import { test, expect } from "@playwright/test";
import { login, USERS } from "../helpers/login";
test.describe("Feature 12 — Cheapest & Closest item tags", () => {
test("US-12a: /inventory/items page loads for TECHNICAL user", async ({
test("US-12a: /catalogue/items page loads for TECHNICAL user", async ({
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
// Page should show some items (table rows or empty state)
@ -41,7 +41,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
// Select a site to enable distance computation
@ -54,7 +54,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
}
// Navigate to items with site selected (wait for URL param)
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -106,7 +106,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -116,7 +116,7 @@ test.describe("Feature 12 — Cheapest & Closest item tags", () => {
return;
}
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -148,7 +148,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -158,7 +158,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
return;
}
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -176,7 +176,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
// Expand a row to reveal sort toggle
@ -196,7 +196,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
}
// Select a site — row stays expanded (preserved React state through soft nav)
const navPromise = page.waitForURL("**/inventory/items?siteId=**", {
const navPromise = page.waitForURL("**/catalogue/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });
@ -223,7 +223,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
page,
}) => {
await login(page, USERS.TECH);
await page.goto("/inventory/items");
await page.goto("/catalogue/items");
await page.waitForLoadState("networkidle");
const siteSelect = page.locator("select").first();
@ -234,7 +234,7 @@ test.describe("Feature 13 — Auto-sort by distance when site selected", () => {
}
// Select a site
const nav1 = page.waitForURL("**/inventory/items?siteId=**", {
const nav1 = page.waitForURL("**/catalogue/items?siteId=**", {
timeout: 10_000,
});
await siteSelect.selectOption({ index: 1 });

View file

@ -2,7 +2,7 @@
* Integration tests for manager approval server actions.
* Covers: M-02 (approve / approve+note), M-03 (reject), M-04 (request edits, vendor ID), S-06 (provide vendor ID), S-07 (resubmit after edits).
*/
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
import { vi, describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
@ -47,6 +47,12 @@ afterEach(async () => {
await deletePosByTitle(PREFIX);
});
afterAll(async () => {
// Products auto-created by the catalogue-on-approval test.
await db.productVendorPrice.deleteMany({ where: { product: { name: { startsWith: PREFIX } } } });
await db.product.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
// Helper: create a PO in MGR_REVIEW state
async function createSubmittedPo(title: string): Promise<string> {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
@ -159,6 +165,40 @@ describe("issue #92 — advance payment on approval", () => {
});
});
// ── Product catalogue registered on approval (so items are reusable) ─────────
describe("product catalogue on approval", () => {
it("creates a catalogue product for a free-text line item and links it", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
const itemName = `${PREFIX}Starter VPS hosting`;
const form = makePoForm({
title: `${PREFIX}CatApprove`,
vesselId,
accountId,
intent: "submit",
lineItems: [{ description: itemName, quantity: 1, unit: "pc", unitPrice: 459.95 }],
});
const { id: poId } = (await createPo(form)) as { id: string };
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
// No catalogue product exists for this name before approval.
expect(await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } })).toBeNull();
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
expect(await approvePo({ poId })).toEqual({ ok: true });
const product = await db.product.findFirst({ where: { name: { equals: itemName, mode: "insensitive" } } });
expect(product).not.toBeNull();
expect(Number(product!.lastPrice)).toBe(459.95);
// The line item is linked back to the new product, and a per-vendor price is recorded.
const li = await db.pOLineItem.findFirstOrThrow({ where: { poId } });
expect(li.productId).toBe(product!.id);
const pvp = await db.productVendorPrice.findFirst({ where: { productId: product!.id, vendorId } });
expect(pvp).not.toBeNull();
});
});
// ── M-03: Reject ──────────────────────────────────────────────────────────────
describe("M-03 — reject PO", () => {

View file

@ -17,13 +17,15 @@ vi.mock("@/lib/storage", async (importOriginal) => {
...actual,
uploadBuffer: vi.fn(async () => {}),
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
statObject: vi.fn(async () => null), // default: no cached object → render
};
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
import { isPdfServiceConfigured } from "@/lib/pdf-service";
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_";
@ -98,6 +100,34 @@ describe("prepareVendorEmail", () => {
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
});
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// 1st send: no cached object → render + upload once.
vi.mocked(statObject).mockResolvedValueOnce(null);
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
});
it("re-renders when the PO changed since the cached copy", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
// Cached object older than the PO's updatedAt → stale → re-render.
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
});
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);

View file

@ -1,7 +1,8 @@
/**
* Integration tests for the Terms & Conditions admin CRUD (issue #11).
* Covers create/update/toggle/delete + the manage_terms guard, and the
* grouping helper used to feed the PO T&C dropdowns.
* Categories are user-defined data: adding a clause under a new category name
* creates the category. Covers CRUD + the manage_terms guard + the catalogue /
* default-terms helpers that feed the PO editor.
*/
import { vi, describe, it, expect, afterAll } from "vitest";
@ -16,7 +17,7 @@ import {
toggleTermActive,
deleteTerm,
} from "@/app/(portal)/admin/terms/actions";
import { getActiveTermsByCategory } from "@/lib/terms-data";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth);
@ -25,43 +26,51 @@ const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAG
afterAll(async () => {
await db.termsCondition.deleteMany({ where: { text: { startsWith: PREFIX } } });
await db.termsCategory.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
describe("createTerm", () => {
it("persists a clause under its category", async () => {
it("creates a new category on the fly and the clause under it", async () => {
asManager();
const result = await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}Within 2 days` }));
const result = await createTerm(fd({ categoryName: `${PREFIX}Warranty`, text: `${PREFIX}12 months` }));
expect(result).toEqual({ ok: true });
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}Within 2 days` } });
expect(t.category).toBe("DELIVERY");
expect(t.isActive).toBe(true);
const cat = await db.termsCategory.findFirstOrThrow({ where: { name: `${PREFIX}Warranty` }, include: { clauses: true } });
expect(cat.clauses).toHaveLength(1);
expect(cat.clauses[0].text).toBe(`${PREFIX}12 months`);
});
it("requires text and a valid category", async () => {
it("reuses an existing category (case-insensitive) for a second clause", async () => {
asManager();
expect("error" in (await createTerm(fd({ category: "DELIVERY", text: " " })))).toBe(true);
expect("error" in (await createTerm(fd({ category: "NOT_A_CATEGORY", text: "x" })))).toBe(true);
await createTerm(fd({ categoryName: `${PREFIX}warranty`, text: `${PREFIX}24 months` }));
const cats = await db.termsCategory.findMany({ where: { name: { startsWith: PREFIX, mode: "insensitive" }, AND: { name: { equals: `${PREFIX}Warranty`, mode: "insensitive" } } } });
expect(cats).toHaveLength(1); // no duplicate category
});
it("requires a category and clause text", async () => {
asManager();
expect("error" in (await createTerm(fd({ categoryName: " ", text: "x" })))).toBe(true);
expect("error" in (await createTerm(fd({ categoryName: `${PREFIX}X`, text: " " })))).toBe(true);
});
it("refuses callers without manage_terms", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
expect(await createTerm(fd({ category: "DELIVERY", text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
expect(await createTerm(fd({ categoryName: `${PREFIX}X`, text: `${PREFIX}nope` }))).toEqual({ error: "Forbidden" });
});
});
describe("updateTerm / toggle / delete", () => {
it("edits, toggles active, then deletes a clause", async () => {
asManager();
await createTerm(fd({ category: "PAYMENT_TERMS", text: `${PREFIX}old wording` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old wording` } });
await createTerm(fd({ categoryName: `${PREFIX}Edit`, text: `${PREFIX}old` }));
const t = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}old` } });
expect(await updateTerm(t.id, fd({ category: "INSPECTION", text: `${PREFIX}new wording` }))).toEqual({ ok: true });
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } });
expect(after.text).toBe(`${PREFIX}new wording`);
expect(after.category).toBe("INSPECTION");
expect(await updateTerm(t.id, fd({ categoryName: `${PREFIX}Edit2`, text: `${PREFIX}new` }))).toEqual({ ok: true });
const after = await db.termsCondition.findUniqueOrThrow({ where: { id: t.id }, include: { category: true } });
expect(after.text).toBe(`${PREFIX}new`);
expect(after.category.name).toBe(`${PREFIX}Edit2`);
expect(await toggleTermActive(t.id)).toEqual({ ok: true });
expect((await db.termsCondition.findUniqueOrThrow({ where: { id: t.id } })).isActive).toBe(false);
@ -72,22 +81,29 @@ describe("updateTerm / toggle / delete", () => {
it("guards update/toggle/delete behind the permission", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await updateTerm("x", fd({ category: "DELIVERY", text: "y" }))).toEqual({ error: "Forbidden" });
expect(await updateTerm("x", fd({ categoryName: `${PREFIX}X`, text: "y" }))).toEqual({ error: "Forbidden" });
expect(await toggleTermActive("x")).toEqual({ error: "Forbidden" });
expect(await deleteTerm("x")).toEqual({ error: "Forbidden" });
});
});
describe("getActiveTermsByCategory", () => {
it("groups only active clauses by category", async () => {
describe("catalogue + default terms helpers", () => {
it("getTermsCatalogue exposes active categories with their active clauses", async () => {
asManager();
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}active dispatch` }));
await createTerm(fd({ category: "DISPATCH", text: `${PREFIX}inactive dispatch` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive dispatch` } });
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}active clause` }));
await createTerm(fd({ categoryName: `${PREFIX}Cat`, text: `${PREFIX}inactive clause` }));
const inactive = await db.termsCondition.findFirstOrThrow({ where: { text: `${PREFIX}inactive clause` } });
await toggleTermActive(inactive.id);
const map = await getActiveTermsByCategory();
expect(map.DISPATCH).toContain(`${PREFIX}active dispatch`);
expect(map.DISPATCH).not.toContain(`${PREFIX}inactive dispatch`);
const cat = (await getTermsCatalogue()).find((c) => c.name === `${PREFIX}Cat`);
expect(cat?.clauses).toContain(`${PREFIX}active clause`);
expect(cat?.clauses).not.toContain(`${PREFIX}inactive clause`);
});
it("getDefaultPoTerms returns isDefault clauses", async () => {
asManager();
await createTerm(fd({ categoryName: `${PREFIX}Def`, text: `${PREFIX}default clause`, isDefault: "true" }));
const defaults = await getDefaultPoTerms();
expect(defaults.some((d) => d.text === `${PREFIX}default clause` && d.category === `${PREFIX}Def`)).toBe(true);
});
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Foundation check: the staging instance is reachable and every seeded test user
* can authenticate with the credentials provider. If this fails, none of the
* per-issue specs can run fix the seed (prisma/seed-test-users.ts) or the tunnel.
*/
test.describe("staging smoke", () => {
test("login page renders the staging build", async ({ page }) => {
await page.goto("/login");
await expect(page.getByLabel(/email address/i)).toBeVisible();
await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
});
for (const [name, creds] of Object.entries(USERS)) {
test(`seeded user ${name} can log in`, async ({ page }) => {
await login(page, creds);
await expect(page).not.toHaveURL(/\/login/);
});
}
});

View file

@ -0,0 +1,34 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Crewing epics (#75 Requisitions, #76 Candidates, #79 Crew records, #81 Leave &
* Attendance, #83 Office verification, #86 Reference data/admin) feature-flagged
* behind NEXT_PUBLIC_CREWING_ENABLED, which is "true" on staging.
*
* These are smoke checks that each epic's primary surface renders for an authorised
* role (the deep state-machine flows pipeline #77, onboarding #78, PPE #80,
* appraisal #82, sign-off #85 are covered by the existing integration suites noted
* in Docs/TESTING.md). Render-without-redirect is the proof the shipped feature is
* live on staging.
*/
const PAGES: Array<{ issue: string; name: string; path: string; user: keyof typeof USERS; heading: RegExp }> = [
{ issue: "#75", name: "Requisitions", path: "/crewing/requisitions", user: "MANAGER", heading: /requisition/i },
{ issue: "#76", name: "Candidates", path: "/crewing/candidates", user: "MANAGER", heading: /candidate/i },
{ issue: "#79", name: "Crew records", path: "/crewing/crew", user: "MANAGER", heading: /crew/i },
{ issue: "#81", name: "Leave", path: "/crewing/leave", user: "MANAGER", heading: /leave/i },
{ issue: "#81", name: "Attendance", path: "/crewing/attendance", user: "MANAGER", heading: /attendance/i },
{ issue: "#83", name: "Verification", path: "/crewing/verification", user: "MANNING", heading: /verif/i },
{ issue: "#86", name: "Ranks", path: "/admin/ranks", user: "MANAGER", heading: /rank/i },
{ issue: "#86", name: "Crew admin", path: "/admin/crew", user: "MANAGER", heading: /crew/i },
];
for (const p of PAGES) {
test(`${p.issue} ${p.name} surface renders on staging (${p.path})`, async ({ page }) => {
await login(page, USERS[p.user]);
await page.goto(p.path);
await expect(page, `should not redirect away from ${p.path}`).toHaveURL(new RegExp(p.path.replace(/\//g, "\\/")));
await expect(page.getByRole("heading", { name: p.heading }).first()).toBeVisible();
});
}

View file

@ -0,0 +1,127 @@
/**
* Runtime fixture lookups for the staging verification suite.
*
* PO ids in `pelagia_test` change on every daily refresh, so specs must not hard-code
* them. These helpers anchor each spec on a real row that currently exists in the
* staging DB (read-only), keeping the suite stable across refreshes. They use the
* SAME `DATABASE_URL` the Playwright run is given (the SSH tunnel to pelagia_test).
*
* This is test *setup* only every assertion still runs against the live UI.
*/
import { PrismaClient } from "@prisma/client";
let _db: PrismaClient | null = null;
export function db(): PrismaClient {
if (!_db) _db = new PrismaClient();
return _db;
}
export async function closeDb() {
if (_db) await _db.$disconnect();
_db = null;
}
// Mirrors lib/utils.ts POST_APPROVAL_STATUSES — the statuses that count as "approved
// and beyond" for spend/approval trackers (issues #12, #32, #50).
export const POST_APPROVAL_STATUSES = [
"MGR_APPROVED",
"SENT_FOR_PAYMENT",
"PARTIALLY_PAID",
"PAID_DELIVERED",
"PARTIALLY_CLOSED",
"CLOSED",
] as const;
/** First day of the current month in local time (matches the dashboard query). */
export function startOfMonth(now = new Date()): Date {
return new Date(now.getFullYear(), now.getMonth(), 1);
}
/** Mirror of lib/utils.ts formatDate → e.g. "Jun 11, 2026". */
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("en-US", { year: "numeric", month: "short", day: "numeric" }).format(
new Date(date),
);
}
export async function approvedPo() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null } },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true, poDate: true },
});
}
/**
* An approved-or-later PO with NO explicit poDate, so its detail "PO Date" must fall
* back to the approval date the exact case issue #5 is about.
*/
export async function approvedPoNoPoDate() {
return db().purchaseOrder.findFirst({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { not: null }, poDate: null },
orderBy: { approvedAt: "desc" },
select: { id: true, poNumber: true, status: true, approvedAt: true },
});
}
export async function closedPo() {
return db().purchaseOrder.findFirst({
where: { status: "CLOSED" },
select: { id: true, poNumber: true },
});
}
/** An approved-or-later PO whose vendor has a contact email (issue #14). */
export async function poWithVendorEmail() {
return db().purchaseOrder.findFirst({
where: {
status: { in: [...POST_APPROVAL_STATUSES] },
vendor: { contacts: { some: { email: { not: null } } } },
},
select: { id: true, poNumber: true, status: true },
});
}
/** A PO that has at least one line item with a non-empty description (issue #8). */
export async function poWithLineItemDescription() {
const li = await db().pOLineItem.findFirst({
where: { description: { not: null }, AND: [{ description: { not: "" } }] },
select: { description: true, po: { select: { id: true, poNumber: true, status: true } } },
});
return li ? { ...li.po, description: li.description as string } : null;
}
/** All CLOSED PO numbers, to assert the manager sees the full closed set (issue #6). */
export async function closedPoNumbers() {
const rows = await db().purchaseOrder.findMany({
where: { status: "CLOSED" },
select: { poNumber: true },
});
return rows.map((r) => r.poNumber);
}
/** A PO that has at least one uploaded document, to exercise attachment grouping (#10). */
export async function poWithDocuments() {
const doc = await db().pODocument.findFirst({
select: { po: { select: { id: true, poNumber: true } } },
});
return doc?.po ?? null;
}
export async function totalPoCount() {
return db().purchaseOrder.count();
}
/** Count of POs approved in the current month, regardless of current status (issues #12/#32). */
export async function approvedThisMonthCount() {
return db().purchaseOrder.count({
where: { status: { in: [...POST_APPROVAL_STATUSES] }, approvedAt: { gte: startOfMonth() } },
});
}
/** A vendor that has a non-null vendorId code, for search-by-code specs (#57/#109). */
export async function vendorWithCode() {
return db().vendor.findFirst({
where: { vendorId: { not: null } },
select: { name: true, vendorId: true },
});
}

View file

@ -0,0 +1,39 @@
/**
* Shared helpers for the staging closed-issue verification suite.
*
* Credentials mirror prisma/seed-test-users.ts (seeded into pelagia_test) and the
* dev-seed users in tests/e2e/helpers/login.ts.
*/
import { type Page, expect } from "@playwright/test";
export interface Credentials {
email: string;
password: string;
}
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" },
SITE: { email: "site@pelagia.local", password: "site1234" },
} satisfies Record<string, Credentials>;
/** Log in via the credentials provider and wait until off the /login page. */
export async function login(page: Page, creds: Credentials): Promise<void> {
await page.goto("/login");
await page.getByLabel(/email address/i).fill(creds.email);
await page.getByLabel(/password/i).fill(creds.password);
// The staging login page has two buttons: "Sign in with Microsoft" (SSO) and the
// credentials "Sign in" submit. Target the exact credentials submit.
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
}
/** Log out via the header control (best-effort; ignores if already logged out). */
export async function logout(page: Page): Promise<void> {
await page.context().clearCookies();
}

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #4 Submitter can set an optional PO date (back/forward-datable).
* Fix: a `poDate` date input on the PO create/edit forms.
*/
test("#4 PO create form exposes an optional, free-to-set PO Date field", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
const poDate = page.locator('input[name="poDate"]');
await expect(poDate).toBeVisible();
await expect(poDate).toHaveAttribute("type", "date");
await expect(poDate).not.toHaveAttribute("required", "");
// It accepts a back-dated value and a forward-dated value.
await poDate.fill("2024-01-15");
await expect(poDate).toHaveValue("2024-01-15");
await poDate.fill("2030-12-31");
await expect(poDate).toHaveValue("2030-12-31");
});

View file

@ -0,0 +1,24 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPoNoPoDate, formatDate, closeDb } from "./fixtures";
/**
* Issue #5 Once a PO is approved, the PO date shown is the approval date
* (when the submitter did not set an explicit poDate). Display rule:
* poDisplayDate = po.poDate ?? po.approvedAt ?? po.createdAt.
*/
test.afterAll(closeDb);
test("#5 approved PO detail shows the approval date as the PO Date", async ({ page }) => {
const po = await approvedPoNoPoDate();
test.skip(!po, "no approved PO without an explicit poDate in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The "PO Date" detail row should render the approval date.
const expected = formatDate(po!.approvedAt!);
const row = page.getByText("PO Date", { exact: true }).locator("xpath=ancestor::*[1]");
await expect(row).toContainText(expected);
});

View file

@ -0,0 +1,35 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { closedPoNumbers, closeDb } from "./fixtures";
/**
* Issue #6 Closed PO list filters.
* - MANAGER: the "Closed Purchase Orders" view shows ALL closed POs.
* - Submitter: the view shows ONLY CLOSED (no APPROVED leaking in).
* Route: /my-orders.
*/
test.afterAll(closeDb);
test("#6 manager sees ALL closed POs on /my-orders", async ({ page }) => {
const closed = await closedPoNumbers();
test.skip(closed.length === 0, "no closed POs in staging data");
await login(page, USERS.MANAGER);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The manager sees the FULL closed set (not just their own): every closed PO number
// is present, and no APPROVED status leaks into this view.
for (const poNumber of closed) {
await expect(page.getByText(poNumber).first()).toBeVisible();
}
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});
test("#6 submitter's closed view excludes APPROVED POs", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/my-orders");
await expect(page.getByRole("heading", { name: "Closed Purchase Orders" })).toBeVisible();
// The bug was APPROVED POs showing here; assert none do.
await expect(page.getByText("Approved", { exact: true })).toHaveCount(0);
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithLineItemDescription, closeDb } from "./fixtures";
/**
* Issue #8 The exported PO must include the line item's optional description.
* The export route renders the printable PO at /api/po/[id]/export?format=pdf;
* we fetch it with the logged-in session and assert the description text is present.
*/
test.afterAll(closeDb);
test("#8 exported PO contains the line-item description", async ({ page }) => {
const po = await poWithLineItemDescription();
test.skip(!po, "no PO with a line-item description in staging data");
await login(page, USERS.MANAGER);
// page.request shares the authenticated browser cookies.
const res = await page.request.get(`/api/po/${po!.id}/export?format=pdf`);
expect(res.ok()).toBeTruthy();
const body = await res.text();
expect(body).toContain(po!.description);
});

View file

@ -0,0 +1,25 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithDocuments, closeDb } from "./fixtures";
/**
* Issue #10 PO detail shows ALL attachments, grouped by type
* (Submission / Payment / Delivery). Requires a PO that actually has documents;
* if the staging mirror has none, the spec skips (documented data limitation).
*/
test.afterAll(closeDb);
test("#10 PO detail groups attachments by type", async ({ page }) => {
const po = await poWithDocuments();
test.skip(!po, "no PO with uploaded documents in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
// The attachments region renders at least one of the typed group headings.
const groups = page.getByText(
/SUBMISSION DOCUMENTS|PAYMENT DOCUMENTS|DELIVERY RECEIPTS|OTHER ATTACHMENTS/i,
);
await expect(groups.first()).toBeVisible();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #104 /history is paginated with an items-per-page dropdown.
*/
test("#104 history has an items-per-page dropdown that drives a perPage query param", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
const perPage = page.locator("#perPage");
await expect(perPage).toBeVisible();
// Options include the configured page sizes.
await expect(perPage.locator('option[value="50"]')).toBeAttached();
await perPage.selectOption("50");
await expect(page).toHaveURL(/perPage=50/);
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #109 the New PO screen vendor field is a searchable combobox that matches
* by vendor name AND code (mirrors the items search).
*/
test.afterAll(closeDb);
test("#109 new PO vendor field is a searchable combobox (name + code)", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/po/new");
// Open the vendor combobox (trigger shows the "No vendor selected" placeholder).
await page.getByText("No vendor selected").click();
const search = page.getByPlaceholder(/Search by name or code/i);
await expect(search).toBeVisible();
// Searching by the vendor's CODE surfaces the vendor by name — proving code search.
await search.fill(vendor!.vendorId!);
await expect(page.getByText(vendor!.name, { exact: false }).first()).toBeVisible();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #11 Admin-managed Terms & Conditions catalogue feeding a dynamic PO editor.
* - Admin surface at /admin/terms.
* - The PO create form renders a Terms & Conditions editor.
*/
test("#11 admin Terms & Conditions page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/terms");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /terms.*conditions/i }).first()).toBeVisible();
});
test("#11 new PO form includes a Terms & Conditions editor", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
await expect(page.getByText(/terms.*conditions/i).first()).toBeVisible();
// The dynamic editor offers an "Add term" affordance.
await expect(page.getByRole("button", { name: /add term/i })).toBeVisible();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedThisMonthCount, closeDb } from "./fixtures";
/**
* Issue #12 Manager dashboard 'Total approved this month' was stuck at 0 / not
* updating. Fix: the card counts every PO approved this month (any post-approval
* status). We assert the rendered value matches the DB-computed count, proving it
* reflects real data rather than 0.
*/
test.afterAll(closeDb);
test("#12 'Approved This Month' card shows the correct, live count", async ({ page }) => {
const expected = await approvedThisMonthCount();
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("a,div", { has: page.getByText("Approved This Month", { exact: true }) }).first();
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});

View file

@ -0,0 +1,20 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #13 Accounts dashboard should have a 'Payments completed this month' card
* (analogous to the manager's monthly approval card).
*
* VERIFICATION RESULT: NOT FIXED on staging. The Accounts dashboard currently shows
* only "Ready for Payment" and "Payment Queue Value" there is no
* payments-completed-this-month card. This is marked test.fail() so the suite stays
* green while clearly recording the gap; if the card is later added, this test will
* start passing and flag that the annotation should be removed.
*/
test.fail(true, "Issue #13 not implemented: no 'payments completed this month' card on the Accounts dashboard");
test("#13 Accounts dashboard shows a 'Payments completed this month' card", async ({ page }) => {
await login(page, USERS.ACCOUNTS);
await page.goto("/dashboard");
await expect(page.getByText(/payments? completed this month/i)).toBeVisible();
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { poWithVendorEmail, closeDb } from "./fixtures";
/**
* Issue #14 'Email to vendor' option becomes available once a PO is approved (and
* after payment), when the vendor has a contact email. We assert the button is
* present on such a PO. (The underlying PDF/Outlook pipeline depends on PdfService
* env config; this verifies the user-facing affordance exists.)
*/
test.afterAll(closeDb);
test("#14 approved PO with a vendor email shows the 'Email to vendor' button", async ({ page }) => {
const po = await poWithVendorEmail();
test.skip(!po, "no approved PO with a vendor contact email in staging data");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await expect(page.getByText(po!.poNumber)).toBeVisible();
await expect(page.getByRole("button", { name: /email to vendor/i })).toBeVisible();
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #19 Place of Delivery is a dropdown (admin-managed delivery locations),
* not free text. The PO forms render <select name="placeOfDelivery">, and the admin
* surface lives at /admin/delivery-locations.
*/
test("#19 PO form Place of Delivery is a <select> dropdown", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/po/new");
const select = page.locator('select[name="placeOfDelivery"]');
await expect(select).toBeVisible();
});
test("#19 admin delivery-locations page renders for a manager", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/admin/delivery-locations");
await expect(page).not.toHaveURL(/\/login/);
await expect(page.getByRole("heading", { name: /delivery location/i }).first()).toBeVisible();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issues #24 / #40 change the sign-out control's tooltip from 'Sign out' to
* 'Log out'. Both were pipeline / button-simulation TEST issues.
*
* VERIFICATION RESULT: NOT FIXED on staging. The header logout control still uses
* title="Sign out". Marked test.fail() to record the gap without failing the suite;
* if the copy is changed to 'Log out' this test will pass and flag the annotation.
*/
test.fail(true, "Issues #24/#40 not implemented: logout tooltip is still 'Sign out'");
test("#24/#40 logout control tooltip reads 'Log out'", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
await expect(page.locator('[title="Log out"]')).toBeVisible();
});

View file

@ -0,0 +1,27 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { totalPoCount, closeDb } from "./fixtures";
/**
* Issue #41 the dashboard 'Total Purchase Orders' card shows the correct count.
* Issue #26 that stat card is clickable and links to the history page.
* Both are verified on the generic dashboard (AUDITOR role).
*/
test.afterAll(closeDb);
test("#41 'Total Purchase Orders' card shows the correct unfiltered count", async ({ page }) => {
const expected = await totalPoCount();
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Total Purchase Orders" });
await expect(card).toBeVisible();
await expect(card).toContainText(String(expected));
});
test("#26 'Total Purchase Orders' card links to the history page", async ({ page }) => {
await login(page, USERS.AUDITOR);
await page.goto("/dashboard");
await page.getByRole("link").filter({ hasText: "Total Purchase Orders" }).click();
await expect(page).toHaveURL(/\/history/);
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #31 PO history allows selecting MULTIPLE statuses (OR-ed). The status
* filter is a checkbox dropdown; applying two statuses yields two `status` query
* params.
*/
test("#31 history status filter supports multiple OR-ed statuses", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/history");
// Open the status dropdown (button label is "All statuses" / "N statuses").
await page.getByRole("button", { name: /statuses/i }).click();
await page.getByRole("checkbox", { name: "Closed" }).check();
await page.getByRole("checkbox", { name: "Approved" }).check();
await page.getByRole("button", { name: "Apply" }).click();
await expect(page).toHaveURL(/status=CLOSED/);
await expect(page).toHaveURL(/status=MGR_APPROVED/);
});

View file

@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #32 the manager 'Approved This Month' card counts POs approved this month
* regardless of their current status, and clicking it opens history pre-filtered by
* approval date (?approvedFrom=YYYY-MM-01).
*/
test("#32 'Approved This Month' card links to history filtered by approval date", async ({ page }) => {
const now = new Date();
const expectedParam = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.getByRole("link").filter({ hasText: "Approved This Month" });
await expect(card).toBeVisible();
await card.click();
await expect(page).toHaveURL(new RegExp(`/history\\?approvedFrom=${expectedParam}`));
});

View file

@ -0,0 +1,15 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #44 the PO line-item unit dropdown must offer months and year(s)
* (previously only days/short units).
*/
test("#44 line-item unit dropdown includes month and year options", async ({ page }) => {
await login(page, USERS.TECH);
await page.goto("/po/new");
// The line-items editor renders at least one unit <select>; assert the new options.
await expect(page.locator('option[value="month"]').first()).toBeAttached();
await expect(page.locator('option[value="year"]').first()).toBeAttached();
});

View file

@ -0,0 +1,18 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #50 the manager approved-spend card uses the rupee symbol and compact
* Indian formatting ( with L / Cr / K), not a dollar sign.
*/
test("#50 'Total Approved Spend' card shows ₹ with compact L/Cr/K formatting", async ({ page }) => {
await login(page, USERS.MANAGER);
await page.goto("/dashboard");
const card = page.locator("div").filter({ has: page.getByText("Total Approved Spend", { exact: true }) }).first();
await expect(card).toBeVisible();
// ₹ present, no dollar sign, value matches the compact format (₹2 Cr / ₹49 L / ₹75 K / ₹500).
await expect(card).toContainText("₹");
await expect(card).not.toContainText("$");
await expect(card).toContainText(/₹\s?-?\d[\d.,]*\s?(Cr|L|K)?/);
});

View file

@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { approvedPo, closeDb } from "./fixtures";
/**
* Issue #53 Managers can cancel a PO via a confirmation modal that requires a
* reason and typing the word "cancel". This spec verifies the affordance and the
* type-to-confirm guard WITHOUT actually cancelling (non-destructive on staging
* data): it confirms the submit button stays disabled until "cancel" is typed, then
* closes the modal.
*/
test.afterAll(closeDb);
test("#53 Cancel PO modal enforces type-'cancel'-to-confirm", async ({ page }) => {
const po = await approvedPo();
test.skip(!po, "no approved PO in staging data to exercise the cancel control");
await login(page, USERS.MANAGER);
await page.goto(`/po/${po!.id}`);
await page.getByRole("button", { name: "Cancel PO" }).click();
const submit = page.getByRole("button", { name: "Cancel this PO" });
await expect(submit).toBeVisible();
await expect(submit).toBeDisabled();
// A reason alone is not enough.
await page.getByPlaceholder(/Duplicate order/i).fill("Verification test — not a real cancellation");
await expect(submit).toBeDisabled();
// Typing the confirmation word enables it.
await page.getByPlaceholder("cancel").fill("cancel");
await expect(submit).toBeEnabled();
// Back out without cancelling.
await page.getByRole("button", { name: "Keep PO" }).click();
await expect(submit).toBeHidden();
});

View file

@ -0,0 +1,22 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
import { vendorWithCode, closeDb } from "./fixtures";
/**
* Issue #57 /catalogue/vendors (formerly /inventory/vendors) is searchable by
* vendor id/code, and the id is shown next to the name.
*/
test.afterAll(closeDb);
test("#57 vendors are searchable by vendor id, with the id shown next to the name", async ({ page }) => {
const vendor = await vendorWithCode();
test.skip(!vendor, "no vendor with a vendorId code in staging data");
await login(page, USERS.MANAGER);
await page.goto("/catalogue/vendors");
await page.getByPlaceholder(/Search by name, ID, GSTIN/i).fill(vendor!.vendorId!);
// The matching vendor row shows both the name and the id badge.
await expect(page.getByText(vendor!.name).first()).toBeVisible();
await expect(page.getByText(vendor!.vendorId!, { exact: false }).first()).toBeVisible();
});

View file

@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import { USERS, login } from "./helpers";
/**
* Issue #96 sidebar section headings (Purchasing / Crewing / Administration) are
* collapsible, collapsed by default, and act as a single-open accordion (opening one
* collapses the others).
*/
test("#96 sidebar sections are collapsible and single-open", async ({ page }) => {
await login(page, USERS.MANAGER);
// /dashboard is not inside any section, so all sections start collapsed.
await page.goto("/dashboard");
const purchasing = page.getByRole("button", { name: "Purchasing" });
const crewing = page.getByRole("button", { name: "Crewing" });
await expect(purchasing).toBeVisible();
await expect(crewing).toBeVisible();
// Collapsed by default.
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
await expect(crewing).toHaveAttribute("aria-expanded", "false");
// Opening Purchasing expands it.
await purchasing.click();
await expect(purchasing).toHaveAttribute("aria-expanded", "true");
// Opening Crewing collapses Purchasing (single-open accordion).
await crewing.click();
await expect(crewing).toHaveAttribute("aria-expanded", "true");
await expect(purchasing).toHaveAttribute("aria-expanded", "false");
});

View file

@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { resolvePagination } from "@/lib/pagination";
const OPTIONS = [25, 50, 100];
const DEFAULT = 25;
function resolve(perPageParam: string | number | undefined, pageParam: string | number | undefined, total: number) {
return resolvePagination({ perPageParam, pageParam, total, options: OPTIONS, defaultPerPage: DEFAULT });
}
describe("resolvePagination", () => {
it("defaults perPage and page when params are missing", () => {
expect(resolve(undefined, undefined, 200)).toEqual({
perPage: 25,
page: 1,
totalPages: 8,
skip: 0,
take: 25,
});
});
it("accepts allowed page sizes", () => {
expect(resolve("50", "1", 200).perPage).toBe(50);
expect(resolve("100", "1", 200).perPage).toBe(100);
expect(resolve(50, "1", 200).perPage).toBe(50);
});
it("falls back to the default for disallowed or non-numeric page sizes", () => {
expect(resolve("10", "1", 200).perPage).toBe(25);
expect(resolve("999", "1", 200).perPage).toBe(25);
expect(resolve("abc", "1", 200).perPage).toBe(25);
expect(resolve("0", "1", 200).perPage).toBe(25);
});
it("computes skip/take for a middle page", () => {
const p = resolve("25", "3", 200);
expect(p).toMatchObject({ page: 3, skip: 50, take: 25, totalPages: 8 });
});
it("clamps a page beyond the last page to the last page", () => {
expect(resolve("25", "99", 200)).toMatchObject({ page: 8, totalPages: 8, skip: 175 });
});
it("clamps non-positive / non-numeric page to 1", () => {
expect(resolve("25", "0", 200).page).toBe(1);
expect(resolve("25", "-5", 200).page).toBe(1);
expect(resolve("25", "abc", 200).page).toBe(1);
expect(resolve("25", undefined, 200).page).toBe(1);
});
it("floors fractional page numbers", () => {
expect(resolve("25", "2.9", 200).page).toBe(2);
});
it("always yields at least one page, even with zero rows", () => {
expect(resolve("25", "1", 0)).toMatchObject({ page: 1, totalPages: 1, skip: 0 });
});
it("handles a partial final page", () => {
// 23 rows, 25 per page -> single page holding all rows
expect(resolve("25", "1", 23)).toMatchObject({ page: 1, totalPages: 1, take: 25 });
// 60 rows, 25 per page -> 3 pages, last page holds 10
expect(resolve("25", "3", 60)).toMatchObject({ page: 3, totalPages: 3, skip: 50 });
});
});

View file

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { isPdfExportServiceRequest } from "@/lib/pdf-export-auth";
const TOKEN = "a".repeat(64);
describe("isPdfExportServiceRequest", () => {
it("allows the export route when the svc token matches", () => {
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export", TOKEN, TOKEN)).toBe(true);
expect(isPdfExportServiceRequest("/api/po/cmqrug123/export/", TOKEN, TOKEN)).toBe(true); // trailing slash
});
it("denies when the token is missing, empty, or wrong", () => {
expect(isPdfExportServiceRequest("/api/po/x/export", TOKEN, undefined)).toBe(false); // service not configured
expect(isPdfExportServiceRequest("/api/po/x/export", null, TOKEN)).toBe(false); // no svc on request
expect(isPdfExportServiceRequest("/api/po/x/export", "", TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po/x/export", "wrong", TOKEN)).toBe(false);
});
it("only matches the PO export route, not other paths", () => {
expect(isPdfExportServiceRequest("/api/po/x/export/extra", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po/x", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/dashboard", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/reports/spend", TOKEN, TOKEN)).toBe(false);
expect(isPdfExportServiceRequest("/api/po//export", TOKEN, TOKEN)).toBe(false); // empty id
});
});

View file

@ -0,0 +1,191 @@
import { describe, it, expect } from "vitest";
import {
fyStartYear,
fyLabel,
fyMonthIndex,
weekOfMonth,
buildAccountIndex,
costCentreRows,
costCentreWeekly,
accountNodeSpend,
accountNodeWeekly,
accountLevelRows,
topAccountsForCostCentre,
costCentresForAccount,
childBreakdown,
applyScope,
parseScope,
parseGranularity,
resolveFy,
resolveMonth,
parseSel,
toggleSel,
allocatePoSpend,
type ReportDataset,
type AccountNode,
} from "@/lib/reports";
const ACCOUNTS: AccountNode[] = [
{ id: "H", code: "5000", name: "Operating", parentId: null, tier: "Heading" },
{ id: "S", code: "5100", name: "Vessel Running", parentId: "H", tier: "Sub-heading" },
{ id: "L1", code: "5110", name: "Fuel", parentId: "S", tier: "Leaf" },
{ id: "L2", code: "5120", name: "Spares", parentId: "S", tier: "Leaf" },
];
// fys ascending: [2024, 2025]. PO p1 is multi-account (rows on L1 + L2).
const DS: ReportDataset = {
vessels: [
{ id: "v1", code: "V1", name: "MV One" },
{ id: "v2", code: "V2", name: "MV Two" },
],
accounts: ACCOUNTS,
fys: [2024, 2025],
rows: [
{ poId: "p1", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 },
{ poId: "p2", vesselId: "v1", accountId: "L1", amount: 50, fy: 2025, month: 1, week: 1 },
{ poId: "p1", vesselId: "v1", accountId: "L2", amount: 30, fy: 2025, month: 0, week: 0 },
{ poId: "p3", vesselId: "v2", accountId: "L1", amount: 200, fy: 2024, month: 5, week: 0 },
{ poId: "p4", vesselId: "v2", accountId: "L2", amount: 70, fy: 2025, month: 11, week: 2 },
],
};
describe("financial-year helpers", () => {
it("maps AprMar to the Indian FY start year", () => {
expect(fyStartYear(new Date(2025, 3, 1))).toBe(2025); // Apr
expect(fyStartYear(new Date(2025, 0, 15))).toBe(2024); // Jan → prior FY
expect(fyStartYear(new Date(2025, 2, 31))).toBe(2024); // Mar → prior FY
});
it("labels and indexes months within the FY", () => {
expect(fyLabel(2025)).toBe("FY 202526");
expect(fyMonthIndex(new Date(2025, 3, 1))).toBe(0); // Apr
expect(fyMonthIndex(new Date(2026, 2, 1))).toBe(11); // Mar
});
});
describe("costCentreRows", () => {
it("totals the selected FY by vessel with a 12-month series and PO count", () => {
const rows = costCentreRows(DS, 2025);
const v1 = rows.find((r) => r.id === "v1")!;
expect(v1.total).toBe(180);
expect(v1.months[0]).toBe(130); // 100 + 30
expect(v1.months[1]).toBe(50);
expect(v1.poCount).toBe(2); // distinct POs (p1 is multi-account, + p2) — not row count
expect(v1.fyTotals).toEqual([0, 180]); // [2024, 2025]
const v2 = rows.find((r) => r.id === "v2")!;
expect(v2.total).toBe(70);
expect(v2.fyTotals).toEqual([200, 70]);
});
});
describe("accounting-code rollup", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("rolls leaf spend up to the heading", () => {
expect(accountNodeSpend(DS, idx, "H", 2025).total).toBe(250); // 100+50+30+70
expect(accountNodeSpend(DS, idx, "L1", 2025).total).toBe(150);
});
it("lists the children to compare at a drill level", () => {
const top = accountLevelRows(DS, idx, null, 2025); // headings
expect(top.map((r) => r.node.id)).toEqual(["H"]);
const subs = accountLevelRows(DS, idx, "H", 2025);
expect(subs.map((r) => r.node.id)).toEqual(["S"]);
});
it("leaf detection and leaf set", () => {
expect(idx.isLeaf("L1")).toBe(true);
expect(idx.isLeaf("H")).toBe(false);
expect([...idx.leavesUnder("H")].sort()).toEqual(["L1", "L2"]);
});
});
describe("breakdowns", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("top accounting codes for a cost centre (by tier)", () => {
const bd = topAccountsForCostCentre(DS, idx, "v1", 2025, "Leaf");
expect(bd.map((b) => [b.id, b.value])).toEqual([
["L1", 150],
["L2", 30],
]);
});
it("cost centres for an account node", () => {
const bd = costCentresForAccount(DS, idx, "H", 2025);
expect(bd.map((b) => [b.id, b.value])).toEqual([
["v1", 180],
["v2", 70],
]);
});
it("child breakdown of a non-leaf node", () => {
const bd = childBreakdown(DS, idx, "H", 2025);
expect(bd).toEqual([{ id: "S", label: "5100 · Vessel Running", value: 250 }]);
});
});
describe("line-item account allocation (#3)", () => {
const po = { id: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 };
it("splits a PO proportionally across its line-item accounts, summing back to the PO total", () => {
const out = allocatePoSpend(po, [
{ accountId: "L1", amount: 30 },
{ accountId: "L2", amount: 90 },
]);
const byAcc = Object.fromEntries(out.map((r) => [r.accountId, r.amount]));
expect(byAcc["L1"]).toBeCloseTo(25); // 100 * 30/120
expect(byAcc["L2"]).toBeCloseTo(75); // 100 * 90/120
expect(out.reduce((s, r) => s + r.amount, 0)).toBeCloseTo(100);
expect(out.every((r) => r.poId === "po9" && r.vesselId === "v1")).toBe(true);
});
it("falls a line with no account back to the PO-level account", () => {
const out = allocatePoSpend(po, [{ accountId: null, amount: 10 }, { accountId: "L2", amount: 10 }]);
expect(Object.fromEntries(out.map((r) => [r.accountId, r.amount]))).toEqual({ L1: 50, L2: 50 });
});
it("puts the whole amount on the PO account when there are no line items", () => {
expect(allocatePoSpend(po, [])).toEqual([{ poId: "po9", vesselId: "v1", accountId: "L1", amount: 100, fy: 2025, month: 0, week: 0 }]);
});
});
describe("weekly buckets (#1)", () => {
const idx = buildAccountIndex(ACCOUNTS);
it("computes week-of-month from the day", () => {
expect(weekOfMonth(new Date(2025, 3, 1))).toBe(0);
expect(weekOfMonth(new Date(2025, 3, 8))).toBe(1);
expect(weekOfMonth(new Date(2025, 3, 29))).toBe(4);
});
it("buckets a month's spend into weeks for a cost centre and an account node", () => {
expect(costCentreWeekly(DS, "v1", 2025, 0)).toEqual([130, 0, 0, 0, 0]); // both month-0 rows in W1
expect(accountNodeWeekly(DS, idx, "H", 2025, 0)).toEqual([130, 0, 0, 0, 0]);
});
});
describe("custom selection (#2)", () => {
it("parses and de-dupes ?sel=", () => {
expect(parseSel("a,b,a, ,c")).toEqual(["a", "b", "c"]);
expect(parseSel(undefined)).toEqual([]);
});
it("toggles ids in and out", () => {
expect(toggleSel(["a", "b"], "a")).toEqual(["b"]);
expect(toggleSel(["a"], "b")).toEqual(["a", "b"]);
});
});
describe("scope + param parsing", () => {
it("applies Top/Bottom-N to a descending list", () => {
const sorted = [5, 4, 3, 2, 1];
expect(applyScope(sorted, "top5")).toEqual([5, 4, 3, 2, 1]);
expect(applyScope([9, 8, 7, 6, 5, 4, 3, 2, 1, 0, -1], "top10")).toHaveLength(10);
expect(applyScope(sorted, "bottom5")).toEqual([1, 2, 3, 4, 5]);
expect(applyScope(sorted, "all")).toEqual(sorted);
});
it("parses scope + resolves FY with sensible defaults", () => {
expect(parseScope("top10")).toBe("top10");
expect(parseScope("garbage")).toBe("top5");
expect(resolveFy(DS, "2024")).toBe(2024);
expect(resolveFy(DS, undefined)).toBe(2025); // latest
expect(resolveFy(DS, "1999")).toBe(2025); // out of range → latest
});
it("parses granularity (incl. weekly) and resolves the weekly month", () => {
expect(parseGranularity("weekly")).toBe("weekly");
expect(parseGranularity("yearly")).toBe("yearly");
expect(parseGranularity(undefined)).toBe("monthly");
expect(resolveMonth(DS, 2025, undefined)).toBe(11); // latest month with spend in FY2025
expect(resolveMonth(DS, 2025, "3")).toBe(3);
expect(resolveMonth(DS, 2025, "99")).toBe(11); // out of range → latest
});
});

View file

@ -100,3 +100,64 @@ describe("Sidebar collapsible sections", () => {
expect(within(adminVendors).queryByText("Vendors")).toBeTruthy();
});
});
describe("Purchase Order links under Purchasing", () => {
it("renders the renamed PO links inside the Purchasing section (not top-level)", () => {
render(<Sidebar userRole="MANAGER" />);
// Collapsed by default → PO links are not in the DOM until Purchasing opens.
expect(screen.queryByRole("link", { name: /New Purchase Order/i })).not.toBeInTheDocument();
fireEvent.click(headerButton("Purchasing"));
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Closed Purchase Orders/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Import Purchase Order/i })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Purchase Order History/i })).toBeInTheDocument();
});
it("auto-expands Purchasing when a PO route is active", () => {
mockPathname = "/po/new";
render(<Sidebar userRole="MANAGER" />);
expect(headerButton("Purchasing")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("link", { name: /New Purchase Order/i })).toBeInTheDocument();
});
it("drops the old PO labels", () => {
render(<Sidebar userRole="MANAGER" />);
fireEvent.click(headerButton("Purchasing"));
// Old labels were "New PO" / "Import PO" / "History".
expect(screen.queryByRole("link", { name: /^New PO$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /^Import PO$/i })).not.toBeInTheDocument();
expect(screen.queryByRole("link", { name: /^History$/i })).not.toBeInTheDocument();
});
});
describe("Reports section (Purchasing subheading)", () => {
it("reveals the report links under a Purchasing subheading for an analytics role", () => {
render(<Sidebar userRole="MANAGER" />);
// Collapsed by default.
expect(screen.queryByRole("link", { name: /Accounting Codes/i })).not.toBeInTheDocument();
fireEvent.click(headerButton("Reports"));
expect(screen.getByRole("link", { name: /Cost Centres/i })).toHaveAttribute("href", "/reports/cost-centres");
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toHaveAttribute("href", "/reports/accounting-codes");
// The "Purchasing" subheading is rendered in addition to the Purchasing section header.
expect(screen.getAllByText("Purchasing").length).toBeGreaterThanOrEqual(2);
});
it("auto-expands Reports when a report route is active", () => {
mockPathname = "/reports/accounting-codes";
render(<Sidebar userRole="MANAGER" />);
expect(headerButton("Reports")).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("link", { name: /Accounting Codes/i })).toBeInTheDocument();
});
it("is hidden from roles without view_analytics", () => {
render(<Sidebar userRole="TECHNICAL" />);
expect(screen.queryByRole("button", { name: /^Reports/i })).not.toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show more