Compare commits

..

68 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
f4c8ec7585 feat(po): make TermsField a combobox (type-or-pick)
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
Per review: the five named PO T&C slots now allow a one-off custom clause as
well as picking a catalogued one. TermsField becomes a native <input list> +
<datalist> combobox (still plain FormData, no form/page changes). Any current/
custom value is preserved as the input value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 03:42:38 +05:30
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
a99b2ed5df feat(po): admin-managed Terms & Conditions catalogue + PO dropdowns (#11)
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
Mirrors the Place-of-Delivery (#19) pattern: an admin clause library that feeds
the PO T&C fields as dropdowns. (No "work order" type — POs only, per steer.)

- schema + migration: TermsCondition (category enum + text + isActive); the
  migration seeds the prior TC_DEFAULTS as the starting clauses.
- permission manage_terms (Manager + SuperUser + Admin).
- admin screen /admin/terms: table + Add/Edit dialogs + activate/deactivate +
  delete (mirrors /admin/delivery-locations); sidebar link under Administration.
- PO forms (new / edit / manager-edit): the five named T&C slots (Delivery /
  Dispatch / Inspection / Transit Insurance / Payment Terms) become a shared
  <TermsField> select sourced from active clauses of that category; "Others"
  stays free text; the fixed boilerplate lines are untouched.
- tc* columns stay free-text SNAPSHOTS (export/import unchanged); a current value
  not among active clauses is preserved as a "(current)" option.
- tests: terms CRUD + permission guard + grouping helper (6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 03:38:32 +05:30
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
7fe46c2448 Merge pull request 'feat(po): email PO to vendor � PDF link in an Outlook draft (#14)' (#101) from feat/email-po-to-vendor into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #101
2026-06-23 21:55:10 +00:00
470523a7a6 Merge branch 'master' into feat/email-po-to-vendor
All checks were successful
PR checks / checks (pull_request) Successful in 53s
PR checks / integration (pull_request) Successful in 32s
2026-06-23 21:55:02 +00:00
cc7161d5ed Merge pull request 'fix(deploy): expand sparse checkout so microservices deploy (run #120 fix)' (#103) from fix/deploy-services-sparse-checkout into master
Some checks are pending
Deploy release to production / deploy (push) Successful in 2m4s
Refresh staging / refresh (push) Waiting to run
Reviewed-on: #103
2026-06-23 21:43:43 +00:00
cd2bcefdbd fix(deploy): expand sparse checkout so microservices/ecosystem are on disk
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 32s
Run #120 (v0.3.0 deploy) failed at the microservice step: every service folder
and ecosystem.config.js were "absent", and pm2 reported "File ecosystem.config.js
not found". Root cause: ~/pms on pms1 is a sparse checkout limited to App/, so
`git checkout -f $TAG` never materialised the service folders or the root
ecosystem.config.js. The app itself deployed fine (App/ is in the sparse set) and
prod stayed healthy.

- deploy.yml: before managing services, disable sparse-checkout (and clear the
  legacy core.sparseCheckout config + .git/info/sparse-checkout), then re-checkout
  the tag to materialise the full tree. Idempotent / no-op once expanded.
- Guard the pm2 call: if ecosystem.config.js is still absent, fail with a clear
  diagnostic (+ sparse-checkout list) instead of the cryptic PM2 error.
- README: note the sparse-checkout expansion.

Needs a fresh tag (e.g. v0.3.1) to re-run the deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 03:12:54 +05:30
2ac11d7528 Merge pull request 'chore(deploy): build & (re)start microservices on release tag' (#102) from chore/deploy-microservices into master
Some checks failed
Refresh staging / refresh (push) Successful in 7s
Deploy release to production / deploy (push) Failing after 1m14s
Reviewed-on: #102
2026-06-23 21:33:45 +00:00
6b0210078a chore(deploy): build & (re)start microservices on release tag
All checks were successful
PR checks / checks (pull_request) Successful in 42s
PR checks / integration (pull_request) Successful in 31s
The v* tag deploy previously only updated the Next app (ppms); GstService /
EpfoService / PdfService were never built or restarted by automation. Now the
same deploy manages them.

- ecosystem.config.js (root): pm2 definitions for gst-service (3003) /
  epfo-service (3004) / pdf-service (3005). Registers only services whose source
  is checked out (keyed on package.json), so a not-yet-merged service is skipped
  and adopted automatically once its PR lands. Secrets come from the env at pm2
  invocation; ports are fixed here.
- deploy.yml: after the app restart, export the few service secrets out of
  App/.env (never PORT or the ephemeral FORGEJO_TOKEN), npm install + playwright
  install chromium + build each present service, then
  `pm2 startOrReload ecosystem.config.js --update-env` (create on first release,
  reload after) + pm2 save, and health-check :3003/:3004/:3005.
- automation/README.md: documents the flow + the one-time alignment for any
  pre-existing differently-named pm2 process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:59:36 +05:30
8206483f88 Merge remote-tracking branch 'origin/master' into feat/email-po-to-vendor
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
# Conflicts:
#	App/app/api/po/[id]/export/route.ts
2026-06-24 02:50:32 +05:30
3edd1ffcc5 feat(po): email PO to vendor — PDF link in an Outlook draft (#14)
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.

Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):

- PdfService/: new standalone Express + Playwright microservice (GstService/
  EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
  headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
  PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
  `pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
  uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:45:48 +05:30
144d44ccca Merge pull request 'feat(po): admin-managed delivery locations + Place of Delivery dropdown (#19)' (#100) from feat/delivery-locations into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #100
2026-06-23 20:43:57 +00:00
dc9ab327b8 Merge branch 'master' into feat/delivery-locations
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 31s
2026-06-23 20:43:24 +00:00
5aae45299b feat(po): admin-managed delivery locations + Place of Delivery dropdown (#19)
All checks were successful
PR checks / checks (pull_request) Successful in 42s
PR checks / integration (pull_request) Successful in 30s
Replaces the free-text "Place of Delivery" with a dropdown sourced from a new
admin-managed Delivery Locations list (each = a Company FK + free-text address).

- schema + migration: new DeliveryLocation model (companyId, address, isActive).
- permission: manage_delivery_locations granted to Manager + SuperUser + Admin
  (Manager-accessible, not admin-only, per the issue).
- admin screen /admin/delivery-locations: table + Add/Edit dialogs +
  activate/deactivate + delete (mirrors /admin/sites); sidebar link under
  Administration for Manager/SuperUser/Admin.
- PO forms (new / edit / manager-edit): shared <DeliveryLocationField> native
  select populated from active locations, formatted "Company — address".
- PurchaseOrder.placeOfDelivery stays a free-text SNAPSHOT (no FK) — the dropdown
  only changes how the value is picked, so export/import/historical POs are
  unchanged, and an edit preserves a current value not in the list as a
  "(current)" option. Deleting a location is therefore always safe.
- tests: delivery-location CRUD + permission guard (6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:08:59 +05:30
8a2c592f6f Merge pull request 'feat(po): manager sets advance payment on approval (#92)' (#99) from feat/manager-advance-payment into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #99
2026-06-23 20:26:27 +00:00
0e0e377718 Merge branch 'master' into feat/manager-advance-payment
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 30s
2026-06-23 20:26:09 +00:00
455d268925 docs(schema): note suggestedAdvancePayment is the reuse point for issue #91
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 32s
The structured payment-request lane (#91) should extend this column for the
ADVANCE/PART 'exact sum due', not add a parallel field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 01:45:56 +05:30
99c928213b feat(po): manager sets advance payment on approval (issue #92)
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
The approving Manager decides how much of the PO is paid first, via a
0–100% slider on the approval card (default 100% = full). The slider is
convenience only — the resolved ABSOLUTE amount is stored on
PurchaseOrder.suggestedAdvancePayment (Decimal(12,2), nullable).

- schema + migration: add suggestedAdvancePayment (null = no explicit
  advance ⇒ full payment, preserves legacy behaviour).
- approvePo(): accepts the amount, clamps to [0, totalAmount], persists
  it, records it on the APPROVED audit row. Set once at approval; never
  edited afterwards.
- approval-actions.tsx: whole-percent slider showing the resolved ₹
  amount + remaining balance; value sent with Approve / Approve-with-Remarks.
- Accounts surface: payment queue + PO detail show the advance; it
  prefills the FIRST payment amount (only when nothing is paid yet and
  it is a true partial). Balance runs the normal PARTIALLY_PAID loop.
- Not shown on the exported PO/invoice (po-export-layout untouched).
- Tests: persist + audit metadata + clamp-to-total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 01:40:20 +05:30
afa5937429 Merge pull request 'fix(crewing): rank-held universal, ex-hand an admin-only flag' (#98) from fix/crewing-exhand-admin-only into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #98
2026-06-23 16:24:14 +00:00
e7888a0886 Merge branch 'master' into fix/crewing-exhand-admin-only
All checks were successful
PR checks / checks (pull_request) Successful in 44s
PR checks / integration (pull_request) Successful in 30s
2026-06-23 16:22:47 +00:00
db4c2096ec Merge pull request 'feat(po): submitter view-all of POs + History + export (feature-flagged)' (#63) from feat/submitter-view-all into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #63
2026-06-23 16:22:32 +00:00
6da6c277ad Merge remote-tracking branch 'origin/master' into feat/submitter-view-all
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
# Conflicts:
#	App/CLAUDE.md
#	App/components/layout/sidebar.tsx
#	App/lib/feature-flags.ts
2026-06-23 21:50:08 +05:30
e951a44a67 fix(crewing): make rank-held universal, ex-hand an admin-only flag
All checks were successful
PR checks / checks (pull_request) Successful in 41s
PR checks / integration (pull_request) Successful in 30s
Rank held applies to every candidate, not just ex-hands; it auto-updates
for returning crew on sign-off. Ex-hand designation is decoupled from the
Source dropdown and owned by the office:

- Candidate form: drop the EX_HAND source option, relabel "Rank held
  (ex-hands)" to "Rank held". addCandidate always intakes NEW/CANDIDATE
  (ex-hand recognition still reuses an existing EX_HAND row); updateCandidate
  no longer rewrites type/status, so an admin-set EX_HAND or onboarded
  EMPLOYEE is never clobbered by a candidate edit.
- Admin crew form: the type NEW/EX_HAND select becomes an "Ex-hand
  (returning crew)" checkbox -- the only place ex-hand is tagged.
- List/detail ex-hand indicators key on type === EX_HAND (not source).
- Sign-off preserves the original recruitment source when flipping to EX_HAND.
- Tests seed EX_HAND rows directly; assert candidate intake stays NEW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:33:50 +05:30
dfefd86832 Merge pull request 'fix: Sidebar - make headings collapsible' (#97) from claude/issue-96 into master
All checks were successful
Refresh staging / refresh (push) Successful in 8s
Reviewed-on: #97
2026-06-23 15:26:15 +00:00
Claude (auto-fix)
964af311f8 feat(sidebar): make section headings collapsible accordion
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 30s
Purchasing, Crewing and Administration headings are now collapsible
buttons (chevron + aria-expanded/aria-controls) that collapse by
default. Single-open accordion: opening one heading collapses any
other open one. The section containing the active route auto-expands
on mount/navigation so the user is never stranded on a hidden link.

Adds a jsdom/Testing Library unit test covering default-collapsed,
toggle, single-open accordion, and active-route auto-expand.

Fixes #96

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:44:37 +05:30
561ff8acf4 Merge pull request 'fix(automation): reset test-db schema before restore (avoid P3009 on unreleased migrations)' (#95) from fix/refresh-test-db-clean-schema into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #95
2026-06-23 14:53:17 +00:00
81744d1fa8 fix(automation): reset test-db schema before restore to avoid P3009
All checks were successful
PR checks / checks (pull_request) Successful in 41s
PR checks / integration (pull_request) Successful in 30s
refresh-test-db.sh mirrored prod with `pg_dump --clean`, which only drops objects
that exist in prod. Objects created on pelagia_test by a previously-applied
UNRELEASED migration (e.g. crewing tables/types not yet released to prod) survived
as leftovers, then collided with the `migrate deploy` replay ("type already
exists", P3009) — blocking every later migration and 500-ing the staging app.

Drop+recreate `public` before the restore so the test DB starts from exactly
prod's objects and unreleased migrations apply cleanly. Adds a guard that refuses
to run unless the derived target is the expected test DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:18:24 +05:30
f7e38fc60c Merge pull request 'feat(crewing): land full Crewing module on master (Phases 1–5 + hardening)' (#93) from feat/crewing-review-hardening into master
Some checks failed
Refresh staging / refresh (push) Failing after 7s
Reviewed-on: #93
2026-06-23 08:44:55 +00:00
2fd3709b22 Merge branch 'master' into feat/submitter-view-all
All checks were successful
PR checks / checks (pull_request) Successful in 34s
PR checks / integration (pull_request) Successful in 26s
2026-06-21 23:30:44 +00:00
da2d856b73 feat(po): submitter view-all of POs + History + export (feature-flagged)
All checks were successful
PR checks / checks (pull_request) Successful in 34s
PR checks / integration (pull_request) Successful in 26s
Gated behind NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED (opt-in, "true").
When on, submitter roles (TECHNICAL/MANNING) get read-only access to every
PO: the History page + report export, any other user's PO detail page, and
the per-PO Export PDF/XLSX buttons. No approval/payment/edit rights are added.

- lib/feature-flags.ts: SUBMITTER_VIEW_ALL_ENABLED flag
- lib/permissions.ts: isSubmitterRole / submitterCanViewAll / canViewAllPos
- po/[id] page + export route: gate via canViewAllPos
- history page + reports/export route: OR submitterCanViewAll into export_reports
- sidebar: show History to submitters when flag on
- tests: permission helpers, both flag states
- docs: .env.example, CLAUDE.md (wiki updated separately)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 04:57:11 +05:30
147 changed files with 9004 additions and 526 deletions

View file

@ -40,6 +40,70 @@ jobs:
pm2 restart ppms
echo "=== Deployed $TAG ==="
- name: Build & (re)start microservices
run: |
set -euo pipefail
export NVM_DIR="$HOME/.nvm"
. "$NVM_DIR/nvm.sh"
cd "$HOME/pms"
# ~/pms has historically been a SPARSE checkout limited to App/ (only the
# app deployed), so the service folders + ecosystem.config.js never landed
# on disk. Expand the working tree to the full repo, then re-materialise
# the tag. Idempotent: a no-op once sparse is disabled / if never sparse.
git sparse-checkout disable 2>/dev/null || true
git config --unset core.sparseCheckout 2>/dev/null || true
rm -f .git/info/sparse-checkout 2>/dev/null || true
git checkout -f "refs/tags/${GITHUB_REF_NAME}"
# Pull only the few keys the services need out of the app's .env (the
# single source of truth on the host). Never import PORT (each service's
# port is fixed in ecosystem.config.js) or the runner's ephemeral
# FORGEJO_TOKEN. Missing keys → empty, which the services tolerate.
envget() { grep -E "^$1=" App/.env 2>/dev/null | head -1 | sed -E 's/^[^=]+=//; s/^"//; s/"$//'; }
export PDF_SERVICE_TOKEN="$(envget PDF_SERVICE_TOKEN)"
export ALLOWED_ORIGIN="$(envget ALLOWED_ORIGIN)"
export EPFO_LIVE="$(envget EPFO_LIVE)"
# Build each present service (skip any not yet in the tree, e.g. before
# its feature PR has merged). npm install (not ci) — not every service
# carries a lockfile. Playwright's postinstall fetches the browser; the
# explicit install is a cached, idempotent backstop.
for svc in GstService EpfoService PdfService; do
[ -f "$svc/package.json" ] || { echo "skip $svc (absent)"; continue; }
echo "=== Building $svc ==="
( cd "$svc" && npm install --no-audit --no-fund && npx playwright install chromium && npm run build )
done
# Create on first release, zero-downtime reload thereafter. The
# ecosystem registers only services whose dirs exist.
if [ ! -f ecosystem.config.js ]; then
echo "ERROR: ecosystem.config.js absent in $(pwd) after checkout — sparse-checkout not expanded?"
git sparse-checkout list 2>/dev/null || true
exit 1
fi
pm2 startOrReload ecosystem.config.js --update-env
pm2 save
pm2 list
echo "=== Microservices up ==="
- name: Verify services respond
run: |
sleep 3
cd "$HOME/pms"
check() {
local dir="$1" port="$2"
[ -f "$dir/package.json" ] || { echo "skip $dir (absent)"; return 0; }
local code
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$port/health" || echo "000")
echo "$dir on :$port /health → HTTP $code"
test "$code" = "200"
}
check GstService 3003
check EpfoService 3004
check PdfService 3005
- name: Verify portal responds
run: |
sleep 5

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

View file

@ -56,12 +56,29 @@ GST_SERVICE_URL=http://localhost:3003
# validated against a real session first). Aadhaar is NOT handled here (manual).
EPFO_SERVICE_URL=http://localhost:3004
# ── PDF render microservice ("Email PO to vendor", issue #14) ──
# Run the PdfService/ microservice alongside the app (default localhost:3005).
# Start with: cd PdfService && npm install && npm run dev
# PDF_SERVICE_TOKEN is a shared secret: the app puts it on the export URL and
# PdfService echoes it in the x-pdf-token header. APP_INTERNAL_URL is the base URL
# PdfService can reach the app at (falls back to NEXTAUTH_URL).
PDF_SERVICE_URL=http://localhost:3005
PDF_SERVICE_TOKEN=dev-pdf-token-change-me
# APP_INTERNAL_URL=http://localhost:3000
# ── Forgejo issue reporting (Report Issue button) ─────────────
# Token needs write:issue scope on the repo below.
FORGEJO_URL=https://git.pelagiamarine.com
FORGEJO_REPO=shad0w/pelagia-portal
FORGEJO_TOKEN=
# ── Feature flags (NEXT_PUBLIC_, available to client + server) ─
# Inventory tracking (site stock / consumption). On unless explicitly "false".
# NEXT_PUBLIC_INVENTORY_ENABLED=false
# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History
# page (read-only). Opt-in — on only when exactly "true".
# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true
# ── Non-production banner ─────────────────────────────────────
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
# Leave UNSET in production. Staging sets this automatically.

1
App/.gitignore vendored
View file

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

View file

@ -98,6 +98,29 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
### Delivery Locations (issue #19)
`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete).
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)``"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
### Terms & Conditions catalogue (issue #11)
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`)
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (AprMar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
@ -106,18 +129,45 @@ Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25
When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation).
**Advance payment (issue #92):** the approving Manager sets how much of the PO is paid first via a 0100% slider on the approval card (`approval-actions.tsx`, default 100%). The slider is convenience only — the resolved **absolute amount** is stored on `PurchaseOrder.suggestedAdvancePayment` (`Decimal(12,2)`, nullable; null = no explicit advance ⇒ full payment). `approvePo()` clamps it to `[0, totalAmount]` and records it on the `APPROVED` audit row; it is **set once at approval and never edited after**. Accounts sees it on the payment queue + PO detail, and it **prefills the first payment's amount** (`payment-actions.tsx`, only when nothing is paid yet and the advance is a true partial); the balance then runs through the normal `PARTIALLY_PAID` loop. It does **not** appear on the exported PO/invoice.
### Vendors
`Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code).
### Email PO to vendor (issue #14)
An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once the PO is approved — `MGR_APPROVED` through `CLOSED`, and again after payment — when the vendor has a primary-contact email) opens an **Outlook draft** addressed to that contact with a **time-limited PDF download link** in the body. The user reviews and sends it.
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:
@ -231,7 +281,11 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
PDF_SERVICE_URL # PdfService microservice for PO→PDF render (defaults to localhost:3005)
PDF_SERVICE_TOKEN # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
```

View file

@ -15,7 +15,6 @@ const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-m
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
const TYPES: CandidateType[] = ["NEW", "EX_HAND"];
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
type Opt = { id: string; name: string };
@ -132,7 +131,10 @@ function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value as CandidateType })}>{TYPES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
<label className="flex items-center gap-2 px-1 text-sm text-neutral-700">
<input type="checkbox" className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" checked={f.type === "EX_HAND"} onChange={(e) => setF({ ...f, type: e.target.checked ? "EX_HAND" : "NEW" })} />
Ex-hand (returning crew)
</label>
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} {r.name}</option>)}</select>
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />

View file

@ -0,0 +1,77 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const schema = z.object({
companyId: z.string().min(1, "Company is required"),
address: z.string().trim().min(1, "Delivery address is required"),
});
type Result = { ok: true } | { error: string };
async function guard(): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_delivery_locations")) {
return { error: "Forbidden" };
}
return { ok: true };
}
export async function createDeliveryLocation(formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0].message };
// Guard against a dangling FK if the company was removed concurrently.
const company = await db.company.findUnique({ where: { id: parsed.data.companyId }, select: { id: true } });
if (!company) return { error: "Selected company no longer exists." };
await db.deliveryLocation.create({
data: { companyId: parsed.data.companyId, address: parsed.data.address },
});
revalidatePath("/admin/delivery-locations");
return { ok: true };
}
export async function updateDeliveryLocation(id: string, formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.errors[0].message };
await db.deliveryLocation.update({
where: { id },
data: { companyId: parsed.data.companyId, address: parsed.data.address },
});
revalidatePath("/admin/delivery-locations");
return { ok: true };
}
export async function toggleDeliveryLocationActive(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const loc = await db.deliveryLocation.findUnique({ where: { id }, select: { isActive: true } });
if (!loc) return { error: "Not found" };
await db.deliveryLocation.update({ where: { id }, data: { isActive: !loc.isActive } });
revalidatePath("/admin/delivery-locations");
return { ok: true };
}
export async function deleteDeliveryLocation(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
// Safe to delete: POs keep their place-of-delivery as a text snapshot, so no
// purchase order references this row.
await db.deliveryLocation.delete({ where: { id } });
revalidatePath("/admin/delivery-locations");
return { ok: true };
}

View file

@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createDeliveryLocation, updateDeliveryLocation } 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 CompanyOption = { id: string; name: string };
export type DeliveryLocationRow = {
id: string;
companyId: string;
companyName: string;
address: string;
isActive: boolean;
};
function Fields({ companies, location }: { companies: CompanyOption[]; location?: DeliveryLocationRow }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Company *</label>
<select name="companyId" defaultValue={location?.companyId ?? ""} required className={INPUT}>
<option value="" disabled>Select a company</option>
{companies.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Delivery address *</label>
<textarea name="address" defaultValue={location?.address ?? ""} rows={3} required className={INPUT} placeholder="e.g. Reti Bundar, Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614" />
</div>
</div>
);
}
export function AddDeliveryLocationButton({ companies }: { companies: CompanyOption[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createDeliveryLocation(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Delivery Location
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Delivery Location">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields companies={companies} />
{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>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditDeliveryLocationButton({
companies,
location,
open: controlledOpen,
onOpenChange,
}: {
companies: CompanyOption[];
location: DeliveryLocationRow;
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await updateDeliveryLocation(location.id, new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Delivery Location">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields companies={companies} location={location} />
{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>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { useTableControls } from "@/components/ui/use-table-controls";
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 {
AddDeliveryLocationButton,
EditDeliveryLocationButton,
type CompanyOption,
type DeliveryLocationRow,
} from "./delivery-location-form";
import { deleteDeliveryLocation, toggleDeliveryLocationActive } from "./actions";
const CHIPS = ["Active", "Inactive"];
function LocationActionsMenu({ companies, location }: { companies: CompanyOption[]; location: DeliveryLocationRow }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}>
{location.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditDeliveryLocationButton companies={companies} location={location} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
label={`${location.companyName}${location.address}`}
onConfirm={() => deleteDeliveryLocation(location.id)}
/>
<ConfirmDialog
open={toggleOpen}
onOpenChange={setToggleOpen}
title={location.isActive ? "Deactivate location?" : "Activate location?"}
description={
location.isActive
? "It will no longer appear in the Place of Delivery dropdown."
: "It will appear in the Place of Delivery dropdown again."
}
confirmLabel={location.isActive ? "Deactivate" : "Activate"}
onConfirm={() => toggleDeliveryLocationActive(location.id)}
/>
</>
);
}
export function DeliveryLocationsTable({
locations,
companies,
}: {
locations: DeliveryLocationRow[];
companies: CompanyOption[];
}) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<DeliveryLocationRow>({
rows: locations,
defaultSortKey: "companyName",
searchText: (l) => [l.companyName, l.address, l.isActive ? "active" : "inactive"].join(" "),
chipMatch: (l, chip) => {
if (chip.toLowerCase() === "active") return l.isActive;
if (chip.toLowerCase() === "inactive") return !l.isActive;
return false;
},
sortValue: (l, key) => {
if (key === "isActive") return l.isActive ? "Active" : "Inactive";
const val = l[key as keyof DeliveryLocationRow];
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
},
});
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Delivery Locations</h1>
<p className="text-sm text-neutral-500 mt-0.5">Destinations that populate the PO &ldquo;Place of Delivery&rdquo; dropdown</p>
</div>
<AddDeliveryLocationButton companies={companies} />
</div>
<TableControls
search={search}
onSearch={setSearch}
searchPlaceholder="Search company or address…"
chips={CHIPS}
activeFilters={activeFilters}
onToggleFilter={toggleFilter}
/>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<SortableTh sortKey="companyName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Company</SortableTh>
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Address</SortableTh>
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Status</SortableTh>
<th className="px-4 py-3 w-10"></th>
</tr>
</thead>
<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 delivery locations yet. Add one to populate the Place of Delivery dropdown.
</td>
</tr>
)}
{filtered.map((location) => (
<tr key={location.id} className="hover:bg-neutral-50">
<td className="px-4 py-3 font-medium text-neutral-900">{location.companyName}</td>
<td className="px-4 py-3 text-neutral-600 max-w-md whitespace-pre-wrap">{location.address}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
location.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
{location.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<LocationActionsMenu companies={companies} location={location} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,35 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { DeliveryLocationsTable } from "./delivery-locations-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Delivery Locations" };
export default async function DeliveryLocationsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_delivery_locations")) redirect("/dashboard");
const [locations, companies] = await Promise.all([
db.deliveryLocation.findMany({
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
include: { company: { select: { name: true } } },
}),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
return (
<DeliveryLocationsTable
companies={companies}
locations={locations.map((l) => ({
id: l.id,
companyId: l.companyId,
companyName: l.company.name,
address: l.address,
isActive: l.isActive,
}))}
/>
);
}

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

@ -0,0 +1,99 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const schema = z.object({
// 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 };
async function guard(): Promise<{ ok: true } | { error: string }> {
const session = await auth();
if (!session?.user || !hasPermission(session.user.role, "manage_terms")) {
return { error: "Forbidden" };
}
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 = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
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 };
}
export async function updateTerm(id: string, formData: FormData): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0].message };
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 };
}
export async function toggleTermActive(id: string): Promise<Result> {
const g = await guard();
if ("error" in g) return g;
const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } });
if (!term) return { error: "Not found" };
await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } });
revalidatePath("/admin/terms");
return { ok: true };
}
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 a JSON snapshot, so no PO references this row.
await db.termsCondition.delete({ where: { id } });
revalidatePath("/admin/terms");
return { ok: true };
}

View file

@ -0,0 +1,35 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { TermsTable } from "./terms-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Terms & Conditions" };
export default async function TermsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
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,
categoryName: t.category.name,
text: t.text,
isDefault: t.isDefault,
isActive: t.isActive,
}))}
/>
);
}

View file

@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
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;
categoryName: string;
text: string;
isDefault: boolean;
isActive: boolean;
};
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>
<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} />
))}
</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({ categoryNames }: { categoryNames: string[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createTerm(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Clause
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<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>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditTermButton({
term,
categoryNames,
open: controlledOpen,
onOpenChange,
}: {
term: TermRow;
categoryNames: string[];
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await updateTerm(term.id, new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<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>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { useTableControls } from "@/components/ui/use-table-controls";
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 { AddTermButton, EditTermButton, type TermRow } from "./terms-form";
import { deleteTerm, toggleTermActive } from "./actions";
const CHIPS = ["Active", "Inactive"];
function TermActionsMenu({ term, categoryNames }: { term: TermRow; categoryNames: string[] }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [toggleOpen, setToggleOpen] = useState(false);
return (
<>
<RowActionsMenu>
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
<RowActionsItem onClick={() => setToggleOpen(true)}>
{term.isActive ? "Deactivate" : "Activate"}
</RowActionsItem>
<RowActionsSeparator />
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
</RowActionsMenu>
<EditTermButton term={term} categoryNames={categoryNames} open={editOpen} onOpenChange={setEditOpen} />
<DeleteConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
label={term.text}
onConfirm={() => deleteTerm(term.id)}
/>
<ConfirmDialog
open={toggleOpen}
onOpenChange={setToggleOpen}
title={term.isActive ? "Deactivate clause?" : "Activate clause?"}
description={
term.isActive
? "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)}
/>
</>
);
}
export function TermsTable({ terms, categoryNames }: { terms: TermRow[]; categoryNames: string[] }) {
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
useTableControls<TermRow>({
rows: terms,
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;
return false;
},
sortValue: (t, key) => {
if (key === "isActive") return t.isActive ? "Active" : "Inactive";
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 ?? "");
},
});
return (
<div>
<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">Categories &amp; clauses that populate the PO Terms &amp; Conditions editor</p>
</div>
<AddTermButton categoryNames={categoryNames} />
</div>
<TableControls
search={search}
onSearch={setSearch}
searchPlaceholder="Search category or clause…"
chips={CHIPS}
activeFilters={activeFilters}
onToggleFilter={toggleFilter}
/>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<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>
</thead>
<tbody className="divide-y divide-neutral-100">
{filtered.length === 0 && (
<tr>
<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">{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"
}`}>
{term.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<TermActionsMenu term={term} categoryNames={categoryNames} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

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

@ -3,6 +3,8 @@
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";
@ -12,14 +14,21 @@ export async function approvePo({
poId,
note,
withNote = false,
suggestedAdvancePayment,
}: {
poId: string;
note?: string;
withNote?: boolean;
// Absolute advance the Manager wants paid first (issue #92). Whole amount,
// resolved from the approval slider client-side. Omitted ⇒ full payment.
suggestedAdvancePayment?: number;
}): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true, lineItems: true },
@ -35,17 +44,28 @@ export async function approvePo({
return { error: "A vendor must be assigned before approving this PO." };
}
// Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance
// (full payment, current default behaviour). The slider always sends a value,
// but a malformed/over-total amount is clamped rather than rejected.
const total = Number(po.totalAmount);
const advance =
parsed.data.suggestedAdvancePayment === undefined
? null
: Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total);
await db.purchaseOrder.update({
where: { id: poId },
data: {
status: "MGR_APPROVED",
approvedAt: new Date(),
managerNote: note ?? null,
suggestedAdvancePayment: advance,
actions: {
create: {
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
note: note ?? null,
actorId: session.user.id,
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
},
},
},
@ -65,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

@ -3,21 +3,38 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
import { formatCurrency } from "@/lib/utils";
import type { POStatus } from "@prisma/client";
// Resolve the slider percent (whole number) into an absolute advance amount.
// 100% is the exact total (no rounding loss on paise); partial advances are
// rounded to whole rupees — the slider is convenience, the amount is the record.
function advanceAmount(total: number, percent: number): number {
if (percent >= 100) return total;
if (percent <= 0) return 0;
return Math.round((total * percent) / 100);
}
export function ApprovalActions({
poId,
poStatus,
totalAmount = 0,
currency = "INR",
}: {
poId: string;
poStatus: POStatus;
totalAmount?: number;
currency?: string;
}) {
const router = useRouter();
const [note, setNote] = useState("");
const [advancePercent, setAdvancePercent] = useState(100);
const [activeAction, setActiveAction] = useState<string | null>(null);
const [pending, setPending] = useState<string | null>(null);
const [error, setError] = useState("");
const advance = advanceAmount(totalAmount, advancePercent);
async function dispatch(action: string, requireNote = false) {
if (requireNote && !note.trim()) {
setError("A note is required for this action.");
@ -26,8 +43,10 @@ export function ApprovalActions({
setPending(action);
setError("");
let result: { ok: true } | { error: string } | undefined;
if (action === "approve") result = await approvePo({ poId, note });
else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true });
// Approvals carry the Manager's advance decision (resolved amount, not %).
if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance });
else if (action === "approve_note")
result = await approvePo({ poId, note, withNote: true, suggestedAdvancePayment: advance });
else if (action === "reject") result = await rejectPo({ poId, note });
else if (action === "request_edits") result = await requestEdits({ poId, note });
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
@ -45,6 +64,37 @@ export function ApprovalActions({
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
{/* Advance payment (issue #92) Manager decides how much Accounts pays
first. 100% = full payment; lower values seed the first part-payment. */}
<div className="mb-5 rounded-lg border border-neutral-200 bg-neutral-50 p-3.5">
<div className="flex items-center justify-between mb-2">
<label htmlFor="advance-slider" className="text-sm font-medium text-neutral-700">
Advance payment
</label>
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
{advancePercent}% · {formatCurrency(advance, currency)}
</span>
</div>
<input
id="advance-slider"
type="range"
min={0}
max={100}
step={1}
value={advancePercent}
onChange={(e) => setAdvancePercent(Number(e.target.value))}
className="w-full accent-primary-600"
/>
<p className="mt-1.5 text-xs text-neutral-500">
{advancePercent >= 100
? "Full payment — Accounts will be prompted to pay the whole PO value."
: `Accounts will be prompted to pay ${formatCurrency(advance, currency)} first; the balance of ${formatCurrency(
Math.max(totalAmount - advance, 0),
currency
)} follows the usual part-payment flow.`}
</p>
</div>
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 mb-1.5">

View file

@ -4,11 +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 { PoTermsEditor } from "@/components/po/po-terms-editor";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
type SerializedLineItem = {
id: string;
@ -39,6 +42,9 @@ interface Props {
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
}
const INPUT =
@ -51,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 }: 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;
@ -98,6 +105,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
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) {
@ -230,21 +238,14 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
<section>
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
<label className={LABEL}>Place of Delivery</label>
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT} />
</section>
{/* Vendor */}
<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 */}
@ -258,38 +259,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
{/* 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>
<input
name={name}
defaultValue={(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

@ -6,6 +6,9 @@ import { ApprovalActions } from "./approval-actions";
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 { 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";
@ -29,7 +32,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
});
const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
db.purchaseOrder.findUnique({
where: { id },
include: {
@ -52,12 +55,17 @@ export default async function ApprovalDetailPage({ params }: Props) {
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
]);
if (!po) notFound();
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -98,12 +106,20 @@ export default async function ApprovalDetailPage({ params }: Props) {
accounts={accounts}
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
/>
</div>
<div className="mt-4 md:mt-6">
{hasSignature ? (
<ApprovalActions poId={po.id} poStatus={po.status} />
<ApprovalActions
poId={po.id}
poStatus={po.status}
totalAmount={Number(po.totalAmount)}
currency={po.currency}
/>
) : (
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
<span className="text-warning-500 text-xl leading-none mt-0.5"></span>

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

@ -51,13 +51,13 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.source === "EX_HAND" && (
{c.type === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)}
</div>
</div>
{c.source === "EX_HAND" && (
{c.type === "EX_HAND" && (
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (

View file

@ -50,13 +50,6 @@ function parse(formData: FormData) {
});
}
// An EX_HAND source means a returning crew member; everyone else is NEW. The
// CrewStatus follows: ex-hands sit in the pool as EX_HAND, the rest as CANDIDATE.
function derive(source: CandidateSource) {
const isExHand = source === "EX_HAND";
return { type: isExHand ? "EX_HAND" : "NEW", status: isExHand ? "EX_HAND" : "CANDIDATE" } as const;
}
// Store an optional CV upload and return its storage key (null if none).
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
const file = formData.get("cv");
@ -74,53 +67,53 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
// candidate (not already tagged EX_HAND) is matched to their existing EX_HAND
// pool record by a stable key — email when given, else an exact name match —
// and the SAME row is reused (so their tour history, documents and bank stay on
// file) rather than creating a duplicate. (Heuristic: with no DOB on file a
// candidate is matched to their existing EX_HAND pool record by a stable key —
// email when given, else an exact name match — and the SAME row is reused (so
// their tour history, documents and bank stay on file) rather than creating a
// duplicate. (Ex-hand is set by the office on the admin crew record; the
// candidate form never tags it directly. Heuristic: with no DOB on file a
// name-only match can in theory collide; email is preferred when available.)
if (d.source !== "EX_HAND") {
const match = await db.crewMember.findFirst({
where: {
status: "EX_HAND",
...(d.email
? { email: { equals: d.email, mode: "insensitive" } }
: { name: { equals: d.name, mode: "insensitive" } }),
const match = await db.crewMember.findFirst({
where: {
status: "EX_HAND",
...(d.email
? { email: { equals: d.email, mode: "insensitive" } }
: { name: { equals: d.name, mode: "insensitive" } }),
},
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
});
if (match) {
const updated = await db.crewMember.update({
where: { id: match.id },
data: {
// Keep EX_HAND type/status; refresh the application's details, never
// discarding prior history (take the larger recorded experience).
appliedRankId: d.appliedRankId || match.appliedRankId,
currentRankId: d.currentRankId || match.currentRankId,
email: d.email || match.email,
phone: d.phone || match.phone,
notes: d.notes || match.notes,
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
},
select: { id: true, appliedRankId: true, currentRankId: true, email: true, phone: true, notes: true, experienceMonths: true, vesselTypeExperience: true },
});
if (match) {
const updated = await db.crewMember.update({
where: { id: match.id },
data: {
// Keep EX_HAND type/status; refresh the application's details, never
// discarding prior history (take the larger recorded experience).
appliedRankId: d.appliedRankId || match.appliedRankId,
currentRankId: d.currentRankId || match.currentRankId,
email: d.email || match.email,
phone: d.phone || match.phone,
notes: d.notes || match.notes,
experienceMonths: Math.max(d.experienceMonths, match.experienceMonths),
vesselTypeExperience: d.vesselTypeExperience || match.vesselTypeExperience,
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId, metadata: { exHandRecognized: true } } },
},
});
const cvKey = await storeCv(formData, updated.id);
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: updated.id };
}
const cvKey = await storeCv(formData, updated.id);
if (cvKey) await db.crewMember.update({ where: { id: updated.id }, data: { cvKey } });
revalidatePath(LIST_PATH);
return { ok: true, id: updated.id };
}
const candidate = await db.crewMember.create({
data: {
name: d.name,
source: d.source,
type,
status,
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
// is an office/admin designation set on the crew record, not here.
type: "NEW",
status: "CANDIDATE",
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,
@ -149,7 +142,6 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
const parsed = parse(formData);
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const d = parsed.data;
const { type, status } = derive(d.source);
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
if (!existing) return { error: "Candidate not found" };
@ -161,9 +153,8 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
data: {
name: d.name,
source: d.source,
// Don't downgrade an onboarded employee back to a candidate via an edit.
type,
status: existing.status === "EMPLOYEE" ? existing.status : status,
// type/status are left untouched — ex-hand / employee designation is owned
// by the office (admin crew record + sign-off), never by a candidate edit.
appliedRankId: d.appliedRankId || null,
currentRankId: d.currentRankId || null,
experienceMonths: d.experienceMonths,

View file

@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import type { CandidateSource } from "@prisma/client";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { addCandidate, updateCandidate } from "./actions";
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
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";
@ -46,7 +46,7 @@ function CandidateFields({
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
{SOURCE_OPTIONS.map((s) => (
{FORM_SOURCE_OPTIONS.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
@ -64,7 +64,7 @@ function CandidateFields({
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
<option value=""></option>
{ranks.map((r) => (
@ -131,7 +131,9 @@ function emptyState(): FieldState {
function stateFrom(c: EditableCandidate): FieldState {
return {
name: c.name,
source: c.source,
// Ex-hand is an admin-only designation; the candidate form only edits origin.
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
appliedRankId: c.appliedRankId ?? "",
currentRankId: c.currentRankId ?? "",
experienceMonths: String(c.experienceMonths),

View file

@ -13,6 +13,11 @@ export const SOURCE_LABEL: Record<CandidateSource, string> = {
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
// Ex-hand is now its own checkbox (not a source) — the Add/Edit form offers only
// the real origins. EX_HAND stays in the enum/label for legacy rows created
// before the split.
export const FORM_SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "WALK_IN", "REFERRAL", "OTHER"];
export const STATUS_LABEL: Record<CrewStatus, string> = {
PROSPECT: "Prospect",
CANDIDATE: "Candidate",

View file

@ -2,7 +2,7 @@
import { useMemo, useState } from "react";
import Link from "next/link";
import type { CandidateSource, CrewStatus } from "@prisma/client";
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
@ -12,6 +12,7 @@ type CandidateRow = {
id: string;
name: string;
source: CandidateSource;
type: CandidateType;
status: CrewStatus;
appliedRankId: string | null;
appliedRank: string | null;
@ -54,13 +55,12 @@ function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
{c.type === "EX_HAND" && (
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
)}
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
</td>
<td className="px-4 py-3">
<span className={c.source === "EX_HAND" ? "text-purple-700 font-medium text-sm" : "text-neutral-600 text-sm"}>
{SOURCE_LABEL[c.source]}
</span>
</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>

View file

@ -33,6 +33,7 @@ export default async function CandidatesPage() {
id: c.id,
name: c.name,
source: c.source,
type: c.type,
status: c.status,
appliedRankId: c.appliedRankId,
appliedRank: c.appliedRank?.name ?? null,

View file

@ -304,10 +304,13 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
source: "internal",
},
});
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
// returning hand. The ex-hand flag lives on type/status — their original
// source (how they were first recruited) is preserved. currentRank (rank
// held) is refreshed to the tour they just signed off from.
await tx.crewMember.update({
where: { id: assignment.crewMemberId },
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
});
await tx.crewAction.create({
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },

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

@ -1,17 +1,21 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { redirect } from "next/navigation";
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;
}>;
}
@ -27,9 +33,17 @@ export default async function HistoryPage({ searchParams }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
// Report-export holders see History; submitters get read+export access when the
// submitter-view-all feature flag is on.
if (
!hasPermission(session.user.role, "export_reports") &&
!submitterCanViewAll(session.user.role)
) {
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) {
@ -56,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);
@ -97,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">
@ -142,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

@ -98,12 +98,25 @@ export default async function PaymentsPage() {
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
</span>
)}
{/* Manager's advance decision (issue #92) — shown until the first payment lands. */}
{po.status === "SENT_FOR_PAYMENT" &&
po.paidAmount == null &&
po.suggestedAdvancePayment != null &&
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) && (
<span className="text-xs text-primary-700">
Advance requested: {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} of{" "}
{formatCurrency(Number(po.totalAmount), po.currency)}
</span>
)}
</div>
<PaymentActions
poId={po.id}
poStatus={po.status}
totalAmount={Number(po.totalAmount)}
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
suggestedAdvancePayment={
po.suggestedAdvancePayment != null ? Number(po.suggestedAdvancePayment) : null
}
/>
</div>
</div>

View file

@ -10,6 +10,9 @@ interface Props {
poStatus: POStatus;
totalAmount?: number;
paidAmount?: number;
// Manager's advance decision (issue #92) — absolute amount. Prefills the FIRST
// payment's amount field; ignored once any payment has been recorded.
suggestedAdvancePayment?: number | null;
}
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
@ -19,15 +22,33 @@ function todayLocal(): string {
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
}
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
export function PaymentActions({
poId,
poStatus,
totalAmount = 0,
paidAmount = 0,
suggestedAdvancePayment = null,
}: Props) {
const router = useRouter();
const remaining = totalAmount - paidAmount;
// Prefill the first payment with the Manager's advance, when it's a genuine
// partial of the (untouched) total. Nothing paid yet ⇒ first payment; a full
// (>= total) advance leaves the field blank so "Confirm Full Payment" is used.
const advancePrefill =
paidAmount === 0 &&
suggestedAdvancePayment != null &&
suggestedAdvancePayment > 0 &&
suggestedAdvancePayment < remaining
? String(suggestedAdvancePayment)
: "";
const [ref, setRef] = useState("");
const [amount, setAmount] = useState<string>("");
const [amount, setAmount] = useState<string>(advancePrefill);
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const remaining = totalAmount - paidAmount;
const today = todayLocal();
async function handleProcessPayment() {
@ -120,6 +141,11 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
className="w-full sm:w-36 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
/>
</div>
{advancePrefill && (
<span className="text-xs text-primary-700">
Manager set an advance of {Number(suggestedAdvancePayment).toFixed(2)} prefilled below; adjust if needed.
</span>
)}
{error && <span className="text-xs text-danger-700">{error}</span>}
<div className="flex gap-2 justify-end">
{isPartialPayment && (

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,8 +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 { 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";
@ -40,10 +44,13 @@ interface Props {
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsCatalogue: CatalogueCategory[];
initialTerms: PoTerm[];
managerNoteAuthor?: string | null;
}
export function EditPoForm({ po, vessels, accounts, vendors, companies, 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) => ({
@ -62,6 +69,9 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
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";
@ -72,6 +82,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
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 ?? "");
@ -88,6 +99,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
setError(result.error);
setSubmitting(null);
} else {
setDirty(false); // saved — don't warn on the redirect
router.push(`/po/${result.id}`);
}
}
@ -108,7 +120,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
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">
@ -172,7 +184,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={setDefaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
groups={accounts}
placeholder="Search accounting code…"
required
@ -229,7 +241,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT_CLS} />
</div>
</section>
@ -238,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
<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}
@ -248,54 +260,14 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
{/* 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>
<input
name={name}
defaultValue={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 && (
@ -324,6 +296,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
</button>
)}
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("save")}
saving={submitting === "save"}
/>
</form>
);
}

View file

@ -3,6 +3,9 @@ import { db } from "@/lib/db";
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 { 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";
@ -29,7 +32,7 @@ export default async function EditPoPage({ params }: Props) {
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`);
const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
@ -38,6 +41,7 @@ export default async function EditPoPage({ params }: Props) {
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
po.status === "EDITS_REQUESTED"
? db.pOAction.findFirst({
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
@ -48,6 +52,10 @@ export default async function EditPoPage({ params }: Props) {
]);
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = {
...po,
@ -73,6 +81,9 @@ export default async function EditPoPage({ params }: Props) {
accounts={accounts}
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
termsCatalogue={termsCatalogue}
initialTerms={initialTerms}
managerNoteAuthor={noteAction?.actor.name ?? null}
/>
</div>

View file

@ -0,0 +1,90 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
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 };
// PO must be approved (a valid document) before it can be emailed to a vendor;
// available through every later state, incl. once payment is recorded (issue #14).
const EMAILABLE = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
const VIEW_ALL_ROLES = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const LINK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
/**
* Build an "email this PO to the vendor" Outlook draft: render the PO to a PDF,
* store it (R2), and return a mailto: addressed to the vendor's primary contact
* with a time-limited download link in the body. The user reviews & sends it.
*/
export async function prepareVendorEmail(poId: string): Promise<Result> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: {
company: { select: { name: true } },
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
},
});
if (!po) return { error: "PO not found" };
const canView = VIEW_ALL_ROLES.includes(session.user.role) || po.submitterId === session.user.id;
if (!canView) return { error: "You cannot access this purchase order." };
if (!EMAILABLE.includes(po.status)) {
return { error: "The PO must be approved before it can be emailed to the vendor." };
}
const to = po.vendor?.contacts?.[0]?.email?.trim();
if (!to) {
return { error: "The vendor has no primary contact email. Add one on the vendor before emailing." };
}
if (!isPdfServiceConfigured()) {
return { error: "PDF emailing is not configured on this environment." };
}
// 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 slug = po.poNumber.replace(/\//g, "-");
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}` };
return { error: "Could not generate the PO PDF." };
}
const company = po.company?.name ?? "Pelagia Marine Services Pvt. Ltd.";
const vendorName = po.vendor?.contacts?.[0]?.name || po.vendor?.name || "Sir/Madam";
const sender = session.user.name ?? "";
const subject = `Purchase Order ${po.poNumber}`;
const body = [
`Dear ${vendorName},`,
"",
`Please find our Purchase Order ${po.poNumber} at the link below:`,
link,
"",
"(The link is valid for 7 days.)",
"",
"Regards,",
sender,
company,
].join("\n");
const mailto = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
return { ok: true, mailto, to };
}

View file

@ -2,6 +2,7 @@ import { auth } from "@/auth";
import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation";
import { PoDetail } from "@/components/po/po-detail";
import { canViewAllPos } from "@/lib/permissions";
import { VendorIdForm } from "./vendor-id-form";
import type { Metadata } from "next";
@ -27,7 +28,7 @@ export default async function PoDetailPage({ params }: Props) {
submitter: true,
vessel: true,
account: true,
vendor: true,
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
lineItems: { orderBy: { sortOrder: "asc" } },
documents: { orderBy: { uploadedAt: "desc" } },
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
@ -39,11 +40,11 @@ export default async function PoDetailPage({ params }: Props) {
if (!po) notFound();
// Submitters can only view their own POs (unless they have view_all_pos)
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
session.user.role
);
if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard");
// Submitters can only view their own POs — unless they hold view_all_pos, or the
// submitter-view-all feature flag grants them read access to every PO.
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
redirect("/dashboard");
}
const canProvideVendorId =
po.status === "VENDOR_ID_PENDING" &&
@ -56,9 +57,11 @@ export default async function PoDetailPage({ params }: Props) {
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
: [];
const vendorEmail = po.vendor?.contacts?.[0]?.email ?? null;
return (
<div className="max-w-6xl space-y-6">
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} />
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} vendorEmail={vendorEmail} />
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
</div>
);

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,9 +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 { 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 }[] };
@ -25,23 +29,28 @@ interface Props {
accounts: AccountGroup[];
vendors: Vendor[];
companies: CompanyOption[];
deliveryOptions: string[];
termsCatalogue: CatalogueCategory[];
defaultTerms: PoTerm[];
initialLineItems?: LineItemInput[];
initialVendorId?: string;
initialVesselId?: string;
initialCompanyId?: string;
}
export function NewPoForm({ vessels, accounts, vendors, companies, 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);
@ -49,6 +58,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
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 ?? "");
@ -75,11 +85,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
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>
@ -136,7 +147,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
<SearchableSelect
name="accountId"
value={defaultAccountId}
onChange={setDefaultAccountId}
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
groups={accounts}
placeholder="Search accounting code…"
required
@ -194,12 +205,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
<textarea
name="placeOfDelivery"
rows={2}
className={INPUT_CLS}
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
/>
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
{deliveryOptions.length === 0 && (
<p className="mt-1.5 text-xs text-neutral-500">
No delivery locations configured yet a Manager can add them under Administration Delivery Locations.
</p>
)}
</div>
</section>
@ -208,7 +219,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
<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}
@ -222,57 +233,21 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
<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>
<input name={name} defaultValue={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 && (
@ -297,6 +272,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
</button>
</div>
<UnsavedChangesGuard
enabled={dirty && !submitting}
onSaveDraft={() => handleSubmit("draft")}
saving={submitting === "draft"}
/>
</form>
);
}

View file

@ -4,6 +4,8 @@ import { hasPermission } from "@/lib/permissions";
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 { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart";
@ -46,7 +48,7 @@ export default async function NewPoPage({ searchParams }: Props) {
}
}
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({
where: { isActive: true, children: { none: {} } },
@ -55,9 +57,12 @@ export default async function NewPoPage({ searchParams }: Props) {
}),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
]);
const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
return (
<div className="max-w-6xl">
@ -72,6 +77,9 @@ export default async function NewPoPage({ searchParams }: Props) {
accounts={accounts}
vendors={vendors}
companies={companies}
deliveryOptions={deliveryOptions}
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,10 +3,12 @@ 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";
import { signatoryLayout } from "@/lib/po-export-layout";
import { canViewAllPos } from "@/lib/permissions";
// ── Company fallback constants (used when no company is linked to a PO) ──────
@ -50,8 +52,14 @@ async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage
interface Props { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, { params }: Props) {
// PdfService renders this page to a real PDF (issue #14). It authenticates with
// a short, server-only token instead of a user session — read-only, PDF only.
const svcToken = request.nextUrl.searchParams.get("svc");
const isService =
!!svcToken && !!process.env.PDF_SERVICE_TOKEN && svcToken === process.env.PDF_SERVICE_TOKEN;
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!session?.user && !isService) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const po = await db.purchaseOrder.findUnique({
@ -66,9 +74,12 @@ export async function GET(request: NextRequest, { params }: Props) {
});
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role);
if (!canViewAll && po.submitterId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (!isService) {
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
// any PO; everyone else only their own. (PdfService bypasses this — read-only, PDF only.)
if (!canViewAllPos(session!.user.role) && po.submitterId !== session!.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
@ -84,6 +95,9 @@ export async function GET(request: NextRequest, { params }: Props) {
}
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// pdf=1 → render a clean page for PdfService: no on-screen print button and no
// window.print() auto-trigger (Chromium's page.pdf() captures it directly).
const cleanPdf = request.nextUrl.searchParams.get("pdf") === "1";
// ── Company data (from linked company, or fallback to constants) ──────────
const co = po.company;
@ -169,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,
@ -735,11 +755,11 @@ export async function GET(request: NextRequest, { params }: Props) {
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<div class="no-print" style="margin-bottom:8px">
${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
🖨 Print / Save as PDF
</button>
</div>
</div>`}
<!-- ── Header ─────────────────────────────────────────────────── -->
<div class="header-band">
@ -888,7 +908,7 @@ ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
<!-- ── Brand bar ─────────────────────────────────────────────── -->
<div class="brand-bar"></div>
<script>window.onload = function() { window.print(); };</script>
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`}
</body>
</html>`;

View file

@ -1,6 +1,6 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
import type { POStatus } from "@prisma/client";
@ -16,7 +16,10 @@ export async function GET(request: NextRequest) {
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!hasPermission(session.user.role, "export_reports")) {
if (
!hasPermission(session.user.role, "export_reports") &&
!submitterCanViewAll(session.user.role)
) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}

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

@ -1,8 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
@ -33,6 +34,9 @@ import {
UserCog,
Gauge,
BadgeCheck,
Truck,
ScrollText,
ChevronRight,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -43,38 +47,59 @@ interface NavItem {
roles?: Role[];
}
// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters
// (TECHNICAL / MANNING) get read+export access to it too.
const HISTORY_ROLES: Role[] = [
"MANAGER", "SUPERUSER", "AUDITOR", "ADMIN",
...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as 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: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
{ 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
@ -95,8 +120,10 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
// ── Administration section ────────────────────────────────────────────────────
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
{ href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED
? [
@ -117,14 +144,60 @@ 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;
groups: NavGroup[];
}
function isItemActive(href: string, pathname: string) {
return pathname === href || pathname.startsWith(href + "/");
}
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", 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) => 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.
const [openSection, setOpenSection] = useState<string | null>(activeSectionId);
// On navigation, open the section holding the new active route (which, being a
// single-open accordion, collapses any other open heading).
useEffect(() => {
if (activeSectionId) setOpenSection(activeSectionId);
}, [activeSectionId]);
const toggleSection = (id: string) =>
setOpenSection((current) => (current === id ? null : id));
return (
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
@ -140,59 +213,70 @@ export function Sidebar({ userRole }: { userRole: Role }) {
<NavLink key={item.href} item={item} pathname={pathname} />
))}
{visiblePurchasing.length > 0 && (
<>
<SectionHeader label="Purchasing" />
{visiblePurchasing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
{visibleCrewing.length > 0 && (
<>
<SectionHeader label="Crewing" />
{visibleCrewing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Vendors under Administration for MANAGER / ACCOUNTS */}
{!isAdmin && visibleMgrAdmin.length > 0 && (
<>
<SectionHeader label="Administration" />
{visibleMgrAdmin.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Full Administration section for ADMIN */}
{isAdmin && (
<>
<SectionHeader label="Administration" />
{[...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS].map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{sections.map((section) => {
const isOpen = openSection === section.id;
const regionId = `nav-section-${section.id}`;
return (
<div key={section.id}>
<SectionHeader
label={section.label}
isOpen={isOpen}
regionId={regionId}
onToggle={() => toggleSection(section.id)}
/>
{isOpen && (
<div id={regionId} className="space-y-0.5">
{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>
)}
</div>
);
})}
</nav>
</aside>
);
}
function SectionHeader({ label }: { label: string }) {
function SectionHeader({
label,
isOpen,
regionId,
onToggle,
}: {
label: string;
isOpen: boolean;
regionId: string;
onToggle: () => void;
}) {
return (
<div className="pt-4 pb-1 px-3">
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">{label}</p>
</div>
<button
type="button"
onClick={onToggle}
aria-expanded={isOpen}
aria-controls={regionId}
className="flex w-full items-center justify-between pt-4 pb-1 px-3 text-xs font-semibold text-neutral-400 uppercase tracking-wider hover:text-neutral-600"
>
<span>{label}</span>
<ChevronRight
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", isOpen && "rotate-90")}
/>
</button>
);
}
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const isActive = isItemActive(item.href, pathname);
const Icon = item.icon;
return (
<Link

View file

@ -0,0 +1,36 @@
"use client";
/**
* Place-of-Delivery dropdown (issue #19) a native <select name="placeOfDelivery">
* sourced from the admin-managed delivery locations. Plain HTML so it works with
* the forms' native FormData submission (no client state needed).
*
* `options` are the formatted "Company — address" strings (also the stored value).
* `current` is the PO's existing place-of-delivery; if it isn't one of the active
* options (legacy / imported / a since-removed location) it is preserved as a
* leading "(current)" option so an edit never silently drops it.
*/
export function DeliveryLocationField({
options,
current,
className,
}: {
options: string[];
current?: string | null;
className?: string;
}) {
const cur = (current ?? "").trim();
const currentMissing = cur.length > 0 && !options.includes(cur);
return (
<select name="placeOfDelivery" defaultValue={cur} className={className}>
<option value=""> Select a delivery location </option>
{currentMissing && <option value={cur}>{cur} (current)</option>}
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
/**
* "Email to vendor" (issue #14): generates the PO PDF, stores it, and opens an
* Outlook (default mail client) draft addressed to the vendor's primary contact
* with a download link in the body. The user reviews and sends it themselves.
*/
export function EmailVendorButton({ poId }: { poId: string }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleClick() {
setPending(true);
setError("");
const result = await prepareVendorEmail(poId);
setPending(false);
if ("error" in result) {
setError(result.error);
} else {
// Opens the default mail client (Outlook) with a pre-filled draft.
window.location.href = result.mailto;
}
}
return (
<div className="inline-flex flex-col items-start gap-1">
<button
onClick={handleClick}
disabled={pending}
className="rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
>
{pending ? "Preparing…" : "Email to vendor"}
</button>
{error && <span className="text-xs text-danger-700 max-w-xs">{error}</span>}
</div>
);
}

View file

@ -4,10 +4,12 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { EmailVendorButton } from "@/components/po/email-vendor-button";
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";
@ -25,6 +27,7 @@ type PoWithRelations = {
paymentRef: string | null;
paymentDate?: Date | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null;
piQuotationDate?: Date | null;
requisitionNo?: string | null;
@ -36,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;
@ -79,6 +83,8 @@ interface Props {
currentUserId: string;
currentRole: Role;
readOnly?: boolean;
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
vendorEmail?: string | null;
}
const ACTION_LABELS: Record<string, string> = {
@ -101,7 +107,7 @@ const ACTION_LABELS: Record<string, string> = {
SUPERSEDED: "Superseded",
};
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name,
description: li.description ?? undefined,
@ -227,6 +233,11 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Export XLSX
</a>
</>)}
{/* Email to vendor — approved (not cancelled) + vendor has a contact email (issue #14) */}
{!readOnly && vendorEmail &&
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (
<EmailVendorButton poId={po.id} />
)}
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
{po.status !== "CANCELLED" &&
["MANAGER", "SUPERUSER"].includes(currentRole) &&
@ -290,6 +301,21 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
</div>
)}
{/* Manager's advance-payment decision (issue #92) a partial advance set
at approval. Shown to Accounts/Manager from approval through payment. */}
{po.suggestedAdvancePayment != null &&
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) &&
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && (
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3">
<p className="text-sm font-medium text-primary-700 mb-0.5">Advance payment requested</p>
<p className="text-sm text-primary-700">
Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "}
{formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual
part-payment flow.
</p>
</div>
)}
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
{resubmitSnapshot &&
po.status === "MGR_REVIEW" &&
@ -435,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

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

View file

@ -0,0 +1,9 @@
/**
* Delivery locations (issue #19) admin-managed destinations used to populate
* the PO "Place of Delivery" dropdown. A location is a Company + a free-text
* address; the PO stores the resolved single string below as a point-in-time
* snapshot in `PurchaseOrder.placeOfDelivery`.
*/
export function formatDeliveryLocation(companyName: string, address: string): string {
return `${companyName}${address}`.trim();
}

View file

@ -5,6 +5,12 @@
* NEXT_PUBLIC_INVENTORY_ENABLED=false hides inventory tracking (site qty/consumption)
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
*
* NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true lets submitters (TECHNICAL / MANNING)
* read every PO (not just their own), open the History page, and use the export buttons.
* Opt-in (off unless explicitly "true") because it widens read access. Submitters stay
* read-only it grants no approval, payment, or edit rights. See lib/permissions.ts
* (canViewAllPos / submitterCanViewAll).
*
* NEXT_PUBLIC_CREWING_ENABLED=true exposes the Crewing module (crew/ranks/requisitions
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
@ -14,5 +20,8 @@
export const INVENTORY_ENABLED =
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
export const SUBMITTER_VIEW_ALL_ENABLED =
process.env.NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED === "true";
export const CREWING_ENABLED =
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";

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

44
App/lib/pdf-service.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Client for PdfService (issue #14) renders a PO's export page to a real PDF.
*
* The app's own /api/po/:id/export?format=pdf produces a print-styled HTML page;
* PdfService (headless Chromium) navigates to it and returns PDF bytes. We pass a
* short-lived service token so the export route serves the page without a user
* session. Configured via:
* PDF_SERVICE_URL e.g. http://localhost:3005
* PDF_SERVICE_TOKEN shared secret echoed by the export route
* APP_INTERNAL_URL base URL PdfService can reach the app at (falls back to NEXTAUTH_URL)
*/
export class PdfServiceError extends Error {}
export function isPdfServiceConfigured(): boolean {
return !!process.env.PDF_SERVICE_URL && !!process.env.PDF_SERVICE_TOKEN;
}
/** Render a PO to a PDF buffer via PdfService. Throws PdfServiceError on failure. */
export async function renderPoPdf(poId: string): Promise<Buffer> {
const serviceUrl = process.env.PDF_SERVICE_URL;
const token = process.env.PDF_SERVICE_TOKEN;
if (!serviceUrl || !token) {
throw new PdfServiceError("PDF service is not configured.");
}
const appBase = (process.env.APP_INTERNAL_URL ?? process.env.NEXTAUTH_URL ?? "http://localhost:3000").replace(/\/$/, "");
const exportUrl = `${appBase}/api/po/${poId}/export?format=pdf&pdf=1&svc=${encodeURIComponent(token)}`;
let res: Response;
try {
res = await fetch(`${serviceUrl.replace(/\/$/, "")}/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-pdf-token": token },
body: JSON.stringify({ url: exportUrl }),
});
} catch (e) {
throw new PdfServiceError(`PDF service unreachable: ${String(e)}`);
}
if (!res.ok) {
throw new PdfServiceError(`PDF service returned ${res.status}`);
}
return Buffer.from(await res.arrayBuffer());
}

View file

@ -1,4 +1,5 @@
import type { Role } from "@prisma/client";
import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
export type Permission =
| "create_po"
@ -21,6 +22,8 @@ export type Permission =
| "manage_vessels_accounts"
| "manage_products"
| "manage_sites"
| "manage_delivery_locations"
| "manage_terms"
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
| "raise_requisition"
| "request_relief_cover"
@ -80,6 +83,8 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_vessels_accounts",
"manage_products",
"manage_sites",
"manage_delivery_locations",
"manage_terms",
"confirm_receipt",
"process_payment"
],
@ -99,6 +104,8 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"view_analytics",
"export_reports",
"create_vendor",
"manage_delivery_locations",
"manage_terms",
],
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
ADMIN: [
@ -112,6 +119,8 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_vessels_accounts",
"manage_products",
"manage_sites",
"manage_delivery_locations",
"manage_terms",
],
SITE_STAFF: [],
};
@ -237,3 +246,31 @@ export function requirePermission(role: Role, permission: Permission): void {
export function getPermissions(role: Role): Permission[] {
return ROLE_PERMISSIONS[role] ?? [];
}
// ── Submitter roles & feature-flagged view-all ────────────────────────────────
// Submitters raise and track their own POs. The two "submitter" roles below hold
// `view_own_pos` but not `view_all_pos`.
export const SUBMITTER_ROLES: Role[] = ["TECHNICAL", "MANNING"];
export function isSubmitterRole(role: Role): boolean {
return SUBMITTER_ROLES.includes(role);
}
/**
* Feature-flagged: when NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true, submitters may
* read & export every PO (not just their own) and reach the History page. This is a
* read-only widening it does not grant approval, payment, or edit rights.
*/
export function submitterCanViewAll(role: Role): boolean {
return SUBMITTER_VIEW_ALL_ENABLED && isSubmitterRole(role);
}
/**
* Whether a role may view/export any PO, not just the ones they submitted.
* True for `view_all_pos` holders (ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN) and,
* when the feature flag is on, for submitters too.
*/
export function canViewAllPos(role: Role): boolean {
return hasPermission(role, "view_all_pos") || submitterCanViewAll(role);
}

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

@ -46,7 +46,7 @@ export async function generateDownloadUrl(
export function buildStorageKey(
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
// phases — see Crewing-Implementation-Spec §4.5.
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract" | "po-pdf",
ownerId: string,
fileName: string
): string {
@ -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).
*/

28
App/lib/terms-data.ts Normal file
View file

@ -0,0 +1,28 @@
import { db } from "@/lib/db";
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
/** 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: [{ sortOrder: "asc" }, { name: "asc" }],
include: {
clauses: {
where: { isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
select: { text: true },
},
},
});
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 }));
}

50
App/lib/terms.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Terms & Conditions catalogue (issue #11) admin-managed categories + clauses
* that feed the PO's dynamic T&C editor. Categories are user-defined data.
*/
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
// One chosen T&C row on a PO (stored as a JSON snapshot in PurchaseOrder.terms).
export type PoTerm = { category: string; text: string };
// 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);
}
/** 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

@ -53,6 +53,13 @@ export const createPoSchema = z.object({
export const approvePoSchema = z.object({
note: z.string().optional(),
// Absolute advance amount the Manager wants paid first (issue #92). The UI
// slider works in whole percent of totalAmount; the resolved amount is what we
// persist. Validated against the PO total in the action. Omitted ⇒ full payment.
suggestedAdvancePayment: z.coerce
.number()
.nonnegative("Advance payment cannot be negative")
.optional(),
});
export const rejectPoSchema = z.object({

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,2 @@
-- AlterTable
ALTER TABLE "PurchaseOrder" ADD COLUMN "suggestedAdvancePayment" DECIMAL(12,2);

View file

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "DeliveryLocation" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"address" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DeliveryLocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DeliveryLocation_companyId_idx" ON "DeliveryLocation"("companyId");
-- AddForeignKey
ALTER TABLE "DeliveryLocation" ADD CONSTRAINT "DeliveryLocation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "TermsCategory" AS ENUM ('DELIVERY', 'DISPATCH', 'INSPECTION', 'TRANSIT_INSURANCE', 'PAYMENT_TERMS');
-- CreateTable
CREATE TABLE "TermsCondition" (
"id" TEXT NOT NULL,
"category" "TermsCategory" NOT NULL,
"text" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TermsCondition_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TermsCondition_category_idx" ON "TermsCondition"("category");
-- Seed the standard clauses (the prior TC_DEFAULTS) so the catalogue is usable
-- immediately and existing default wording stays selectable.
INSERT INTO "TermsCondition" ("id", "category", "text", "updatedAt") VALUES
('tcseed_delivery', 'DELIVERY', 'Within 4 to 5 days', CURRENT_TIMESTAMP),
('tcseed_dispatch', 'DISPATCH', 'To be transported to site address as above. Freight Supplier''s A/C', CURRENT_TIMESTAMP),
('tcseed_inspect', 'INSPECTION', 'NA', CURRENT_TIMESTAMP),
('tcseed_transit', 'TRANSIT_INSURANCE', 'NA', CURRENT_TIMESTAMP),
('tcseed_payment', 'PAYMENT_TERMS', 'Within 30 days from delivery.', CURRENT_TIMESTAMP);

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

@ -389,7 +389,57 @@ model Company {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrders PurchaseOrder[]
purchaseOrders PurchaseOrder[]
deliveryLocations DeliveryLocation[]
}
// Admin-managed delivery destinations (issue #19). Each is a Company + a
// free-text address; the PO "Place of Delivery" field becomes a dropdown sourced
// from these. The PO stores the resolved text snapshot in
// PurchaseOrder.placeOfDelivery (point-in-time document), so deleting/editing a
// location never rewrites historical POs. Managed by manage_delivery_locations.
model DeliveryLocation {
id String @id @default(cuid())
companyId String
company Company @relation(fields: [companyId], references: [id])
address String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
}
// 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())
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([categoryId])
}
model Account {
@ -512,6 +562,17 @@ model PurchaseOrder {
paymentRef String?
paymentDate DateTime?
paidAmount Decimal? @db.Decimal(12, 2)
// Advance the approving Manager wants paid first (absolute amount, not %).
// The approval slider (0100% of totalAmount) is convenience only — the
// resolved amount is stored here. Null on legacy/pre-feature POs ⇒ no explicit
// advance, so Accounts defaults to the full remaining balance. Set once at
// approval and not edited afterwards (issue #92).
//
// NOTE (issue #91): this IS the "exact sum due for payment" for an ADVANCE/PART
// request. When the structured payment-request lane (payment-term enum +
// separate approval) is built, reuse this column for the requested amount
// rather than adding a parallel "exact sum" field.
suggestedAdvancePayment Decimal? @db.Decimal(12, 2)
piQuotationNo String?
piQuotationDate DateTime?
requisitionNo String?
@ -523,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"));
@ -119,6 +125,80 @@ describe("M-02 — approve PO", () => {
});
});
// ── #92: Advance payment decided at approval ─────────────────────────────────
describe("issue #92 — advance payment on approval", () => {
it("persists the manager's advance amount and records it on the audit row", async () => {
const poId = await createSubmittedPo(`${PREFIX}Advance`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
const half = Math.round(Number(before.totalAmount) / 2);
const result = await approvePo({ poId, suggestedAdvancePayment: half });
expect(result).toEqual({ ok: true });
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(po.status).toBe("MGR_APPROVED");
expect(Number(po.suggestedAdvancePayment)).toBe(half);
const action = await db.pOAction.findFirst({ where: { poId, actionType: "APPROVED" } });
expect((action?.metadata as { suggestedAdvancePayment?: number } | null)?.suggestedAdvancePayment).toBe(half);
});
it("defaults to null (full payment) when no advance is provided", async () => {
const poId = await createSubmittedPo(`${PREFIX}AdvanceNone`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
await approvePo({ poId });
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(po.suggestedAdvancePayment).toBeNull();
});
it("clamps an advance above the PO total down to the total", async () => {
const poId = await createSubmittedPo(`${PREFIX}AdvanceClamp`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
const before = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
const total = Number(before.totalAmount);
await approvePo({ poId, suggestedAdvancePayment: total + 5000 });
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
expect(Number(po.suggestedAdvancePayment)).toBe(total);
});
});
// ── 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

@ -33,6 +33,21 @@ const SS_EMAIL = "sitestaff@itcand.local";
const as = (userId: string, role: Role) =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
// Ex-hand is an office/admin designation (set on the admin crew record, not the
// candidate form) — seed such rows directly for the recognition tests.
const seedExHand = (data: { name: string; email?: string; experienceMonths?: number }) =>
db.crewMember.create({
data: {
name: data.name,
type: "EX_HAND",
status: "EX_HAND",
source: "CAREERS",
email: data.email ?? null,
experienceMonths: data.experienceMonths ?? 0,
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: managerId } },
},
});
beforeAll(async () => {
managerId = (await getSeedUser("manager@pelagia.local")).id;
const ss = await db.user.upsert({
@ -71,12 +86,14 @@ describe("addCandidate", () => {
expect(c.actions[0].actorId).toBe(managerId);
});
it("an EX_HAND source yields type EX_HAND and status EX_HAND", async () => {
it("candidate intake always creates a NEW candidate — ex-hand is admin-only", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
// Even if an ex-hand hint is smuggled into the form data, intake stays
// NEW/CANDIDATE; ex-hand is set only on the admin crew record.
await addCandidate(fd({ name: "Returning Ravi", source: "CAREERS", isExHand: "true" }));
const c = await db.crewMember.findFirstOrThrow();
expect(c.type).toBe("EX_HAND");
expect(c.status).toBe("EX_HAND");
expect(c.type).toBe("NEW");
expect(c.status).toBe("CANDIDATE");
});
it("requires a name", async () => {
@ -98,8 +115,7 @@ describe("addCandidate", () => {
describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by email and reuses the same row (AC1)", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Ravi Old", source: "EX_HAND", email: "ravi@ex.com", experienceMonths: "120" }));
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
const exhand = await seedExHand({ name: "Ravi Old", email: "ravi@ex.com", experienceMonths: 120 });
// Re-applies as a fresh careers candidate with the same email → recognized.
const res = await addCandidate(fd({ name: "Ravi Returning", source: "CAREERS", email: "ravi@ex.com", appliedRankId: rankId }));
@ -115,16 +131,15 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("recognizes a returning hand by exact name when no email is given (AC1)", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Returning Ravi", source: "EX_HAND" }));
const exhand = await seedExHand({ name: "Returning Ravi" });
const res = await addCandidate(fd({ name: "returning ravi", source: "REFERRAL" })); // case-insensitive
const exhand = await db.crewMember.findFirstOrThrow({ where: { status: "EX_HAND" } });
expect("ok" in res && res.id).toBe(exhand.id);
expect(await db.crewMember.count()).toBe(1);
});
it("does not match a different person → creates a new candidate", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "Ex One", source: "EX_HAND", email: "one@ex.com" }));
await seedExHand({ name: "Ex One", email: "one@ex.com" });
await addCandidate(fd({ name: "Brand New", source: "CAREERS", email: "new@ex.com" }));
expect(await db.crewMember.count()).toBe(2);
});
@ -132,7 +147,7 @@ describe("ex-hand recognition + ordering (B3)", () => {
it("lists ex-hands above new candidates by default (AC2)", async () => {
as(managerId, "MANAGER");
await addCandidate(fd({ name: "New First", source: "CAREERS" }));
await addCandidate(fd({ name: "Ex Second", source: "EX_HAND" }));
await seedExHand({ name: "Ex Second" });
const el = (await CandidatesPage()) as unknown as { props: { candidates: Array<{ name: string; status: string }> } };
expect(el.props.candidates[0].status).toBe("EX_HAND");
expect(el.props.candidates[0].name).toBe("Ex Second");

View file

@ -0,0 +1,89 @@
/**
* Integration tests for the Delivery Locations admin CRUD (issue #19).
* Covers create/update/toggle/delete + the manage_delivery_locations guard.
*/
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { auth } from "@/auth";
import { db } from "@/lib/db";
import {
createDeliveryLocation,
updateDeliveryLocation,
toggleDeliveryLocationActive,
deleteDeliveryLocation,
} from "@/app/(portal)/admin/delivery-locations/actions";
import { makeSession, fd } from "./helpers";
const mockedAuth = vi.mocked(auth);
const PREFIX = "INTTEST_DELLOC_";
let companyId: string;
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
beforeAll(async () => {
const company = await db.company.create({ data: { name: `${PREFIX}Co`, code: "ZZDELLOC" } });
companyId = company.id;
});
afterAll(async () => {
await db.deliveryLocation.deleteMany({ where: { companyId } });
await db.company.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
describe("createDeliveryLocation", () => {
it("persists a location tied to its company", async () => {
asManager();
const result = await createDeliveryLocation(fd({ companyId, address: "Dock 4, Mumbai" }));
expect(result).toEqual({ ok: true });
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Dock 4, Mumbai" } });
expect(loc.isActive).toBe(true);
expect(loc.companyId).toBe(companyId);
});
it("requires both a company and an address", async () => {
asManager();
expect("error" in (await createDeliveryLocation(fd({ companyId, address: " " })))).toBe(true);
expect("error" in (await createDeliveryLocation(fd({ companyId: "", address: "x" })))).toBe(true);
});
it("rejects a company that no longer exists", async () => {
asManager();
const result = await createDeliveryLocation(fd({ companyId: "nonexistent", address: "x" }));
expect("error" in result).toBe(true);
});
it("refuses callers without manage_delivery_locations", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
});
});
describe("updateDeliveryLocation / toggle / delete", () => {
it("edits, toggles active, then deletes a location", async () => {
asManager();
await createDeliveryLocation(fd({ companyId, address: "Old Address" }));
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Old Address" } });
expect(await updateDeliveryLocation(loc.id, fd({ companyId, address: "New Address" }))).toEqual({ ok: true });
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).address).toBe("New Address");
expect(await toggleDeliveryLocationActive(loc.id)).toEqual({ ok: true });
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).isActive).toBe(false);
expect(await deleteDeliveryLocation(loc.id)).toEqual({ ok: true });
expect(await db.deliveryLocation.findUnique({ where: { id: loc.id } })).toBeNull();
});
it("guards update/toggle/delete behind the permission", async () => {
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
expect(await updateDeliveryLocation("x", fd({ companyId, address: "y" }))).toEqual({ error: "Forbidden" });
expect(await toggleDeliveryLocationActive("x")).toEqual({ error: "Forbidden" });
expect(await deleteDeliveryLocation("x")).toEqual({ error: "Forbidden" });
});
});

View file

@ -0,0 +1,164 @@
/**
* Integration tests for prepareVendorEmail (issue #14) the "Email to vendor"
* action that renders the PO PDF, stores it, and returns an Outlook mailto draft
* with a download link. PdfService + storage are mocked (no Chromium / R2).
*/
import { vi, describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("@/lib/pdf-service", () => ({
renderPoPdf: vi.fn(async () => Buffer.from("%PDF-1.4 fake")),
isPdfServiceConfigured: vi.fn(() => true),
PdfServiceError: class PdfServiceError extends Error {},
}));
vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return {
...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, renderPoPdf } from "@/lib/pdf-service";
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
const PREFIX = "INTTEST_EMAILVENDOR_";
let techId: string;
let vesselId: string;
let accountId: string;
let vendorWithEmailId: string;
let vendorNoEmailId: string;
const as = (userId: string, role: "TECHNICAL" | "MANAGER") =>
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
async function makePo(status: string, vendorId: string): Promise<string> {
const po = await db.purchaseOrder.create({
data: {
poNumber: `${PREFIX}${status}-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
title: `${PREFIX}PO`,
status: status as never,
totalAmount: 1000,
currency: "INR",
vesselId,
accountId,
submitterId: techId,
vendorId,
},
});
return po.id;
}
beforeAll(async () => {
const [tech, vessel, account] = await Promise.all([
getSeedUser("tech@pelagia.local"),
getSeedVessel("MV Poseidon"),
getSeedAccount("700201"),
]);
techId = tech.id;
vesselId = vessel.id;
accountId = account.id;
const withEmail = await db.vendor.create({
data: { name: `${PREFIX}WithEmail`, contacts: { create: { name: "Vinod", email: "vinod@vendor.test", isPrimary: true } } },
});
vendorWithEmailId = withEmail.id;
const noEmail = await db.vendor.create({ data: { name: `${PREFIX}NoEmail` } });
vendorNoEmailId = noEmail.id;
});
afterEach(() => {
vi.mocked(isPdfServiceConfigured).mockReturnValue(true);
vi.clearAllMocks();
});
afterAll(async () => {
await db.purchaseOrder.deleteMany({ where: { title: { startsWith: PREFIX } } });
await db.vendorContact.deleteMany({ where: { vendor: { name: { startsWith: PREFIX } } } });
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
});
describe("prepareVendorEmail", () => {
it("builds a mailto draft to the vendor's primary contact with the PDF link", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("ok" in result && result.ok).toBe(true);
if (!("ok" in result)) throw new Error(result.error);
expect(result.to).toBe("vinod@vendor.test");
expect(result.mailto.startsWith("mailto:vinod%40vendor.test?")).toBe(true);
// Subject is the PO number; body carries the (mocked) download link.
expect(decodeURIComponent(result.mailto)).toContain("Purchase Order");
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);
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
});
it("refuses a PO that is not yet approved (DRAFT)", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("DRAFT", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("errors when the vendor has no primary contact email", async () => {
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorNoEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("errors when the PDF service is not configured", async () => {
vi.mocked(isPdfServiceConfigured).mockReturnValue(false);
as(techId, "TECHNICAL");
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
const result = await prepareVendorEmail(poId);
expect("error" in result).toBe(true);
});
it("rejects an unauthenticated caller", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
expect(await prepareVendorEmail(poId)).toEqual({ error: "Unauthorized" });
});
});

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