Compare commits
11 commits
master
...
feat/crewi
| Author | SHA1 | Date | |
|---|---|---|---|
| af156a4d46 | |||
| cfb2533e33 | |||
| bc4fcc6e35 | |||
| 1ccd777cb6 | |||
| bec8fba6f0 | |||
| b0caa9a2dd | |||
| 23ec2b91ea | |||
| a8acf26eca | |||
| 79eba5505c | |||
| 136cb798f5 | |||
| feac86e3a3 |
188 changed files with 613 additions and 11558 deletions
|
|
@ -40,70 +40,6 @@ jobs:
|
||||||
pm2 restart ppms
|
pm2 restart ppms
|
||||||
echo "=== Deployed $TAG ==="
|
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
|
- name: Verify portal responds
|
||||||
run: |
|
run: |
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -32,10 +32,6 @@ automation/watcher.config.json
|
||||||
automation/logs/
|
automation/logs/
|
||||||
automation/.watcher.lock
|
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
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
|
|
@ -49,36 +49,12 @@ EMAIL_FROM_NAME="Pelagia Portal"
|
||||||
# Start the service with: cd GstService && npm run dev
|
# Start the service with: cd GstService && npm run dev
|
||||||
GST_SERVICE_URL=http://localhost:3003
|
GST_SERVICE_URL=http://localhost:3003
|
||||||
|
|
||||||
# ── EPFO / UAN lookup microservice (crewing) ──────────────────
|
|
||||||
# Run the EpfoService/ microservice alongside the app (default localhost:3004).
|
|
||||||
# Start with: cd EpfoService && npm run dev
|
|
||||||
# Runs in STUB mode unless EPFO_LIVE=true (the live portal selectors/OTP must be
|
|
||||||
# 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) ─────────────
|
# ── Forgejo issue reporting (Report Issue button) ─────────────
|
||||||
# Token needs write:issue scope on the repo below.
|
# Token needs write:issue scope on the repo below.
|
||||||
FORGEJO_URL=https://git.pelagiamarine.com
|
FORGEJO_URL=https://git.pelagiamarine.com
|
||||||
FORGEJO_REPO=shad0w/pelagia-portal
|
FORGEJO_REPO=shad0w/pelagia-portal
|
||||||
FORGEJO_TOKEN=
|
FORGEJO_TOKEN=
|
||||||
|
|
||||||
# ── 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 ─────────────────────────────────────
|
# ── Non-production banner ─────────────────────────────────────
|
||||||
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
|
# When set, a fixed "internal dev / staging" banner is shown (EnvBanner).
|
||||||
# Leave UNSET in production. Staging sets this automatically.
|
# Leave UNSET in production. Staging sets this automatically.
|
||||||
|
|
|
||||||
1
App/.gitignore
vendored
1
App/.gitignore
vendored
|
|
@ -13,7 +13,6 @@
|
||||||
# Testing
|
# Testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report
|
/playwright-report
|
||||||
/playwright-report-staging
|
|
||||||
/test-results
|
/test-results
|
||||||
/blob-report
|
/blob-report
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,35 +98,6 @@ 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).
|
`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).
|
|
||||||
|
|
||||||
### Project Codes (issue #124)
|
|
||||||
|
|
||||||
`ProjectCode` (a unique `code` string + `isActive`) is an admin-managed list that backs the PO **Project Code** dropdown — it replaced an earlier hardcoded `PROJECT_CODES` array. Managed at `/admin/project-codes`, gated by the **`manage_project_codes`** permission (Manager + SuperUser + Admin), mirroring Delivery Locations (table + Add/Edit dialogs + activate/deactivate + delete). The migration seeds the five originally-hardcoded codes so the dropdown stays populated.
|
|
||||||
|
|
||||||
The three PO forms render a shared `<ProjectCodeField options={…}>` — a native `<select name="projectCode">` populated from the **active** codes plus an empty "— none —" option (the field stays **optional**). **`PurchaseOrder.projectCode` stays a nullable 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 code 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`)
|
### PO Numbering (`lib/po-number.ts`)
|
||||||
|
|
||||||
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
||||||
|
|
@ -135,47 +106,18 @@ 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).
|
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 0–100% 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
|
### 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).
|
`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 (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.
|
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
|
### 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.
|
`/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 **Apr–Mar** 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 (W1–W5). 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).
|
|
||||||
|
|
||||||
**Drill to POs (#126):** each detail page (`/reports/cost-centres/[id]`, `/reports/accounting-codes/[id]`) has a **"View POs"** link to **PO History** pre-filtered to that cost centre / accounting code over the period in view — `periodRange(gran, fy, month, fys)` (`lib/reports.ts`) maps the on-screen period onto History's `approvedFrom`/`approvedTo` (weekly → the focused month, monthly → the FY, yearly → the full FY span; spend is dated by `approvedAt`). PO History (`/history`) gained an **`accountId`** filter that accepts **any** account-tree node and matches a PO whose **PO-level account or any line-item account** is a leaf under it (`accountLeafIds()` expands the node) — the same attribution basis the reports use. The History page **and** its CSV/PDF export route (`/api/reports/export`) build their `where` from one shared `lib/history-filter.ts` `buildPoHistoryWhere()` so they stay in lockstep.
|
|
||||||
|
|
||||||
Sites are **not** cost centres (only vessels are).
|
|
||||||
|
|
||||||
### Crewing (feature-flagged)
|
### 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:
|
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:
|
||||||
|
|
@ -262,7 +204,6 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
|
||||||
- **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site.
|
- **SITE_STAFF login on onboard/placement** — `lib/crew-login.ts` `maybeCreateSiteStaffLogin` creates a passwordless `SITE_STAFF` `User` (sharing the `CRW-` employee no.) when a `grantsLogin` rank is onboarded (`onboardCandidate`) or placed (`placeCrew`) and the crew member has an email; the login's `siteId` is set to the assignment's site.
|
||||||
- **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above.
|
- **Own-site scoping (§8.7)** — `User.siteId` added; the Crew directory filters a `SITE_STAFF` user with a home site to crew whose active assignment is at that site (graceful: no `siteId` → unscoped). The link is set at login creation above.
|
||||||
- **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`.
|
- **PPE / next-of-kin verify gates** — `PpeIssue` / `NextOfKin` gained `verificationStatus` + `verifiedById`; `verifyPpe` / `verifyNextOfKin` (`verify_site_records` — MPO) and queue sections in `/crewing/verification`.
|
||||||
- **EPFO / UAN assisted verification (A3):** `EpfoService/` is a standalone Express + Playwright proxy (the **GstService pattern**) that does an OTP-handshake UAN lookup against the EPFO member portal — `POST /otp` then `POST /verify`. The app proxies via `/api/epfo/otp` + `/api/epfo` (gated by `verify_bank_epf`), and the **EPFO check** affordance in the verification queue records the returned member name onto `EpfDetail.epfoMemberName` (`recordEpfoCheck`). The live portal navigation is **stubbed behind `EPFO_LIVE`** (deterministic in dev/CI: OTP `000000` → matched) until the real selectors/OTP are validated. **Aadhaar is intentionally not handled** (UIDAI-restricted — stays assisted-manual; only `aadhaarLast4` stored, masked).
|
|
||||||
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
|
- Still deferred (not self-contained): the public careers intake API (A2, external) and the Pay-status pay rows (Phase 6 payroll).
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
@ -288,12 +229,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
|
||||||
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
||||||
|
|
||||||
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
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_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_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.
|
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ 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 STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
||||||
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
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());
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
type Opt = { id: string; name: string };
|
type Opt = { id: string; name: string };
|
||||||
|
|
@ -131,10 +132,7 @@ 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 />
|
<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.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.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.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">
|
<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>
|
||||||
<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.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>
|
<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 })} />
|
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
"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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
"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 “Place of Delivery” 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { distanceKm, formatDistance } from "@/lib/geo";
|
import { distanceKm, formatDistance } from "@/lib/geo";
|
||||||
import { ToggleProductButton, EditProductButton } from "../product-form";
|
import { ToggleProductButton, EditProductButton } from "../product-form";
|
||||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||||
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
|
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
|
||||||
import { SiteSelect } from "@/components/inventory/site-select";
|
import { SiteSelect } from "@/components/inventory/site-select";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
|
||||||
export function ProductsTable({
|
export function ProductsTable({
|
||||||
products,
|
products,
|
||||||
canManage,
|
canManage,
|
||||||
detailBase = "/catalogue/items",
|
detailBase = "/inventory/items",
|
||||||
}: {
|
}: {
|
||||||
products: ProductRow[];
|
products: ProductRow[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
"use server";
|
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { hasPermission } from "@/lib/permissions";
|
|
||||||
import { revalidatePath } from "next/cache";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
code: z.string().trim().min(1, "Project code 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_project_codes")) {
|
|
||||||
return { error: "Forbidden" };
|
|
||||||
}
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProjectCode(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 };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.projectCode.create({ data: { code: parsed.data.code } });
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
||||||
return { error: "That project code already exists." };
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/project-codes");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProjectCode(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 };
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.projectCode.update({ where: { id }, data: { code: parsed.data.code } });
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
|
||||||
return { error: "That project code already exists." };
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
revalidatePath("/admin/project-codes");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleProjectCodeActive(id: string): Promise<Result> {
|
|
||||||
const g = await guard();
|
|
||||||
if ("error" in g) return g;
|
|
||||||
|
|
||||||
const code = await db.projectCode.findUnique({ where: { id }, select: { isActive: true } });
|
|
||||||
if (!code) return { error: "Not found" };
|
|
||||||
await db.projectCode.update({ where: { id }, data: { isActive: !code.isActive } });
|
|
||||||
revalidatePath("/admin/project-codes");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteProjectCode(id: string): Promise<Result> {
|
|
||||||
const g = await guard();
|
|
||||||
if ("error" in g) return g;
|
|
||||||
|
|
||||||
// Safe to delete: POs keep their project code as a text snapshot, so no
|
|
||||||
// purchase order references this row.
|
|
||||||
await db.projectCode.delete({ where: { id } });
|
|
||||||
revalidatePath("/admin/project-codes");
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { hasPermission } from "@/lib/permissions";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { ProjectCodesTable } from "./project-codes-table";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Project Codes" };
|
|
||||||
|
|
||||||
export default async function ProjectCodesPage() {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user) redirect("/login");
|
|
||||||
if (!hasPermission(session.user.role, "manage_project_codes")) redirect("/dashboard");
|
|
||||||
|
|
||||||
const projectCodes = await db.projectCode.findMany({
|
|
||||||
orderBy: [{ isActive: "desc" }, { code: "asc" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ProjectCodesTable
|
|
||||||
projectCodes={projectCodes.map((c) => ({
|
|
||||||
id: c.id,
|
|
||||||
code: c.code,
|
|
||||||
isActive: c.isActive,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
|
||||||
import { createProjectCode, updateProjectCode } 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 ProjectCodeRow = {
|
|
||||||
id: string;
|
|
||||||
code: string;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Fields({ projectCode }: { projectCode?: ProjectCodeRow }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Project code *</label>
|
|
||||||
<input name="code" defaultValue={projectCode?.code ?? ""} required className={INPUT} placeholder="e.g. Petronet LNG Cochin" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AddProjectCodeButton() {
|
|
||||||
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 createProjectCode(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 Project Code
|
|
||||||
</button>
|
|
||||||
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Project Code">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Fields />
|
|
||||||
{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 EditProjectCodeButton({
|
|
||||||
projectCode,
|
|
||||||
open: controlledOpen,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
projectCode: ProjectCodeRow;
|
|
||||||
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 updateProjectCode(projectCode.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 Project Code">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<Fields projectCode={projectCode} />
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
"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 {
|
|
||||||
AddProjectCodeButton,
|
|
||||||
EditProjectCodeButton,
|
|
||||||
type ProjectCodeRow,
|
|
||||||
} from "./project-code-form";
|
|
||||||
import { deleteProjectCode, toggleProjectCodeActive } from "./actions";
|
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
|
||||||
|
|
||||||
function ProjectCodeActionsMenu({ projectCode }: { projectCode: ProjectCodeRow }) {
|
|
||||||
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)}>
|
|
||||||
{projectCode.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</RowActionsItem>
|
|
||||||
<RowActionsSeparator />
|
|
||||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
|
||||||
</RowActionsMenu>
|
|
||||||
|
|
||||||
<EditProjectCodeButton projectCode={projectCode} open={editOpen} onOpenChange={setEditOpen} />
|
|
||||||
<DeleteConfirmDialog
|
|
||||||
open={deleteOpen}
|
|
||||||
onOpenChange={setDeleteOpen}
|
|
||||||
label={projectCode.code}
|
|
||||||
onConfirm={() => deleteProjectCode(projectCode.id)}
|
|
||||||
/>
|
|
||||||
<ConfirmDialog
|
|
||||||
open={toggleOpen}
|
|
||||||
onOpenChange={setToggleOpen}
|
|
||||||
title={projectCode.isActive ? "Deactivate project code?" : "Activate project code?"}
|
|
||||||
description={
|
|
||||||
projectCode.isActive
|
|
||||||
? "It will no longer appear in the Project Code dropdown."
|
|
||||||
: "It will appear in the Project Code dropdown again."
|
|
||||||
}
|
|
||||||
confirmLabel={projectCode.isActive ? "Deactivate" : "Activate"}
|
|
||||||
onConfirm={() => toggleProjectCodeActive(projectCode.id)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProjectCodesTable({ projectCodes }: { projectCodes: ProjectCodeRow[] }) {
|
|
||||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
|
||||||
useTableControls<ProjectCodeRow>({
|
|
||||||
rows: projectCodes,
|
|
||||||
defaultSortKey: "code",
|
|
||||||
searchText: (c) => [c.code, c.isActive ? "active" : "inactive"].join(" "),
|
|
||||||
chipMatch: (c, chip) => {
|
|
||||||
if (chip.toLowerCase() === "active") return c.isActive;
|
|
||||||
if (chip.toLowerCase() === "inactive") return !c.isActive;
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
sortValue: (c, key) => {
|
|
||||||
if (key === "isActive") return c.isActive ? "Active" : "Inactive";
|
|
||||||
const val = c[key as keyof ProjectCodeRow];
|
|
||||||
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">Project Codes</h1>
|
|
||||||
<p className="text-sm text-neutral-500 mt-0.5">Codes that populate the PO “Project Code” dropdown</p>
|
|
||||||
</div>
|
|
||||||
<AddProjectCodeButton />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TableControls
|
|
||||||
search={search}
|
|
||||||
onSearch={setSearch}
|
|
||||||
searchPlaceholder="Search project code…"
|
|
||||||
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="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>Project Code</SortableTh>
|
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof ProjectCodeRow)}>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={3} className="px-4 py-8 text-center text-neutral-400">
|
|
||||||
No project codes yet. Add one to populate the Project Code dropdown.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{filtered.map((projectCode) => (
|
|
||||||
<tr key={projectCode.id} className="hover:bg-neutral-50">
|
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900">{projectCode.code}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
||||||
projectCode.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
|
||||||
}`}>
|
|
||||||
{projectCode.isActive ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<ProjectCodeActionsMenu projectCode={projectCode} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
"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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
"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 & Conditions</h1>
|
|
||||||
<p className="text-sm text-neutral-500 mt-0.5">Categories & clauses that populate the PO Terms & 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 & 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
4
App/app/(portal)/admin/vendors/actions.ts
vendored
4
App/app/(portal)/admin/vendors/actions.ts
vendored
|
|
@ -95,7 +95,7 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/vendors");
|
revalidatePath("/admin/vendors");
|
||||||
revalidatePath("/catalogue/vendors");
|
revalidatePath("/inventory/vendors");
|
||||||
return { ok: true };
|
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 } });
|
await db.vendor.update({ where: { id: vendorId }, data: { isVerified: true } });
|
||||||
revalidatePath("/admin/vendors");
|
revalidatePath("/admin/vendors");
|
||||||
revalidatePath("/catalogue/vendors");
|
revalidatePath("/inventory/vendors");
|
||||||
revalidatePath(`/admin/vendors/${vendorId}`);
|
revalidatePath(`/admin/vendors/${vendorId}`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
81
App/app/(portal)/admin/vendors/vendor-form.tsx
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
|
@ -113,44 +113,6 @@ 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 }) {
|
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
||||||
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
|
||||||
const [name, setName] = useState(vendor?.name ?? "");
|
const [name, setName] = useState(vendor?.name ?? "");
|
||||||
|
|
@ -187,19 +149,13 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
body: JSON.stringify({ gstin, captcha: captchaAnswer, sessionId }),
|
||||||
});
|
});
|
||||||
const data: GstResult & { error?: string } = await res.json();
|
const data: GstResult & { error?: string } = await res.json();
|
||||||
// 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("idle"); return; }
|
||||||
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
|
|
||||||
setName(data.tradeName || data.legalName);
|
setName(data.tradeName || data.legalName);
|
||||||
setAddress(data.address);
|
setAddress(data.address);
|
||||||
if (data.pincode) setPincode(data.pincode);
|
if (data.pincode) setPincode(data.pincode);
|
||||||
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
setGstSuccess(`✓ ${data.legalName} — ${data.status} since ${data.registrationDate}`);
|
||||||
setCaptchaStep("idle");
|
setCaptchaStep("idle");
|
||||||
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
|
} catch { setGstError("Lookup failed"); setCaptchaStep("idle"); }
|
||||||
}
|
|
||||||
|
|
||||||
// Close the CAPTCHA popup without touching the vendor form fields.
|
|
||||||
function closeCaptcha() {
|
|
||||||
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -227,46 +183,31 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
|
{captchaStep === "ready" && captchaB64 && (
|
||||||
{captchaStep === "loading" ? (
|
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
||||||
<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>
|
<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"
|
<img src={`data:image/png;base64,${captchaB64}`} alt="CAPTCHA"
|
||||||
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
className="rounded border border-neutral-200 bg-white" style={{ imageRendering: "pixelated", height: 48 }} />
|
||||||
)}
|
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
<input type="text" inputMode="numeric" maxLength={6} value={captchaAnswer}
|
||||||
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
onChange={(e) => setCaptchaAnswer(e.target.value.replace(/\D/g, ""))}
|
||||||
placeholder="6 digits"
|
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"
|
||||||
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
|
autoFocus
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }}
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
|
<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">
|
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"}
|
Verify
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
|
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
||||||
className="text-xs text-neutral-500 hover:underline disabled:opacity-50">
|
|
||||||
New image
|
New image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</CaptchaPopup>
|
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</p>}
|
||||||
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
|
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||||
{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>}
|
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canPerformAction } from "@/lib/po-state-machine";
|
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 { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
|
@ -14,21 +12,14 @@ export async function approvePo({
|
||||||
poId,
|
poId,
|
||||||
note,
|
note,
|
||||||
withNote = false,
|
withNote = false,
|
||||||
suggestedAdvancePayment,
|
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
withNote?: boolean;
|
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> {
|
}): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) return { error: "Unauthorized" };
|
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({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
include: { submitter: true, lineItems: true },
|
include: { submitter: true, lineItems: true },
|
||||||
|
|
@ -44,28 +35,17 @@ export async function approvePo({
|
||||||
return { error: "A vendor must be assigned before approving this PO." };
|
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({
|
await db.purchaseOrder.update({
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
status: "MGR_APPROVED",
|
status: "MGR_APPROVED",
|
||||||
approvedAt: new Date(),
|
approvedAt: new Date(),
|
||||||
managerNote: note ?? null,
|
managerNote: note ?? null,
|
||||||
suggestedAdvancePayment: advance,
|
|
||||||
actions: {
|
actions: {
|
||||||
create: {
|
create: {
|
||||||
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
|
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
|
||||||
note: note ?? null,
|
note: note ?? null,
|
||||||
actorId: session.user.id,
|
actorId: session.user.id,
|
||||||
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -85,12 +65,6 @@ export async function approvePo({
|
||||||
revalidatePath(`/admin/sites/${siteId}`);
|
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 } });
|
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||||
await notify({
|
await notify({
|
||||||
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
|
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",
|
||||||
|
|
|
||||||
|
|
@ -3,38 +3,21 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
|
import { approvePo, rejectPo, requestEdits, requestVendorId } from "./actions";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
|
||||||
import type { POStatus } from "@prisma/client";
|
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({
|
export function ApprovalActions({
|
||||||
poId,
|
poId,
|
||||||
poStatus,
|
poStatus,
|
||||||
totalAmount = 0,
|
|
||||||
currency = "INR",
|
|
||||||
}: {
|
}: {
|
||||||
poId: string;
|
poId: string;
|
||||||
poStatus: POStatus;
|
poStatus: POStatus;
|
||||||
totalAmount?: number;
|
|
||||||
currency?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [advancePercent, setAdvancePercent] = useState(100);
|
|
||||||
const [activeAction, setActiveAction] = useState<string | null>(null);
|
const [activeAction, setActiveAction] = useState<string | null>(null);
|
||||||
const [pending, setPending] = useState<string | null>(null);
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const advance = advanceAmount(totalAmount, advancePercent);
|
|
||||||
|
|
||||||
async function dispatch(action: string, requireNote = false) {
|
async function dispatch(action: string, requireNote = false) {
|
||||||
if (requireNote && !note.trim()) {
|
if (requireNote && !note.trim()) {
|
||||||
setError("A note is required for this action.");
|
setError("A note is required for this action.");
|
||||||
|
|
@ -43,10 +26,8 @@ export function ApprovalActions({
|
||||||
setPending(action);
|
setPending(action);
|
||||||
setError("");
|
setError("");
|
||||||
let result: { ok: true } | { error: string } | undefined;
|
let result: { ok: true } | { error: string } | undefined;
|
||||||
// Approvals carry the Manager's advance decision (resolved amount, not %).
|
if (action === "approve") result = await approvePo({ poId, note });
|
||||||
if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance });
|
else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true });
|
||||||
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 === "reject") result = await rejectPo({ poId, note });
|
||||||
else if (action === "request_edits") result = await requestEdits({ poId, note });
|
else if (action === "request_edits") result = await requestEdits({ poId, note });
|
||||||
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
|
else if (action === "request_vendor_id") result = await requestVendorId({ poId });
|
||||||
|
|
@ -64,37 +45,6 @@ export function ApprovalActions({
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
<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>
|
<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") && (
|
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,11 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { managerEditPo } from "./manager-po-edit-actions";
|
import { managerEditPo } from "./manager-po-edit-actions";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
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 { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
|
||||||
import { ProjectCodeField } from "@/components/po/project-code-field";
|
|
||||||
import { PoTermsEditor } from "@/components/po/po-terms-editor";
|
|
||||||
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -43,10 +39,6 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
|
||||||
projectCodeOptions: string[];
|
|
||||||
termsCatalogue: CatalogueCategory[];
|
|
||||||
initialTerms: PoTerm[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
|
|
@ -59,13 +51,12 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
||||||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms }: Props) {
|
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [terms, setTerms] = useState<PoTerm[]>(initialTerms);
|
|
||||||
|
|
||||||
const extPo = po as typeof po & {
|
const extPo = po as typeof po & {
|
||||||
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
piQuotationNo?: string | null; piQuotationDate?: Date | null;
|
||||||
|
|
@ -107,7 +98,6 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
||||||
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
||||||
});
|
});
|
||||||
data.set("termsJson", JSON.stringify(terms));
|
|
||||||
|
|
||||||
const result = await managerEditPo(po.id, data);
|
const result = await managerEditPo(po.id, data);
|
||||||
if ("error" in result) {
|
if ("error" in result) {
|
||||||
|
|
@ -197,7 +187,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Project Code</label>
|
<label className={LABEL}>Project Code</label>
|
||||||
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT} />
|
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT} placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Delivery Date Required</label>
|
<label className={LABEL}>Delivery Date Required</label>
|
||||||
|
|
@ -240,14 +230,21 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||||||
<label className={LABEL}>Place of Delivery</label>
|
<label className={LABEL}>Place of Delivery</label>
|
||||||
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT} />
|
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Vendor</h3>
|
||||||
<label className={LABEL}>Vendor</label>
|
<label className={LABEL}>Vendor</label>
|
||||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Line Items */}
|
{/* Line Items */}
|
||||||
|
|
@ -261,7 +258,38 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, d
|
||||||
{/* Terms & Conditions */}
|
{/* Terms & Conditions */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms & Conditions</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Terms & Conditions</h3>
|
||||||
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { createPoSchema } from "@/lib/validations/po";
|
import { createPoSchema } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
export async function managerEditPo(
|
export async function managerEditPo(
|
||||||
|
|
@ -69,10 +68,6 @@ export async function managerEditPo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
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(
|
const newTotal = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
0
|
0
|
||||||
|
|
@ -135,7 +130,6 @@ export async function managerEditPo(
|
||||||
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
||||||
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||||||
tcOthers: data.tcOthers ?? null,
|
tcOthers: data.tcOthers ?? null,
|
||||||
terms,
|
|
||||||
totalAmount: newTotal,
|
totalAmount: newTotal,
|
||||||
lineItems: {
|
lineItems: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,6 @@ import { ApprovalActions } from "./approval-actions";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
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 { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -32,7 +29,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([
|
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -55,19 +52,12 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
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.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 } } } }),
|
|
||||||
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
|
||||||
const projectCodeOptions = projectCodes.map((c) => c.code);
|
|
||||||
const termsCatalogue = await getTermsCatalogue();
|
|
||||||
const savedTerms = parsePoTerms(po.terms);
|
|
||||||
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -108,21 +98,12 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
|
||||||
projectCodeOptions={projectCodeOptions}
|
|
||||||
termsCatalogue={termsCatalogue}
|
|
||||||
initialTerms={initialTerms}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 md:mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
{hasSignature ? (
|
{hasSignature ? (
|
||||||
<ApprovalActions
|
<ApprovalActions poId={po.id} poStatus={po.status} />
|
||||||
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">
|
<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>
|
<span className="text-warning-500 text-xl leading-none mt-0.5">✎</span>
|
||||||
|
|
|
||||||
|
|
@ -115,16 +115,6 @@ export async function advanceStage(id: string, action: ApplicationAction): Promi
|
||||||
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
|
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
|
||||||
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
|
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
|
||||||
|
|
||||||
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
|
|
||||||
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
|
|
||||||
// gate is completed by `verify_competency`.
|
|
||||||
if (action === "verify_competency") {
|
|
||||||
const references = await db.referenceCheck.count({ where: { applicationId: id } });
|
|
||||||
if (references === 0) {
|
|
||||||
return { error: "Record at least one reference check before completing competency & references" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.application.update({
|
await db.application.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -217,33 +207,6 @@ export async function verifyDocuments(formData: FormData): Promise<ActionResult>
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
const crewMemberId = app.crewMember.id;
|
const crewMemberId = app.crewMember.id;
|
||||||
|
|
||||||
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
|
|
||||||
// the seat's rank is EXPIRED.
|
|
||||||
// Scope note (documented limitation): seafarer documents are collected on the
|
|
||||||
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
|
|
||||||
// usually has none on file, so a hard "missing document" block would stall the
|
|
||||||
// whole funnel. We therefore gate on what is available (expiry of documents the
|
|
||||||
// candidate already holds); the "all required documents present" check is
|
|
||||||
// enforced post-onboarding in the verification queue (§8.11). Once careers
|
|
||||||
// intake (A2) uploads documents pre-onboarding, tighten this to also require
|
|
||||||
// presence of every mandatory docType.
|
|
||||||
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
|
|
||||||
if (reqRank) {
|
|
||||||
const [required, candidateDocs] = await Promise.all([
|
|
||||||
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
|
|
||||||
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
|
|
||||||
]);
|
|
||||||
const requiredTypes = new Set(required.map((r) => r.docType));
|
|
||||||
const now = new Date();
|
|
||||||
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
|
|
||||||
if (expired.length > 0) {
|
|
||||||
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// C4 (experience check) is deferred: the Requisition has no min-experience
|
|
||||||
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
|
|
||||||
// the candidate's ExperienceRecord total against it here and flag a shortfall.
|
|
||||||
|
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
// Capture bank / EPF (PII — encryption deferred to Phase 4).
|
// Capture bank / EPF (PII — encryption deferred to Phase 4).
|
||||||
await tx.bankDetail.upsert({
|
await tx.bankDetail.upsert({
|
||||||
|
|
@ -369,7 +332,7 @@ export async function returnSalary(id: string, reason: string): Promise<ActionRe
|
||||||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
});
|
});
|
||||||
await db.crewAction.create({
|
await db.crewAction.create({
|
||||||
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
||||||
});
|
});
|
||||||
revalidateApp(id, app.requisition.id);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -486,7 +449,7 @@ export async function declineInterviewWaiver(id: string, reason: string): Promis
|
||||||
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
});
|
});
|
||||||
await db.crewAction.create({
|
await db.crewAction.create({
|
||||||
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
||||||
});
|
});
|
||||||
revalidateApp(id, app.requisition.id);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -536,7 +499,7 @@ export async function returnSelection(id: string, reason: string): Promise<Actio
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
|
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
|
||||||
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
|
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
|
||||||
await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
|
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
|
||||||
});
|
});
|
||||||
revalidateApp(id, app.requisition.id);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -599,33 +562,8 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
||||||
});
|
});
|
||||||
if (!app) return { error: "Application not found" };
|
if (!app) return { error: "Application not found" };
|
||||||
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
|
if (app.stage !== "SELECTED") return { error: `Only a SELECTED candidate can be onboarded (currently ${app.stage})` };
|
||||||
|
|
||||||
// D1 (spec §8.5): onboarding is blocked until the salary structure is
|
|
||||||
// Manager-approved. Without this guard a SELECTED application that somehow has
|
|
||||||
// no approved structure would still "succeed" but bind zero salary rows
|
|
||||||
// (the updateMany below would match nothing) — a silent payroll gap.
|
|
||||||
const approvedSalary = await db.salaryStructure.findFirst({
|
|
||||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
|
||||||
select: { id: true },
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
});
|
|
||||||
if (!approvedSalary) return { error: "Salary structure must be Manager-approved before onboarding" };
|
|
||||||
|
|
||||||
const joiningDate = new Date(joiningStr);
|
const joiningDate = new Date(joiningStr);
|
||||||
|
|
||||||
// Upload the optional contract letter BEFORE the transaction (storage I/O),
|
|
||||||
// then persist its row INSIDE the tx so onboarding is one atomic side-effecting
|
|
||||||
// event (spec §11). The blob key is keyed on the crew member (stable before the
|
|
||||||
// assignment exists); if the tx fails we leave only a harmless orphan blob,
|
|
||||||
// never a fully-onboarded crew member with no contract row.
|
|
||||||
const file = formData.get("contract");
|
|
||||||
let contract: { fileKey: string; salaryRestricted: boolean } | null = null;
|
|
||||||
if (file instanceof File && file.size > 0) {
|
|
||||||
const key = buildStorageKey("contract", app.crewMember.id, file.name);
|
|
||||||
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
|
||||||
contract = { fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await db.$transaction(async (tx) => {
|
const result = await db.$transaction(async (tx) => {
|
||||||
const employeeId = await generateEmployeeId(tx);
|
const employeeId = await generateEmployeeId(tx);
|
||||||
const assignment = await tx.crewAssignment.create({
|
const assignment = await tx.crewAssignment.create({
|
||||||
|
|
@ -644,23 +582,9 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
||||||
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||||
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
|
data: { assignmentId: assignment.id, effectiveFrom: joiningDate },
|
||||||
});
|
});
|
||||||
if (contract) {
|
|
||||||
await tx.contractLetter.create({ data: { assignmentId: assignment.id, fileKey: contract.fileKey, salaryRestricted: contract.salaryRestricted } });
|
|
||||||
}
|
|
||||||
// D3 AC2 (spec §11): the single CREW_ONBOARDED audit row records the created IDs.
|
|
||||||
await tx.application.update({
|
await tx.application.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: { stage: "ONBOARDED", actions: { create: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
|
||||||
stage: "ONBOARDED",
|
|
||||||
actions: {
|
|
||||||
create: {
|
|
||||||
actionType: "CREW_ONBOARDED",
|
|
||||||
actorId: g.userId,
|
|
||||||
crewMemberId: app.crewMember.id,
|
|
||||||
metadata: { assignmentId: assignment.id, employeeId, salaryStructureId: approvedSalary.id },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await tx.requisition.update({
|
await tx.requisition.update({
|
||||||
where: { id: app.requisition.id },
|
where: { id: app.requisition.id },
|
||||||
|
|
@ -675,6 +599,16 @@ export async function onboardCandidate(formData: FormData): Promise<ActionResult
|
||||||
return { assignmentId: assignment.id, employeeId };
|
return { assignmentId: assignment.id, employeeId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Contract letter (optional) — stored after the core transaction.
|
||||||
|
const file = formData.get("contract");
|
||||||
|
if (file instanceof File && file.size > 0) {
|
||||||
|
const key = buildStorageKey("contract", result.assignmentId, file.name);
|
||||||
|
await uploadBuffer(key, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||||
|
await db.contractLetter.create({
|
||||||
|
data: { assignmentId: result.assignmentId, fileKey: key, salaryRestricted: formData.get("salaryRestricted") !== "false" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
revalidateApp(id, app.requisition.id);
|
revalidateApp(id, app.requisition.id);
|
||||||
return { ok: true, id: result.employeeId };
|
return { ok: true, id: result.employeeId };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,7 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const c = await db.crewMember.findUnique({
|
const c = await db.crewMember.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
|
||||||
appliedRank: { select: { name: true } },
|
|
||||||
currentRank: { select: { name: true } },
|
|
||||||
// B3 AC3 — pull the returning hand's history so the callout shows real records.
|
|
||||||
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
|
||||||
documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!c) notFound();
|
if (!c) notFound();
|
||||||
|
|
||||||
|
|
@ -51,50 +45,16 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
||||||
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||||
{c.type === "EX_HAND" && (
|
{c.source === "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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{c.type === "EX_HAND" && (
|
{c.source === "EX_HAND" && (
|
||||||
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
<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.{" "}
|
<strong>Returning crew.</strong> Prior documents, bank details and tour history are on file from earlier
|
||||||
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
assignments; the interview may be waived with Manager approval (recruitment pipeline — next phase).
|
||||||
<span>No prior records are on file yet.</span>
|
|
||||||
) : (
|
|
||||||
<span>Prior records on file from earlier assignments:</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{c.experienceRecords.length > 0 && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{c.experienceRecords.map((e) => (
|
|
||||||
<li key={e.id} className="text-sm text-purple-900">
|
|
||||||
{e.rank?.name ?? "—"}
|
|
||||||
{e.vesselType ? ` · ${e.vesselType}` : ""}
|
|
||||||
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
|
|
||||||
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `–${e.toDate.getFullYear()}` : ""})` : ""}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{c.documents.length > 0 && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{c.documents.map((doc) => (
|
|
||||||
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
|
|
||||||
{doc.docType}
|
|
||||||
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,13 @@ 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).
|
// Store an optional CV upload and return its storage key (null if none).
|
||||||
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
||||||
const file = formData.get("cv");
|
const file = formData.get("cv");
|
||||||
|
|
@ -67,53 +74,14 @@ export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
|
const { type, status } = derive(d.source);
|
||||||
// B3 AC1 — ex-hand recognition: a returning person re-entered as a fresh
|
|
||||||
// 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.)
|
|
||||||
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 } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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({
|
const candidate = await db.crewMember.create({
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
|
type,
|
||||||
// is an office/admin designation set on the crew record, not here.
|
status,
|
||||||
type: "NEW",
|
|
||||||
status: "CANDIDATE",
|
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
@ -142,6 +110,7 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
const parsed = parse(formData);
|
const parsed = parse(formData);
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
const d = parsed.data;
|
const d = parsed.data;
|
||||||
|
const { type, status } = derive(d.source);
|
||||||
|
|
||||||
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
||||||
if (!existing) return { error: "Candidate not found" };
|
if (!existing) return { error: "Candidate not found" };
|
||||||
|
|
@ -153,8 +122,9 @@ export async function updateCandidate(formData: FormData): Promise<ActionResult>
|
||||||
data: {
|
data: {
|
||||||
name: d.name,
|
name: d.name,
|
||||||
source: d.source,
|
source: d.source,
|
||||||
// type/status are left untouched — ex-hand / employee designation is owned
|
// Don't downgrade an onboarded employee back to a candidate via an edit.
|
||||||
// by the office (admin crew record + sign-off), never by a candidate edit.
|
type,
|
||||||
|
status: existing.status === "EMPLOYEE" ? existing.status : status,
|
||||||
appliedRankId: d.appliedRankId || null,
|
appliedRankId: d.appliedRankId || null,
|
||||||
currentRankId: d.currentRankId || null,
|
currentRankId: d.currentRankId || null,
|
||||||
experienceMonths: d.experienceMonths,
|
experienceMonths: d.experienceMonths,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||||
import type { CandidateSource } from "@prisma/client";
|
import type { CandidateSource } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { addCandidate, updateCandidate } from "./actions";
|
import { addCandidate, updateCandidate } from "./actions";
|
||||||
import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
import { SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
||||||
|
|
||||||
const INPUT =
|
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";
|
"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>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
<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)}>
|
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
||||||
{FORM_SOURCE_OPTIONS.map((s) => (
|
{SOURCE_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -64,7 +64,7 @@ function CandidateFields({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held (ex-hands)</label>
|
||||||
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{ranks.map((r) => (
|
{ranks.map((r) => (
|
||||||
|
|
@ -131,9 +131,7 @@ function emptyState(): FieldState {
|
||||||
function stateFrom(c: EditableCandidate): FieldState {
|
function stateFrom(c: EditableCandidate): FieldState {
|
||||||
return {
|
return {
|
||||||
name: c.name,
|
name: c.name,
|
||||||
// Ex-hand is an admin-only designation; the candidate form only edits origin.
|
source: c.source,
|
||||||
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
|
|
||||||
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
|
|
||||||
appliedRankId: c.appliedRankId ?? "",
|
appliedRankId: c.appliedRankId ?? "",
|
||||||
currentRankId: c.currentRankId ?? "",
|
currentRankId: c.currentRankId ?? "",
|
||||||
experienceMonths: String(c.experienceMonths),
|
experienceMonths: String(c.experienceMonths),
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,6 @@ export const SOURCE_LABEL: Record<CandidateSource, string> = {
|
||||||
|
|
||||||
export const SOURCE_OPTIONS: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
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> = {
|
export const STATUS_LABEL: Record<CrewStatus, string> = {
|
||||||
PROSPECT: "Prospect",
|
PROSPECT: "Prospect",
|
||||||
CANDIDATE: "Candidate",
|
CANDIDATE: "Candidate",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
||||||
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
||||||
|
|
@ -12,7 +12,6 @@ type CandidateRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
source: CandidateSource;
|
source: CandidateSource;
|
||||||
type: CandidateType;
|
|
||||||
status: CrewStatus;
|
status: CrewStatus;
|
||||||
appliedRankId: string | null;
|
appliedRankId: string | null;
|
||||||
appliedRank: string | null;
|
appliedRank: string | null;
|
||||||
|
|
@ -55,12 +54,13 @@ function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
|
||||||
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
<td className="px-4 py-3">
|
<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>
|
<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>}
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</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">{c.currentRank ?? "—"}</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">{c.appliedRank ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ export default async function CandidatesPage() {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
source: c.source,
|
source: c.source,
|
||||||
type: c.type,
|
|
||||||
status: c.status,
|
status: c.status,
|
||||||
appliedRankId: c.appliedRankId,
|
appliedRankId: c.appliedRankId,
|
||||||
appliedRank: c.appliedRank?.name ?? null,
|
appliedRank: c.appliedRank?.name ?? null,
|
||||||
|
|
@ -47,9 +46,5 @@ export default async function CandidatesPage() {
|
||||||
hasCv: Boolean(c.cvKey),
|
hasCv: Boolean(c.cvKey),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// B3 AC2 — ex-hands (proven crew) surface above new candidates by default.
|
|
||||||
// Stable sort preserves the createdAt-desc order within each group.
|
|
||||||
rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND"));
|
|
||||||
|
|
||||||
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
return <CandidatesManager candidates={rows} ranks={ranks} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
import { canViewSalary, bankEpfValue } from "@/lib/crew-pii";
|
||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import { CrewProfile } from "./crew-profile";
|
import { CrewProfile } from "./crew-profile";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
@ -68,7 +68,7 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id
|
||||||
documents={c.documents.map((d) => ({
|
documents={c.documents.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
docType: d.docType,
|
docType: d.docType,
|
||||||
number: documentNumberValue(d.number, d.docType, role),
|
number: d.number,
|
||||||
issueDate: d.issueDate?.toISOString() ?? null,
|
issueDate: d.issueDate?.toISOString() ?? null,
|
||||||
expiryDate: d.expiryDate?.toISOString() ?? null,
|
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||||
verificationStatus: d.verificationStatus,
|
verificationStatus: d.verificationStatus,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
|
import { autoRaiseRequisition } from "@/lib/requisition-service";
|
||||||
import { SeafarerDocType, PpeItem } from "@prisma/client";
|
import { SeafarerDocType, PpeItem } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
@ -83,14 +83,9 @@ export async function uploadDocument(formData: FormData): Promise<ActionResult>
|
||||||
export async function deleteDocument(id: string): Promise<ActionResult> {
|
export async function deleteDocument(id: string): Promise<ActionResult> {
|
||||||
const g = await guard("upload_crew_records");
|
const g = await guard("upload_crew_records");
|
||||||
if ("error" in g) return g;
|
if ("error" in g) return g;
|
||||||
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
|
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true } });
|
||||||
if (!doc) return { error: "Document not found" };
|
if (!doc) return { error: "Document not found" };
|
||||||
await db.$transaction(async (tx) => {
|
await db.seafarerDocument.delete({ where: { id } });
|
||||||
await tx.seafarerDocument.delete({ where: { id } });
|
|
||||||
await tx.crewAction.create({
|
|
||||||
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: doc.crewMemberId, metadata: { record: "document", docType: doc.docType } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
revalidatePath(crewPath(doc.crewMemberId));
|
revalidatePath(crewPath(doc.crewMemberId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -183,12 +178,7 @@ export async function deleteNextOfKin(id: string): Promise<ActionResult> {
|
||||||
if ("error" in g) return g;
|
if ("error" in g) return g;
|
||||||
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
||||||
if (!nok) return { error: "Record not found" };
|
if (!nok) return { error: "Record not found" };
|
||||||
await db.$transaction(async (tx) => {
|
await db.nextOfKin.delete({ where: { id } });
|
||||||
await tx.nextOfKin.delete({ where: { id } });
|
|
||||||
await tx.crewAction.create({
|
|
||||||
data: { actionType: "RECORD_DELETED", actorId: g.userId, crewMemberId: nok.crewMemberId, metadata: { record: "next_of_kin" } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
revalidatePath(crewPath(nok.crewMemberId));
|
revalidatePath(crewPath(nok.crewMemberId));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -289,9 +279,7 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
|
|
||||||
const off = new Date(signOffDate);
|
const off = new Date(signOffDate);
|
||||||
|
|
||||||
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
|
await db.$transaction(async (tx) => {
|
||||||
// seat can never become vacant without its backfill being raised.
|
|
||||||
const backfill = await db.$transaction(async (tx) => {
|
|
||||||
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
|
await tx.crewAssignment.update({ where: { id: assignmentId }, data: { status: "SIGNED_OFF", signOffDate: off } });
|
||||||
await tx.experienceRecord.create({
|
await tx.experienceRecord.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -304,24 +292,23 @@ export async function signOffCrew(assignmentId: string, signOffDate: string, rem
|
||||||
source: "internal",
|
source: "internal",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
|
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a returning hand.
|
||||||
// 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({
|
await tx.crewMember.update({
|
||||||
where: { id: assignment.crewMemberId },
|
where: { id: assignment.crewMemberId },
|
||||||
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
|
data: { status: "EX_HAND", type: "EX_HAND", source: "EX_HAND", currentRankId: assignment.rankId },
|
||||||
});
|
});
|
||||||
await tx.crewAction.create({
|
await tx.crewAction.create({
|
||||||
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
data: { actionType: "CREW_SIGNED_OFF", actorId: g.userId, crewMemberId: assignment.crewMemberId, note: remarks?.trim() || null },
|
||||||
});
|
});
|
||||||
return autoRaiseRequisition(
|
|
||||||
{ rankId: assignment.rankId, vesselId: assignment.vesselId, siteId: assignment.siteId, reason: "SIGN_OFF" },
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
// Notify the office after the transaction commits.
|
|
||||||
await notifyAutoRaised(backfill);
|
// The seat is now vacant → auto-raise a backfill requisition (spec §5.3).
|
||||||
|
await autoRaiseRequisition({
|
||||||
|
rankId: assignment.rankId,
|
||||||
|
vesselId: assignment.vesselId,
|
||||||
|
siteId: assignment.siteId,
|
||||||
|
reason: "SIGN_OFF",
|
||||||
|
});
|
||||||
|
|
||||||
revalidatePath(crewPath(assignment.crewMemberId));
|
revalidatePath(crewPath(assignment.crewMemberId));
|
||||||
revalidatePath("/crewing/crew");
|
revalidatePath("/crewing/crew");
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission, type Permission } from "@/lib/permissions";
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { leaveCausesClash } from "@/lib/leave-clash";
|
import { leaveCausesClash } from "@/lib/leave-clash";
|
||||||
import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service";
|
import { autoRaiseRequisition, getManagerRecipients } from "@/lib/requisition-service";
|
||||||
import { notifyCrew } from "@/lib/notifier";
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
import { LeaveType } from "@prisma/client";
|
import { LeaveType } from "@prisma/client";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
@ -110,9 +110,7 @@ export async function decideLeave(id: string, approve: boolean, note?: string):
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leave approval + the clash check + any backfill requisition commit atomically
|
const { clash } = await db.$transaction(async (tx) => {
|
||||||
// (spec §5.3/§11): an approved leave can never leave a cover gap un-raised.
|
|
||||||
const backfill = await db.$transaction(async (tx) => {
|
|
||||||
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
|
await tx.leaveRequest.update({ where: { id }, data: { status: "APPROVED", decidedById: g.userId, decidedAt: new Date() } });
|
||||||
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
|
await tx.crewAssignment.update({ where: { id: leave.assignment.id }, data: { status: "ON_LEAVE" } });
|
||||||
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
|
await tx.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, metadata: { decision: "APPROVED" } } });
|
||||||
|
|
@ -123,15 +121,18 @@ export async function decideLeave(id: string, approve: boolean, note?: string):
|
||||||
fromDate: leave.fromDate,
|
fromDate: leave.fromDate,
|
||||||
toDate: leave.toDate,
|
toDate: leave.toDate,
|
||||||
});
|
});
|
||||||
if (!clash) return null;
|
return { clash };
|
||||||
return autoRaiseRequisition(
|
|
||||||
{ rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" },
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify the office after the transaction commits.
|
// A detected clash auto-raises a LEAVE requisition (reuses the Phase-2 helper).
|
||||||
if (backfill) await notifyAutoRaised(backfill);
|
if (clash) {
|
||||||
|
await autoRaiseRequisition({
|
||||||
|
rankId: leave.assignment.rankId,
|
||||||
|
vesselId: leave.assignment.vesselId,
|
||||||
|
siteId: leave.assignment.siteId,
|
||||||
|
reason: "LEAVE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
revalidate();
|
revalidate();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ export default async function RequisitionsPage() {
|
||||||
vessel: { select: { name: true } },
|
vessel: { select: { name: true } },
|
||||||
site: { select: { name: true } },
|
site: { select: { name: true } },
|
||||||
raisedBy: { select: { name: true } },
|
raisedBy: { select: { name: true } },
|
||||||
_count: { select: { applications: true } },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.reliefRequest.findMany({
|
db.reliefRequest.findMany({
|
||||||
|
|
@ -53,7 +52,6 @@ export default async function RequisitionsPage() {
|
||||||
rankName: r.rank.name,
|
rankName: r.rank.name,
|
||||||
location: r.vessel?.name ?? r.site?.name ?? "—",
|
location: r.vessel?.name ?? r.site?.name ?? "—",
|
||||||
raisedBy: r.raisedBy?.name ?? "System",
|
raisedBy: r.raisedBy?.name ?? "System",
|
||||||
candidateCount: r._count.applications,
|
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ type RequisitionRow = {
|
||||||
rankName: string;
|
rankName: string;
|
||||||
location: string;
|
location: string;
|
||||||
raisedBy: string;
|
raisedBy: string;
|
||||||
candidateCount: number;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -59,33 +58,21 @@ export function RequisitionsManager({
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
|
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
|
||||||
const [location, setLocation] = useState("ALL");
|
const [location, setLocation] = useState("ALL");
|
||||||
const [rank, setRank] = useState("ALL");
|
|
||||||
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
|
|
||||||
|
|
||||||
const locations = useMemo(
|
const locations = useMemo(
|
||||||
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
||||||
[requisitions]
|
[requisitions]
|
||||||
);
|
);
|
||||||
const rankNames = useMemo(
|
|
||||||
() => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(),
|
|
||||||
[requisitions]
|
|
||||||
);
|
|
||||||
const reasons = useMemo(
|
|
||||||
() => Array.from(new Set(requisitions.map((r) => r.reason))),
|
|
||||||
[requisitions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return requisitions.filter((r) => {
|
return requisitions.filter((r) => {
|
||||||
if (status !== "ALL" && r.status !== status) return false;
|
if (status !== "ALL" && r.status !== status) return false;
|
||||||
if (location !== "ALL" && r.location !== location) return false;
|
if (location !== "ALL" && r.location !== location) return false;
|
||||||
if (rank !== "ALL" && r.rankName !== rank) return false;
|
|
||||||
if (reason !== "ALL" && r.reason !== reason) return false;
|
|
||||||
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [requisitions, search, status, location, rank, reason]);
|
}, [requisitions, search, status, location]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -119,18 +106,6 @@ export function RequisitionsManager({
|
||||||
<option key={l} value={l}>{l}</option>
|
<option key={l} value={l}>{l}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
|
|
||||||
<option value="ALL">All ranks</option>
|
|
||||||
{rankNames.map((r) => (
|
|
||||||
<option key={r} value={r}>{r}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
|
||||||
<option value="ALL">All reasons</option>
|
|
||||||
{reasons.map((r) => (
|
|
||||||
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requisitions table */}
|
{/* Requisitions table */}
|
||||||
|
|
@ -142,7 +117,6 @@ export function RequisitionsManager({
|
||||||
<th className="px-4 py-3">Vessel / site</th>
|
<th className="px-4 py-3">Vessel / site</th>
|
||||||
<th className="px-4 py-3">Rank</th>
|
<th className="px-4 py-3">Rank</th>
|
||||||
<th className="px-4 py-3">Reason</th>
|
<th className="px-4 py-3">Reason</th>
|
||||||
<th className="px-4 py-3">Candidates</th>
|
|
||||||
<th className="px-4 py-3">Raised by</th>
|
<th className="px-4 py-3">Raised by</th>
|
||||||
<th className="px-4 py-3">Status</th>
|
<th className="px-4 py-3">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -150,7 +124,7 @@ export function RequisitionsManager({
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
<td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
|
||||||
No requisitions match these filters.
|
No requisitions match these filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -171,7 +145,6 @@ export function RequisitionsManager({
|
||||||
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
<td className="px-4 py-3 text-neutral-700">{r.location}</td>
|
||||||
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
|
||||||
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
|
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
|
||||||
<td className="px-4 py-3 text-neutral-700 tabular-nums">{r.candidateCount}</td>
|
|
||||||
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -132,34 +132,3 @@ export async function verifyNextOfKin(id: string, approve: boolean, remarks?: st
|
||||||
g.userId
|
g.userId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EPFO assisted lookup (Accounts) ────────────────────────────────────────────
|
|
||||||
// Records the result of an EpfoService UAN check on the crew member's EpfDetail
|
|
||||||
// (A3 "record the result"). The actual lookup runs in the browser via /api/epfo;
|
|
||||||
// this just persists the returned member name + a timestamp for the audit trail.
|
|
||||||
|
|
||||||
export async function recordEpfoCheck(crewMemberId: string, memberName: string | null): Promise<ActionResult> {
|
|
||||||
const g = await guard("verify_bank_epf");
|
|
||||||
if ("error" in g) return g;
|
|
||||||
|
|
||||||
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true } });
|
|
||||||
if (!rec) return { error: "EPF details not found" };
|
|
||||||
|
|
||||||
await db.epfDetail.update({
|
|
||||||
where: { crewMemberId },
|
|
||||||
data: { epfoMemberName: memberName, epfoCheckedAt: new Date() },
|
|
||||||
});
|
|
||||||
await db.crewAction.create({
|
|
||||||
data: {
|
|
||||||
actionType: "RECORD_UPDATED",
|
|
||||||
actorId: g.userId,
|
|
||||||
crewMemberId,
|
|
||||||
note: memberName ? `EPFO check matched: ${memberName}` : "EPFO check: no match",
|
|
||||||
metadata: { record: "epfo_check" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath(PATH);
|
|
||||||
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { SeafarerDocType } from "@prisma/client";
|
import type { SeafarerDocType } from "@prisma/client";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
|
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin } from "./actions";
|
||||||
import { verifyAppraisal } from "../appraisals/actions";
|
import { verifyAppraisal } from "../appraisals/actions";
|
||||||
import type { PpeItem } from "@prisma/client";
|
import type { PpeItem } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -60,85 +60,6 @@ function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EPFO assisted lookup (Accounts): OTP handshake against EpfoService via /api/epfo,
|
|
||||||
// then record the returned member name onto the EpfDetail (A3). Aadhaar is not
|
|
||||||
// checked here (UIDAI-restricted — stays manual).
|
|
||||||
function EpfoAssist({ crewMemberId, uan }: { crewMemberId: string; uan: string | null }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<"start" | "otp" | "result">("start");
|
|
||||||
const [sessionId, setSessionId] = useState("");
|
|
||||||
const [mobileHint, setMobileHint] = useState("");
|
|
||||||
const [otp, setOtp] = useState("");
|
|
||||||
const [result, setResult] = useState<{ matched: boolean; name: string | null } | null>(null);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
if (!uan) return null;
|
|
||||||
|
|
||||||
function reset() { setStep("start"); setSessionId(""); setOtp(""); setResult(null); setError(""); setMobileHint(""); }
|
|
||||||
|
|
||||||
async function requestOtp() {
|
|
||||||
setPending(true); setError("");
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/epfo/otp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uan }) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) throw new Error(d.error || "Failed to request OTP");
|
|
||||||
setSessionId(d.sessionId); setMobileHint(d.mobileHint || ""); setStep("otp");
|
|
||||||
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
async function verify() {
|
|
||||||
setPending(true); setError("");
|
|
||||||
try {
|
|
||||||
const r = await fetch("/api/epfo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, uan, otp }) });
|
|
||||||
const d = await r.json();
|
|
||||||
if (!r.ok) throw new Error(d.error || "Lookup failed");
|
|
||||||
setResult({ matched: Boolean(d.matched), name: d.name ?? null }); setStep("result");
|
|
||||||
} catch (e) { setError(String(e instanceof Error ? e.message : e)); }
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
async function record() {
|
|
||||||
setPending(true);
|
|
||||||
await recordEpfoCheck(crewMemberId, result?.name ?? null);
|
|
||||||
setPending(false); setOpen(false); reset(); router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button onClick={() => { reset(); setOpen(true); }} className="rounded-md border border-primary-300 px-3 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50">EPFO check</button>
|
|
||||||
<AdminDialog title="EPFO / UAN check" open={open} onClose={() => setOpen(false)}>
|
|
||||||
<div className="space-y-4 text-left">
|
|
||||||
<p className="text-sm text-neutral-600">Assisted UAN lookup via the EPFO portal. An OTP is sent to the member's registered mobile. <span className="text-neutral-400">(Aadhaar is verified manually — not via this check.)</span></p>
|
|
||||||
<p className="text-xs text-neutral-500">UAN: <span className="font-mono">{uan}</span></p>
|
|
||||||
|
|
||||||
{step === "start" && (
|
|
||||||
<button onClick={requestOtp} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Requesting…" : "Request OTP"}</button>
|
|
||||||
)}
|
|
||||||
{step === "otp" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs text-neutral-500">OTP sent to {mobileHint || "the registered mobile"}.</p>
|
|
||||||
<input className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" placeholder="Enter OTP" value={otp} onChange={(e) => setOtp(e.target.value)} />
|
|
||||||
<button onClick={verify} disabled={pending || !otp} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Checking…" : "Submit OTP"}</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step === "result" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{result?.matched ? (
|
|
||||||
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Matched — EPFO member: <strong>{result.name}</strong></p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">No matching EPFO member for this UAN.</p>
|
|
||||||
)}
|
|
||||||
<button onClick={record} disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Recording…" : "Record result"}</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
|
||||||
</div>
|
|
||||||
</AdminDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
|
function Card({ title, sub, empty, children }: { title: string; sub: string; empty: boolean; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|
@ -248,12 +169,7 @@ export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, can
|
||||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
|
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.uan ?? "—"}</td>
|
||||||
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
|
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{e.aadhaarLast4 ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600">{e.pfNumber ?? "—"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} /></td>
|
||||||
<div className="flex flex-col items-end gap-1.5">
|
|
||||||
<EpfoAssist crewMemberId={e.crewMemberId} uan={e.uan} />
|
|
||||||
<Actions onVerify={() => verifyBankEpf(e.crewMemberId, "epf", true)} onReject={(r) => verifyBankEpf(e.crewMemberId, "epf", false, r)} />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
|
||||||
import type { AccountGroup } from "@/app/(portal)/po/new/new-po-form";
|
|
||||||
|
|
||||||
const STATUSES = [
|
const STATUSES = [
|
||||||
{ value: "DRAFT", label: "Draft" },
|
{ value: "DRAFT", label: "Draft" },
|
||||||
|
|
@ -21,25 +19,17 @@ const STATUSES = [
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
vessels: { id: string; name: string }[];
|
||||||
accounts: AccountGroup[];
|
|
||||||
perPageOptions: number[];
|
|
||||||
defaultPerPage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPage }: Props) {
|
export function HistoryFilters({ vessels }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
|
|
||||||
? Number(sp.get("perPage"))
|
|
||||||
: defaultPerPage;
|
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
|
||||||
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
const [approvedTo, setApprovedTo] = useState(sp.get("approvedTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
||||||
const [accountId, setAccountId] = useState(sp.get("accountId") ?? "");
|
|
||||||
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
const [statuses, setStatuses] = useState<string[]>(sp.getAll("status"));
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const statusRef = useRef<HTMLDivElement>(null);
|
const statusRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -60,37 +50,23 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Changing any filter resets to page 1; perPage is preserved across applies.
|
function apply() {
|
||||||
function buildParams(nextPerPage: number) {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
if (approvedFrom) params.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
if (accountId) params.set("accountId", accountId);
|
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
router.push(`/history?${params.toString()}`);
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
function apply() {
|
|
||||||
router.push(`/history?${buildParams(perPage).toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePerPage(next: number) {
|
|
||||||
router.push(`/history?${buildParams(next).toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setAccountId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||||
const params = new URLSearchParams();
|
router.push("/history");
|
||||||
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 || accountId || statuses.length > 0;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||||
|
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
statuses.length === 0
|
statuses.length === 0
|
||||||
|
|
@ -130,16 +106,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Accounting Code</label>
|
|
||||||
<SearchableSelect
|
|
||||||
name="accountId"
|
|
||||||
value={accountId}
|
|
||||||
onChange={setAccountId}
|
|
||||||
groups={accounts}
|
|
||||||
placeholder="All accounting codes"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative" ref={statusRef}>
|
<div className="relative" ref={statusRef}>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Status</label>
|
||||||
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
<button type="button" onClick={() => setStatusOpen((o) => !o)}
|
||||||
|
|
@ -173,13 +139,6 @@ export function HistoryFilters({ vessels, accounts, perPageOptions, defaultPerPa
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,17 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { HistoryFilters } from "./history-filters";
|
import { HistoryFilters } from "./history-filters";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
|
||||||
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
|
||||||
import { resolvePagination } from "@/lib/pagination";
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "History" };
|
export const metadata: Metadata = { title: "History" };
|
||||||
|
|
||||||
const PER_PAGE_OPTIONS = [25, 50, 100];
|
|
||||||
const DEFAULT_PER_PAGE = 25;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
|
|
@ -24,10 +19,7 @@ interface Props {
|
||||||
approvedFrom?: string;
|
approvedFrom?: string;
|
||||||
approvedTo?: string;
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
accountId?: string;
|
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
page?: string;
|
|
||||||
perPage?: string;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,77 +27,51 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
// Report-export holders see History; submitters get read+export access when the
|
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
||||||
// submitter-view-all feature flag is on.
|
|
||||||
if (
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
||||||
!hasPermission(session.user.role, "export_reports") &&
|
|
||||||
!submitterCanViewAll(session.user.role)
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
) {
|
if (dateFrom || dateTo) {
|
||||||
redirect("/dashboard");
|
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
||||||
|
if (dateTo) {
|
||||||
|
const end = new Date(dateTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
createdAt.lt = end;
|
||||||
}
|
}
|
||||||
|
where.createdAt = createdAt;
|
||||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, status, page: pageParam, perPage: perPageParam } =
|
}
|
||||||
await searchParams;
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
|
if (vesselId) where.vesselId = vesselId;
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
const where = await buildPoHistoryWhere({
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
dateFrom, dateTo, approvedFrom, approvedTo, vesselId, accountId, statuses,
|
|
||||||
});
|
|
||||||
|
|
||||||
const total = await db.purchaseOrder.count({ where });
|
const [orders, vessels] = await Promise.all([
|
||||||
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
|
||||||
perPageParam,
|
|
||||||
pageParam,
|
|
||||||
total,
|
|
||||||
options: PER_PAGE_OPTIONS,
|
|
||||||
defaultPerPage: DEFAULT_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [orders, vessels, leafAccounts] = await Promise.all([
|
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, account: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
skip,
|
take: 200,
|
||||||
take,
|
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
db.account.findMany({
|
|
||||||
where: { isActive: true, children: { none: {} } },
|
|
||||||
orderBy: { code: "asc" },
|
|
||||||
select: { id: true, code: true, name: true, parent: { select: { name: true, code: true, parent: { select: { name: true, code: true } } } } },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
if (accountId) pageParams.set("accountId", accountId);
|
|
||||||
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" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
if (approvedFrom) exportParams.set("approvedFrom", approvedFrom);
|
||||||
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
if (approvedTo) exportParams.set("approvedTo", approvedTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (vesselId) exportParams.set("vesselId", vesselId);
|
||||||
if (accountId) exportParams.set("accountId", accountId);
|
|
||||||
for (const s of statuses) exportParams.append("status", s);
|
for (const s of statuses) exportParams.append("status", s);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,7 +97,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<HistoryFilters vessels={vessels} accounts={accounts} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
<HistoryFilters vessels={vessels} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
@ -141,7 +107,6 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">PO Number</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Title</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Cost Centre</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Accounting Code</th>
|
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Submitter</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Amount</th>
|
||||||
|
|
@ -161,9 +126,6 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">
|
|
||||||
<span className="font-mono text-xs text-neutral-400">{po.account.code}</span> {po.account.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
|
|
@ -180,41 +142,8 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{total > 0 && (
|
{orders.length === 200 && (
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
|
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results — refine filters to narrow results.</p>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ export function CartView() {
|
||||||
<p className="text-neutral-500 font-medium">Your cart is empty</p>
|
<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>
|
<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">
|
<div className="flex gap-3 justify-center">
|
||||||
<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="/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="/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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -108,7 +108,7 @@ export function CartView() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<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">
|
<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">
|
||||||
+ Add more items
|
+ Add more items
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { site: siteId } = await searchParams;
|
const { site: siteId } = await searchParams;
|
||||||
const baseHref = `/catalogue/items/${id}`;
|
const baseHref = `/inventory/items/${id}`;
|
||||||
|
|
||||||
const [product, sites] = await Promise.all([
|
const [product, sites] = await Promise.all([
|
||||||
db.product.findUnique({
|
db.product.findUnique({
|
||||||
|
|
@ -85,7 +85,7 @@ export default async function ItemDetailPage({ params, searchParams }: Props) {
|
||||||
<div className="max-w-6xl space-y-6">
|
<div className="max-w-6xl space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<Link href="/catalogue/items" className="hover:text-neutral-700">Items</Link>
|
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
<span className="text-neutral-900 font-medium">{product.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -108,7 +108,7 @@ export function ItemsTable({
|
||||||
value={currentSiteId ?? ""}
|
value={currentSiteId ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const id = e.target.value;
|
const id = e.target.value;
|
||||||
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
|
router.push(id ? `/inventory/items?siteId=${id}` : "/inventory/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"
|
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">
|
<td className="px-12 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/catalogue/vendors/${vendor.vendorId}`}
|
href={`/inventory/vendors/${vendor.vendorId}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
className="font-medium text-neutral-800 hover:text-primary-600 hover:underline"
|
||||||
>
|
>
|
||||||
|
|
@ -20,7 +20,7 @@ export default async function InventoryItemsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/items
|
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
|
||||||
const canManage = hasPermission(session.user.role, "manage_products");
|
const canManage = hasPermission(session.user.role, "manage_products");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +48,7 @@ export default async function InventoryVendorDetailPage({ params }: Props) {
|
||||||
<div className="max-w-5xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<Link href="/catalogue/vendors" className="hover:text-neutral-700">Vendors</Link>
|
<Link href="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-neutral-900 font-medium">{vendor.name}</span>
|
<span className="text-neutral-900 font-medium">{vendor.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -68,7 +68,7 @@ export function VendorsTable({
|
||||||
value={currentSiteId ?? ""}
|
value={currentSiteId ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const id = e.target.value;
|
const id = e.target.value;
|
||||||
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
|
router.push(id ? `/inventory/vendors?siteId=${id}` : "/inventory/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"
|
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">
|
<tr key={vendor.id} className="hover:bg-neutral-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||||
{vendor.name}
|
{vendor.name}
|
||||||
</Link>
|
</Link>
|
||||||
{vendor.vendorId && (
|
{vendor.vendorId && (
|
||||||
|
|
@ -4,12 +4,107 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { canPerformAction } from "@/lib/po-state-machine";
|
import { canPerformAction } from "@/lib/po-state-machine";
|
||||||
import { processPaymentSchema } from "@/lib/validations/po";
|
import { processPaymentSchema } from "@/lib/validations/po";
|
||||||
import { syncProductCatalog } from "@/lib/product-catalog";
|
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
type ActionResult = { ok: true } | { error: string };
|
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
|
// Step 1: Accounts picks up the PO — MGR_APPROVED → SENT_FOR_PAYMENT
|
||||||
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
|
export async function processPayment({ poId }: { poId: string }): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
|
||||||
|
|
@ -98,25 +98,12 @@ export default async function PaymentsPage() {
|
||||||
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
|
Paid {formatCurrency(Number(po.paidAmount), po.currency)} of {formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<PaymentActions
|
<PaymentActions
|
||||||
poId={po.id}
|
poId={po.id}
|
||||||
poStatus={po.status}
|
poStatus={po.status}
|
||||||
totalAmount={Number(po.totalAmount)}
|
totalAmount={Number(po.totalAmount)}
|
||||||
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
|
paidAmount={po.paidAmount != null ? Number(po.paidAmount) : 0}
|
||||||
suggestedAdvancePayment={
|
|
||||||
po.suggestedAdvancePayment != null ? Number(po.suggestedAdvancePayment) : null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ interface Props {
|
||||||
poStatus: POStatus;
|
poStatus: POStatus;
|
||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
paidAmount?: 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)
|
// Today's date as a local yyyy-mm-dd string (for <input type="date"> default + max)
|
||||||
|
|
@ -22,33 +19,15 @@ function todayLocal(): string {
|
||||||
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
|
return new Date(d.getTime() - off * 60_000).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentActions({
|
export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
|
||||||
poId,
|
|
||||||
poStatus,
|
|
||||||
totalAmount = 0,
|
|
||||||
paidAmount = 0,
|
|
||||||
suggestedAdvancePayment = null,
|
|
||||||
}: Props) {
|
|
||||||
const router = useRouter();
|
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 [ref, setRef] = useState("");
|
||||||
const [amount, setAmount] = useState<string>(advancePrefill);
|
const [amount, setAmount] = useState<string>("");
|
||||||
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
const [paymentDate, setPaymentDate] = useState<string>(todayLocal());
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const remaining = totalAmount - paidAmount;
|
||||||
const today = todayLocal();
|
const today = todayLocal();
|
||||||
|
|
||||||
async function handleProcessPayment() {
|
async function handleProcessPayment() {
|
||||||
|
|
@ -141,11 +120,6 @@ export function PaymentActions({
|
||||||
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"
|
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>
|
</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>}
|
{error && <span className="text-xs text-danger-700">{error}</span>}
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
{isPartialPayment && (
|
{isPartialPayment && (
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { createPoSchema } from "@/lib/validations/po";
|
import { createPoSchema } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
|
@ -72,11 +71,6 @@ export async function updatePo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
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(
|
const total = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
0
|
0
|
||||||
|
|
@ -162,7 +156,6 @@ export async function updatePo(
|
||||||
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
||||||
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||||||
tcOthers: data.tcOthers ?? null,
|
tcOthers: data.tcOthers ?? null,
|
||||||
terms,
|
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
|
status: shouldSubmit ? "MGR_REVIEW" : "DRAFT",
|
||||||
submittedAt: shouldSubmit ? new Date() : po.submittedAt,
|
submittedAt: shouldSubmit ? new Date() : po.submittedAt,
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,8 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
|
||||||
import { ProjectCodeField } from "@/components/po/project-code-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 type { LineItemInput } from "@/lib/validations/po";
|
||||||
|
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||||
|
|
||||||
const INPUT_CLS =
|
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";
|
"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";
|
||||||
|
|
@ -45,14 +40,10 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
|
||||||
projectCodeOptions: string[];
|
|
||||||
termsCatalogue: CatalogueCategory[];
|
|
||||||
initialTerms: PoTerm[];
|
|
||||||
managerNoteAuthor?: string | null;
|
managerNoteAuthor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
|
export function EditPoForm({ po, vessels, accounts, vendors, companies, managerNoteAuthor }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
po.lineItems.map((li) => ({
|
||||||
|
|
@ -71,9 +62,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
||||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
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 canSubmit = po.status === "DRAFT";
|
||||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||||
|
|
@ -84,7 +72,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const form = document.getElementById("edit-po-form") as HTMLFormElement;
|
const form = document.getElementById("edit-po-form") as HTMLFormElement;
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
data.set("intent", intent);
|
data.set("intent", intent);
|
||||||
data.set("termsJson", JSON.stringify(terms));
|
|
||||||
lineItems.forEach((item, i) => {
|
lineItems.forEach((item, i) => {
|
||||||
data.set(`lineItems[${i}].name`, item.name);
|
data.set(`lineItems[${i}].name`, item.name);
|
||||||
data.set(`lineItems[${i}].description`, item.description ?? "");
|
data.set(`lineItems[${i}].description`, item.description ?? "");
|
||||||
|
|
@ -101,7 +88,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
setSubmitting(null);
|
setSubmitting(null);
|
||||||
} else {
|
} else {
|
||||||
setDirty(false); // saved — don't warn on the redirect
|
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -122,7 +108,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
const extPo = po;
|
const extPo = po;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||||
{canResubmit && (
|
{canResubmit && (
|
||||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||||
<p className="text-sm font-medium text-warning-700">
|
<p className="text-sm font-medium text-warning-700">
|
||||||
|
|
@ -186,7 +172,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
onChange={setDefaultAccountId}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -199,7 +185,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<ProjectCodeField options={projectCodeOptions} current={po.projectCode} className={INPUT_CLS} />
|
<input name="projectCode" defaultValue={po.projectCode ?? ""} className={INPUT_CLS} placeholder="Optional" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
|
@ -243,7 +229,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT_CLS} />
|
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -252,7 +238,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={(v) => { setLineItems(v); markDirty(); }}
|
onChange={setLineItems}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -262,14 +248,54 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Vendor</h2>
|
||||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Terms & Conditions */}
|
{/* Terms & Conditions */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms & Conditions</h2>
|
||||||
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
<div className="space-y-3">
|
||||||
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -298,12 +324,6 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, delivery
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnsavedChangesGuard
|
|
||||||
enabled={dirty && !submitting}
|
|
||||||
onSaveDraft={() => handleSubmit("save")}
|
|
||||||
saving={submitting === "save"}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@ import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { EditPoForm } from "./edit-po-form";
|
import { EditPoForm } from "./edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
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 { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -32,7 +29,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes, noteAction] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -41,8 +38,6 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
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.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 } } } }),
|
|
||||||
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
|
||||||
po.status === "EDITS_REQUESTED"
|
po.status === "EDITS_REQUESTED"
|
||||||
? db.pOAction.findFirst({
|
? db.pOAction.findFirst({
|
||||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||||
|
|
@ -53,11 +48,6 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
|
||||||
const projectCodeOptions = projectCodes.map((c) => c.code);
|
|
||||||
const termsCatalogue = await getTermsCatalogue();
|
|
||||||
const savedTerms = parsePoTerms(po.terms);
|
|
||||||
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -83,10 +73,6 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
|
||||||
projectCodeOptions={projectCodeOptions}
|
|
||||||
termsCatalogue={termsCatalogue}
|
|
||||||
initialTerms={initialTerms}
|
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
"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 };
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
import { canViewAllPos } from "@/lib/permissions";
|
|
||||||
import { VendorIdForm } from "./vendor-id-form";
|
import { VendorIdForm } from "./vendor-id-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -28,7 +27,7 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
account: true,
|
account: true,
|
||||||
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
vendor: true,
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
documents: { orderBy: { uploadedAt: "desc" } },
|
documents: { orderBy: { uploadedAt: "desc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
|
|
@ -40,11 +39,11 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
|
|
||||||
// Submitters can only view their own POs — unless they hold view_all_pos, or the
|
// Submitters can only view their own POs (unless they have view_all_pos)
|
||||||
// submitter-view-all feature flag grants them read access to every PO.
|
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
|
||||||
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
|
session.user.role
|
||||||
redirect("/dashboard");
|
);
|
||||||
}
|
if (!canViewAll && po.submitterId !== session.user.id) redirect("/dashboard");
|
||||||
|
|
||||||
const canProvideVendorId =
|
const canProvideVendorId =
|
||||||
po.status === "VENDOR_ID_PENDING" &&
|
po.status === "VENDOR_ID_PENDING" &&
|
||||||
|
|
@ -57,11 +56,9 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
? await db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } })
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const vendorEmail = po.vendor?.contacts?.[0]?.email ?? null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl space-y-6">
|
<div className="max-w-6xl space-y-6">
|
||||||
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} vendorEmail={vendorEmail} />
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} />
|
||||||
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ export async function confirmReceipt({
|
||||||
if (newStatus === "CLOSED" && po.vendorId) {
|
if (newStatus === "CLOSED" && po.vendorId) {
|
||||||
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
|
await db.vendor.update({ where: { id: po.vendorId }, data: { isVerified: true } });
|
||||||
revalidatePath("/admin/vendors");
|
revalidatePath("/admin/vendors");
|
||||||
revalidatePath("/catalogue/vendors");
|
revalidatePath("/inventory/vendors");
|
||||||
}
|
}
|
||||||
|
|
||||||
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
const managers = await db.user.findMany({ where: { role: "MANAGER", isActive: true } });
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ export async function importPo(
|
||||||
if (resolvedVendorId) {
|
if (resolvedVendorId) {
|
||||||
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
|
await db.vendor.update({ where: { id: resolvedVendorId }, data: { isVerified: true } });
|
||||||
revalidatePath("/admin/vendors");
|
revalidatePath("/admin/vendors");
|
||||||
revalidatePath("/catalogue/vendors");
|
revalidatePath("/inventory/vendors");
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/history");
|
revalidatePath("/history");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { requirePermission } from "@/lib/permissions";
|
import { requirePermission } from "@/lib/permissions";
|
||||||
import { createPoSchema } from "@/lib/validations/po";
|
import { createPoSchema } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
|
||||||
import { generatePoNumber } from "@/lib/po-number";
|
import { generatePoNumber } from "@/lib/po-number";
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
@ -78,11 +77,6 @@ export async function createPo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
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
|
// totalAmount = grand total including GST
|
||||||
const total = data.lineItems.reduce(
|
const total = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
|
|
@ -114,7 +108,6 @@ export async function createPo(
|
||||||
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
tcTransitInsurance: data.tcTransitInsurance ?? null,
|
||||||
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
tcPaymentTerms: data.tcPaymentTerms ?? null,
|
||||||
tcOthers: data.tcOthers ?? null,
|
tcOthers: data.tcOthers ?? null,
|
||||||
terms,
|
|
||||||
submitterId: session.user.id,
|
submitterId: session.user.id,
|
||||||
submittedAt: intent === "submit" ? new Date() : null,
|
submittedAt: intent === "submit" ? new Date() : null,
|
||||||
lineItems: {
|
lineItems: {
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,9 @@ import type { Vendor } from "@prisma/client";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
import { VendorSelect } from "@/components/ui/vendor-select";
|
|
||||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
|
||||||
import { ProjectCodeField } from "@/components/po/project-code-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 { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
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 VesselOption = { id: string; code: string; name: string };
|
||||||
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
export type AccountGroup = { group: string; items: { id: string; code: string; name: string }[] };
|
||||||
|
|
@ -30,29 +25,23 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
deliveryOptions: string[];
|
|
||||||
projectCodeOptions: string[];
|
|
||||||
termsCatalogue: CatalogueCategory[];
|
|
||||||
defaultTerms: PoTerm[];
|
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
initialVesselId?: string;
|
initialVesselId?: string;
|
||||||
initialCompanyId?: string;
|
initialCompanyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, projectCodeOptions, termsCatalogue, defaultTerms, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
export function NewPoForm({ vessels, accounts, vendors, companies, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||||
);
|
);
|
||||||
|
const [vendorId, setVendorId] = useState(initialVendorId ?? "");
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [multiAccount, setMultiAccount] = useState(false);
|
const [multiAccount, setMultiAccount] = useState(false);
|
||||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
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") {
|
async function handleSubmit(intent: "draft" | "submit") {
|
||||||
setSubmitting(intent);
|
setSubmitting(intent);
|
||||||
|
|
@ -60,7 +49,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
const form = document.getElementById("po-form") as HTMLFormElement;
|
const form = document.getElementById("po-form") as HTMLFormElement;
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
data.set("intent", intent);
|
data.set("intent", intent);
|
||||||
data.set("termsJson", JSON.stringify(terms));
|
|
||||||
lineItems.forEach((item, i) => {
|
lineItems.forEach((item, i) => {
|
||||||
data.set(`lineItems[${i}].name`, item.name);
|
data.set(`lineItems[${i}].name`, item.name);
|
||||||
data.set(`lineItems[${i}].description`, item.description ?? "");
|
data.set(`lineItems[${i}].description`, item.description ?? "");
|
||||||
|
|
@ -87,12 +75,11 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDirty(false); // saved — don't warn on the redirect
|
|
||||||
router.push(`/po/${result.id}`);
|
router.push(`/po/${result.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
||||||
{/* Order Information */}
|
{/* Order Information */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Order Information</h2>
|
||||||
|
|
@ -149,7 +136,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
onChange={setDefaultAccountId}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -163,12 +150,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Project Code</label>
|
||||||
<ProjectCodeField options={projectCodeOptions} className={INPUT_CLS} />
|
<input name="projectCode" className={INPUT_CLS} placeholder="Optional" />
|
||||||
{projectCodeOptions.length === 0 && (
|
|
||||||
<p className="mt-1.5 text-xs text-neutral-500">
|
|
||||||
No project codes configured yet — a Manager can add them under Administration → Project Codes.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Delivery Date Required</label>
|
||||||
|
|
@ -212,12 +194,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
<textarea
|
||||||
{deliveryOptions.length === 0 && (
|
name="placeOfDelivery"
|
||||||
<p className="mt-1.5 text-xs text-neutral-500">
|
rows={2}
|
||||||
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
className={INPUT_CLS}
|
||||||
</p>
|
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
||||||
)}
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -226,7 +208,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={(v) => { setLineItems(v); markDirty(); }}
|
onChange={setLineItems}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -240,21 +222,57 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Vendor (optional — can be added later)
|
Vendor (optional — can be added later)
|
||||||
</label>
|
</label>
|
||||||
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Terms & Conditions */}
|
{/* Terms & Conditions */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Terms & 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 & Conditions.</p>
|
<div className="space-y-3">
|
||||||
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
<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>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Attachments (optional)</h2>
|
||||||
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
|
<FileUploader files={files} onChange={setFiles} disabled={!!submitting} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -279,12 +297,6 @@ export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptio
|
||||||
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
{submitting === "submit" ? "Submitting…" : "Submit for Approval"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UnsavedChangesGuard
|
|
||||||
enabled={dirty && !submitting}
|
|
||||||
onSaveDraft={() => handleSubmit("draft")}
|
|
||||||
saving={submitting === "draft"}
|
|
||||||
/>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { NewPoForm } from "./new-po-form";
|
import { NewPoForm } from "./new-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
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 { Metadata } from "next";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { CartItem } from "@/lib/cart";
|
import type { CartItem } from "@/lib/cart";
|
||||||
|
|
@ -48,7 +46,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations, projectCodes] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
where: { isActive: true, children: { none: {} } },
|
||||||
|
|
@ -57,14 +55,9 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
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.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 } } } }),
|
|
||||||
db.projectCode.findMany({ where: { isActive: true }, orderBy: { code: "asc" }, select: { code: true } }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
|
||||||
const projectCodeOptions = projectCodes.map((c) => c.code);
|
|
||||||
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
|
|
@ -79,10 +72,6 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
deliveryOptions={deliveryOptions}
|
|
||||||
projectCodeOptions={projectCodeOptions}
|
|
||||||
termsCatalogue={termsCatalogue}
|
|
||||||
defaultTerms={defaultTerms}
|
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
initialVesselId={initialVesselId}
|
||||||
|
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
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,
|
|
||||||
periodRange,
|
|
||||||
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}`;
|
|
||||||
// Drill into the POs behind this spend: PO History filtered to this accounting
|
|
||||||
// code (expanded to its leaves) over the period in view (dated by approvedAt).
|
|
||||||
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
|
||||||
const poListHref = `/history?accountId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<Link
|
|
||||||
href={node.parentId ? `/reports/accounting-codes?fy=${fy}&gran=${gran}&parent=${node.parentId}` : `/reports/accounting-codes?fy=${fy}&gran=${gran}`}
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700"
|
|
||||||
>
|
|
||||||
← Back to Accounting Codes
|
|
||||||
</Link>
|
|
||||||
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
|
||||||
View POs · {periodLabel} →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
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,
|
|
||||||
periodRange,
|
|
||||||
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}`;
|
|
||||||
// Drill into the POs behind this spend: PO History filtered to this cost centre
|
|
||||||
// over the period currently in view (spend is dated by approvedAt).
|
|
||||||
const { from, to } = periodRange(gran, fy, month, ds.fys);
|
|
||||||
const poListHref = `/history?vesselId=${id}&approvedFrom=${from}&approvedTo=${to}`;
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<Link href={`/reports/cost-centres?fy=${fy}&gran=${gran}`} className="inline-flex items-center gap-1.5 text-sm font-medium text-primary-600 hover:text-primary-700">
|
|
||||||
← Back to Cost Centres
|
|
||||||
</Link>
|
|
||||||
<Link href={poListHref} className="inline-flex items-center gap-1.5 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition-colors">
|
|
||||||
View POs · {periodLabel} →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { hasPermission } from "@/lib/permissions";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
|
|
||||||
|
|
||||||
/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
if (!hasPermission(session.user.role, "verify_bank_epf")) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
|
||||||
if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${EPFO_SERVICE}/otp`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ uan: body.uan }),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { hasPermission } from "@/lib/permissions";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
|
|
||||||
|
|
||||||
/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
||||||
if (!hasPermission(session.user.role, "verify_bank_epf")) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
|
||||||
if (!body.sessionId || !body.uan || !body.otp) {
|
|
||||||
return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${EPFO_SERVICE}/verify`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }),
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
|
||||||
} catch (e) {
|
|
||||||
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,12 +3,10 @@ import { db } from "@/lib/db";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import ExcelJS from "exceljs";
|
import ExcelJS from "exceljs";
|
||||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
|
||||||
import { downloadBuffer } from "@/lib/storage";
|
import { downloadBuffer } from "@/lib/storage";
|
||||||
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
||||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||||
import { canViewAllPos } from "@/lib/permissions";
|
|
||||||
|
|
||||||
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
||||||
|
|
||||||
|
|
@ -52,14 +50,8 @@ async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage
|
||||||
interface Props { params: Promise<{ id: string }> }
|
interface Props { params: Promise<{ id: string }> }
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: Props) {
|
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();
|
const session = await auth();
|
||||||
if (!session?.user && !isService) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
|
@ -74,13 +66,10 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
});
|
});
|
||||||
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
if (!isService) {
|
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role);
|
||||||
// view_all_pos holders, or submitters when the view-all feature flag is on, may export
|
if (!canViewAll && po.submitterId !== session.user.id) {
|
||||||
// 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 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
||||||
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
||||||
|
|
@ -95,9 +84,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = request.nextUrl.searchParams.get("format") ?? "pdf";
|
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) ──────────
|
// ── Company data (from linked company, or fallback to constants) ──────────
|
||||||
const co = po.company;
|
const co = po.company;
|
||||||
|
|
@ -183,13 +169,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const reqDate = fmtDate(ext.requisitionDate);
|
const reqDate = fmtDate(ext.requisitionDate);
|
||||||
const delivery = ext.placeOfDelivery ?? "";
|
const delivery = ext.placeOfDelivery ?? "";
|
||||||
|
|
||||||
// T&C (issue #11): prefer the dynamic snapshot (po.terms) when present; older
|
const tcLines: [number, string, string][] = [
|
||||||
// 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],
|
[1, "", TC_FIXED_LINE],
|
||||||
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
|
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
|
||||||
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
|
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
|
||||||
|
|
@ -755,11 +735,11 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
|
|
||||||
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
||||||
|
|
||||||
${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
|
<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">
|
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
||||||
🖨 Print / Save as PDF
|
🖨 Print / Save as PDF
|
||||||
</button>
|
</button>
|
||||||
</div>`}
|
</div>
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||||
<div class="header-band">
|
<div class="header-band">
|
||||||
|
|
@ -908,7 +888,7 @@ ${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
|
||||||
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
<!-- ── Brand bar ─────────────────────────────────────────────── -->
|
||||||
<div class="brand-bar"></div>
|
<div class="brand-bar"></div>
|
||||||
|
|
||||||
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`}
|
<script>window.onload = function() { window.print(); };</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission, submitterCanViewAll } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { buildPoHistoryWhere } from "@/lib/history-filter";
|
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { POStatus } from "@prisma/client";
|
||||||
|
|
||||||
const PO_STATUS_LABELS: Record<string, string> = {
|
const PO_STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||||
|
|
@ -16,25 +16,42 @@ export async function GET(request: NextRequest) {
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
if (
|
if (!hasPermission(session.user.role, "export_reports")) {
|
||||||
!hasPermission(session.user.role, "export_reports") &&
|
|
||||||
!submitterCanViewAll(session.user.role)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sp = request.nextUrl.searchParams;
|
const sp = request.nextUrl.searchParams;
|
||||||
const format = sp.get("format") ?? "csv";
|
const format = sp.get("format") ?? "csv";
|
||||||
|
const dateFrom = sp.get("dateFrom");
|
||||||
|
const dateTo = sp.get("dateTo");
|
||||||
|
const approvedFrom = sp.get("approvedFrom");
|
||||||
|
const approvedTo = sp.get("approvedTo");
|
||||||
|
const vesselId = sp.get("vesselId");
|
||||||
|
const statuses = sp.getAll("status").filter(Boolean);
|
||||||
|
|
||||||
const where = await buildPoHistoryWhere({
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
dateFrom: sp.get("dateFrom"),
|
if (dateFrom || dateTo) {
|
||||||
dateTo: sp.get("dateTo"),
|
const createdAt: { gte?: Date; lt?: Date } = {};
|
||||||
approvedFrom: sp.get("approvedFrom"),
|
if (dateFrom) createdAt.gte = new Date(dateFrom);
|
||||||
approvedTo: sp.get("approvedTo"),
|
if (dateTo) {
|
||||||
vesselId: sp.get("vesselId"),
|
const end = new Date(dateTo);
|
||||||
accountId: sp.get("accountId"),
|
end.setDate(end.getDate() + 1);
|
||||||
statuses: sp.getAll("status"),
|
createdAt.lt = end;
|
||||||
});
|
}
|
||||||
|
where.createdAt = createdAt;
|
||||||
|
}
|
||||||
|
if (approvedFrom || approvedTo) {
|
||||||
|
const approvedAt: { gte?: Date; lt?: Date } = {};
|
||||||
|
if (approvedFrom) approvedAt.gte = new Date(approvedFrom);
|
||||||
|
if (approvedTo) {
|
||||||
|
const end = new Date(approvedTo);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
approvedAt.lt = end;
|
||||||
|
}
|
||||||
|
where.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
|
if (vesselId) where.vesselId = vesselId;
|
||||||
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
|
@ -34,10 +33,6 @@ import {
|
||||||
UserCog,
|
UserCog,
|
||||||
Gauge,
|
Gauge,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Truck,
|
|
||||||
FolderKanban,
|
|
||||||
ScrollText,
|
|
||||||
ChevronRight,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -48,59 +43,38 @@ interface NavItem {
|
||||||
roles?: Role[];
|
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[] = [
|
const NAV_ITEMS: NavItem[] = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ 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: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
|
||||||
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
|
||||||
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
|
{ 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 },
|
{ href: "/profile", label: "My Profile", icon: UserCircle },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Purchasing section ────────────────────────────────────────────────────────
|
// ── 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)
|
// Staff browsing items (product catalogue + cart for PO creation)
|
||||||
const PURCHASING_STAFF: NavItem[] = [
|
const PURCHASING_STAFF: NavItem[] = [
|
||||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
|
||||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Manager catalogue management — Sites conditionally shown
|
// Manager catalogue management — Sites conditionally shown
|
||||||
// Admin does not use Purchasing; their links live under Administration
|
// Admin does not use Purchasing; their links live under Administration
|
||||||
const PURCHASING_MGMT: NavItem[] = [
|
const PURCHASING_MGMT: NavItem[] = [
|
||||||
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||||
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||||
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
||||||
...(INVENTORY_ENABLED
|
...(INVENTORY_ENABLED
|
||||||
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
const PURCHASING_ITEMS: NavItem[] = [...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) ─────────────────────────────────────────
|
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||||
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
|
||||||
|
|
@ -123,9 +97,6 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
||||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "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/project-codes", label: "Project Codes", icon: FolderKanban, 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 reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||||
...(CREWING_ENABLED
|
...(CREWING_ENABLED
|
||||||
? [
|
? [
|
||||||
|
|
@ -146,60 +117,14 @@ const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/companies", label: "Companies", icon: Briefcase },
|
{ 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 }) {
|
export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAdmin = userRole === "ADMIN";
|
const isAdmin = userRole === "ADMIN";
|
||||||
|
|
||||||
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole);
|
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
const visibleMain = NAV_ITEMS.filter(visible);
|
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
const visiblePurchasing = PURCHASING_ITEMS.filter(visible);
|
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
const visibleReports = REPORTS_PURCHASING.filter(visible);
|
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||||
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 (
|
return (
|
||||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
||||||
|
|
@ -215,70 +140,59 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{sections.map((section) => {
|
{visiblePurchasing.length > 0 && (
|
||||||
const isOpen = openSection === section.id;
|
<>
|
||||||
const regionId = `nav-section-${section.id}`;
|
<SectionHeader label="Purchasing" />
|
||||||
return (
|
{visiblePurchasing.map((item) => (
|
||||||
<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} />
|
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* 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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({
|
function SectionHeader({ label }: { label: string }) {
|
||||||
label,
|
|
||||||
isOpen,
|
|
||||||
regionId,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
isOpen: boolean;
|
|
||||||
regionId: string;
|
|
||||||
onToggle: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="pt-4 pb-1 px-3">
|
||||||
type="button"
|
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">{label}</p>
|
||||||
onClick={onToggle}
|
</div>
|
||||||
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 }) {
|
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||||
const isActive = isItemActive(item.href, pathname);
|
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -4,12 +4,10 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||||
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
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 { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||||
import { generateDownloadUrl } from "@/lib/storage";
|
import { generateDownloadUrl } from "@/lib/storage";
|
||||||
import { groupAttachments } from "@/lib/attachments";
|
import { groupAttachments } from "@/lib/attachments";
|
||||||
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import { parsePoTerms } from "@/lib/terms";
|
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
|
@ -27,7 +25,6 @@ type PoWithRelations = {
|
||||||
paymentRef: string | null;
|
paymentRef: string | null;
|
||||||
paymentDate?: Date | null;
|
paymentDate?: Date | null;
|
||||||
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
paidAmount?: import("@prisma/client").Prisma.Decimal | null;
|
||||||
suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null;
|
|
||||||
piQuotationNo?: string | null;
|
piQuotationNo?: string | null;
|
||||||
piQuotationDate?: Date | null;
|
piQuotationDate?: Date | null;
|
||||||
requisitionNo?: string | null;
|
requisitionNo?: string | null;
|
||||||
|
|
@ -39,7 +36,6 @@ type PoWithRelations = {
|
||||||
tcTransitInsurance?: string | null;
|
tcTransitInsurance?: string | null;
|
||||||
tcPaymentTerms?: string | null;
|
tcPaymentTerms?: string | null;
|
||||||
tcOthers?: string | null;
|
tcOthers?: string | null;
|
||||||
terms?: import("@prisma/client").Prisma.JsonValue;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
submittedAt: Date | null;
|
submittedAt: Date | null;
|
||||||
approvedAt: Date | null;
|
approvedAt: Date | null;
|
||||||
|
|
@ -83,8 +79,6 @@ interface Props {
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
currentRole: Role;
|
currentRole: Role;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
|
|
||||||
vendorEmail?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -107,7 +101,7 @@ const ACTION_LABELS: Record<string, string> = {
|
||||||
SUPERSEDED: "Superseded",
|
SUPERSEDED: "Superseded",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) {
|
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||||
const lineItemsForEditor = po.lineItems.map((li) => ({
|
const lineItemsForEditor = po.lineItems.map((li) => ({
|
||||||
name: li.name,
|
name: li.name,
|
||||||
description: li.description ?? undefined,
|
description: li.description ?? undefined,
|
||||||
|
|
@ -233,11 +227,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
Export XLSX
|
Export XLSX
|
||||||
</a>
|
</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 */}
|
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
|
||||||
{po.status !== "CANCELLED" &&
|
{po.status !== "CANCELLED" &&
|
||||||
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
||||||
|
|
@ -301,21 +290,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
</div>
|
</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 */}
|
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
|
||||||
{resubmitSnapshot &&
|
{resubmitSnapshot &&
|
||||||
po.status === "MGR_REVIEW" &&
|
po.status === "MGR_REVIEW" &&
|
||||||
|
|
@ -461,41 +435,31 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */}
|
{/* Terms & Conditions */}
|
||||||
{(() => {
|
{(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
|
||||||
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">
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms & Conditions</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms & Conditions</h3>
|
||||||
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
|
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
|
||||||
{rows.map((r, i) => (
|
<li className="flex gap-2">
|
||||||
<li key={i} className="flex gap-2">
|
<span className="shrink-0 font-medium text-neutral-500">1.</span>
|
||||||
<span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span>
|
<span>{TC_FIXED_LINE}</span>
|
||||||
<span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
|
||||||
{attachmentGroups.length > 0 && (
|
{attachmentGroups.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
/**
|
|
||||||
* Project Code dropdown (issue #124) — a native <select name="projectCode">
|
|
||||||
* sourced from the admin-managed project codes, plus an empty "— none —" option
|
|
||||||
* (the field stays optional). Plain HTML so it works with the forms' native
|
|
||||||
* FormData submission (no client state needed), matching DeliveryLocationField.
|
|
||||||
*
|
|
||||||
* `options` are the active project-code strings (also the stored value).
|
|
||||||
* `current` is the PO's existing project code; if it isn't one of the active
|
|
||||||
* options (legacy / imported / a since-removed code) it is preserved as a
|
|
||||||
* leading "(current)" option so an edit never silently drops it.
|
|
||||||
*/
|
|
||||||
export function ProjectCodeField({
|
|
||||||
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="projectCode" defaultValue={cur} className={className}>
|
|
||||||
<option value="">— none —</option>
|
|
||||||
{currentMissing && <option value={cur}>{cur} (current)</option>}
|
|
||||||
{options.map((code) => (
|
|
||||||
<option key={code} value={code}>
|
|
||||||
{code}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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>;
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,211 +0,0 @@
|
||||||
"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 “{query}”</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Role, SeafarerDocType } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
// PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8).
|
||||||
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
|
// Bank account / EPF identity numbers are full only for Accounts (and SuperUser);
|
||||||
|
|
@ -8,11 +8,6 @@ export function canViewFullBankEpf(role: Role): boolean {
|
||||||
return role === "ACCOUNTS" || role === "SUPERUSER";
|
return role === "ACCOUNTS" || role === "SUPERUSER";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated
|
|
||||||
// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents
|
|
||||||
// (passport, CDC, STCW, COC, medical…) are not number-restricted.
|
|
||||||
const RESTRICTED_DOC_TYPES = new Set<SeafarerDocType>(["AADHAAR", "PAN"]);
|
|
||||||
|
|
||||||
export function canViewSalary(role: Role): boolean {
|
export function canViewSalary(role: Role): boolean {
|
||||||
// Office roles see salary; site staff see status only (§6, R7).
|
// Office roles see salary; site staff see status only (§6, R7).
|
||||||
return role !== "SITE_STAFF";
|
return role !== "SITE_STAFF";
|
||||||
|
|
@ -31,18 +26,3 @@ export function bankEpfValue(value: string | null | undefined, role: Role): stri
|
||||||
if (!value) return "—";
|
if (!value) return "—";
|
||||||
return canViewFullBankEpf(role) ? value : maskTail(value);
|
return canViewFullBankEpf(role) ? value : maskTail(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A seafarer document number, masked for non-privileged roles when the document
|
|
||||||
// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass
|
|
||||||
// through unchanged. Preserves the `string | null` contract the profile expects.
|
|
||||||
export function documentNumberValue(
|
|
||||||
value: string | null | undefined,
|
|
||||||
docType: SeafarerDocType,
|
|
||||||
role: Role
|
|
||||||
): string | null {
|
|
||||||
if (!value) return null;
|
|
||||||
if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) {
|
|
||||||
return maskTail(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
|
|
@ -5,12 +5,6 @@
|
||||||
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
||||||
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
|
* 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
|
* 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;
|
* 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)
|
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
|
||||||
|
|
@ -20,8 +14,5 @@
|
||||||
export const INVENTORY_ENABLED =
|
export const INVENTORY_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
|
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 =
|
export const CREWING_ENABLED =
|
||||||
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
/**
|
|
||||||
* Shared `where` builder for the PO History list (`/history` page) and its
|
|
||||||
* CSV/PDF export route, so the two never drift. Filters: created-date range,
|
|
||||||
* approved-date range, cost centre (vessel), status, and — for report
|
|
||||||
* drill-downs (issue #124 review) — an accounting code.
|
|
||||||
*
|
|
||||||
* The `accountId` filter accepts any account-tree node (Heading / Sub-heading /
|
|
||||||
* Leaf); it expands to the leaf codes underneath via `accountLeafIds` and
|
|
||||||
* matches a PO whose **PO-level account** or **any line item account** is in
|
|
||||||
* that leaf set — the same attribution basis the spend reports use.
|
|
||||||
*/
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { accountLeafIds } from "@/lib/reports";
|
|
||||||
import type { POStatus } from "@prisma/client";
|
|
||||||
|
|
||||||
type PoWhere = NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"];
|
|
||||||
|
|
||||||
export interface HistoryFilterParams {
|
|
||||||
dateFrom?: string | null;
|
|
||||||
dateTo?: string | null;
|
|
||||||
approvedFrom?: string | null;
|
|
||||||
approvedTo?: string | null;
|
|
||||||
vesselId?: string | null;
|
|
||||||
accountId?: string | null;
|
|
||||||
statuses?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildPoHistoryWhere(p: HistoryFilterParams): Promise<PoWhere> {
|
|
||||||
const where: NonNullable<PoWhere> = {};
|
|
||||||
|
|
||||||
if (p.dateFrom || p.dateTo) {
|
|
||||||
const createdAt: { gte?: Date; lt?: Date } = {};
|
|
||||||
if (p.dateFrom) createdAt.gte = new Date(p.dateFrom);
|
|
||||||
if (p.dateTo) {
|
|
||||||
const end = new Date(p.dateTo);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
createdAt.lt = end;
|
|
||||||
}
|
|
||||||
where.createdAt = createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.approvedFrom || p.approvedTo) {
|
|
||||||
const approvedAt: { gte?: Date; lt?: Date } = {};
|
|
||||||
if (p.approvedFrom) approvedAt.gte = new Date(p.approvedFrom);
|
|
||||||
if (p.approvedTo) {
|
|
||||||
const end = new Date(p.approvedTo);
|
|
||||||
end.setDate(end.getDate() + 1);
|
|
||||||
approvedAt.lt = end;
|
|
||||||
}
|
|
||||||
where.approvedAt = approvedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.vesselId) where.vesselId = p.vesselId;
|
|
||||||
|
|
||||||
const statuses = (p.statuses ?? []).filter(Boolean);
|
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
|
||||||
|
|
||||||
if (p.accountId) {
|
|
||||||
const accounts = await db.account.findMany({ select: { id: true, parentId: true } });
|
|
||||||
const leaves = accountLeafIds(accounts, p.accountId);
|
|
||||||
where.OR = [
|
|
||||||
{ accountId: { in: leaves } },
|
|
||||||
{ lineItems: { some: { accountId: { in: leaves } } } },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return where;
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
/**
|
|
||||||
* 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());
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
import { SUBMITTER_VIEW_ALL_ENABLED } from "./feature-flags";
|
|
||||||
|
|
||||||
export type Permission =
|
export type Permission =
|
||||||
| "create_po"
|
| "create_po"
|
||||||
|
|
@ -22,9 +21,6 @@ export type Permission =
|
||||||
| "manage_vessels_accounts"
|
| "manage_vessels_accounts"
|
||||||
| "manage_products"
|
| "manage_products"
|
||||||
| "manage_sites"
|
| "manage_sites"
|
||||||
| "manage_delivery_locations"
|
|
||||||
| "manage_project_codes"
|
|
||||||
| "manage_terms"
|
|
||||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||||
| "raise_requisition"
|
| "raise_requisition"
|
||||||
| "request_relief_cover"
|
| "request_relief_cover"
|
||||||
|
|
@ -84,9 +80,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
"manage_delivery_locations",
|
|
||||||
"manage_project_codes",
|
|
||||||
"manage_terms",
|
|
||||||
"confirm_receipt",
|
"confirm_receipt",
|
||||||
"process_payment"
|
"process_payment"
|
||||||
],
|
],
|
||||||
|
|
@ -106,9 +99,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"view_analytics",
|
"view_analytics",
|
||||||
"export_reports",
|
"export_reports",
|
||||||
"create_vendor",
|
"create_vendor",
|
||||||
"manage_delivery_locations",
|
|
||||||
"manage_project_codes",
|
|
||||||
"manage_terms",
|
|
||||||
],
|
],
|
||||||
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
||||||
ADMIN: [
|
ADMIN: [
|
||||||
|
|
@ -122,9 +112,6 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
"manage_delivery_locations",
|
|
||||||
"manage_project_codes",
|
|
||||||
"manage_terms",
|
|
||||||
],
|
],
|
||||||
SITE_STAFF: [],
|
SITE_STAFF: [],
|
||||||
};
|
};
|
||||||
|
|
@ -250,31 +237,3 @@ export function requirePermission(role: Role, permission: Permission): void {
|
||||||
export function getPermissions(role: Role): Permission[] {
|
export function getPermissions(role: Role): Permission[] {
|
||||||
return ROLE_PERMISSIONS[role] ?? [];
|
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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
// 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",
|
|
||||||
];
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
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
|
|
||||||
* Apr–Mar 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 (Apr–Mar): Jan–Mar belong to the prior year. */
|
|
||||||
export function fyStartYear(d: Date): number {
|
|
||||||
return d.getMonth() >= 3 ? d.getFullYear() : d.getFullYear() - 1;
|
|
||||||
}
|
|
||||||
/** "FY 2025–26" 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: 0–4 (W1–W5) 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; // 0–11 within the FY (Apr=0)
|
|
||||||
week: number; // 0–4 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 (Apr–Mar) 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 (W1–W5) 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 (W1–W5) 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 (0–11) 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];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Report → PO drill-down ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Leaf account ids under `accountId` (the node itself when it is already a
|
|
||||||
* leaf), from the raw `{ id, parentId }` account rows. A report drill-down can
|
|
||||||
* target any tier, but a PO / line item only ever carries a leaf code — so this
|
|
||||||
* translates a drilled node into the concrete leaf set PO History filters by.
|
|
||||||
* Returns `[]` for an unknown id.
|
|
||||||
*/
|
|
||||||
export function accountLeafIds(
|
|
||||||
accounts: { id: string; parentId: string | null }[],
|
|
||||||
accountId: string,
|
|
||||||
): string[] {
|
|
||||||
const ids = new Set(accounts.map((a) => a.id));
|
|
||||||
if (!ids.has(accountId)) return [];
|
|
||||||
const kids = new Map<string, string[]>();
|
|
||||||
for (const a of accounts) {
|
|
||||||
if (a.parentId === null) continue;
|
|
||||||
if (!kids.has(a.parentId)) kids.set(a.parentId, []);
|
|
||||||
kids.get(a.parentId)!.push(a.id);
|
|
||||||
}
|
|
||||||
const out: string[] = [];
|
|
||||||
const walk = (id: string) => {
|
|
||||||
const cs = kids.get(id) ?? [];
|
|
||||||
if (cs.length === 0) out.push(id);
|
|
||||||
else cs.forEach(walk);
|
|
||||||
};
|
|
||||||
walk(accountId);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The approved-date window (`from`..`to`, inclusive `YYYY-MM-DD`) a report
|
|
||||||
* detail view currently shows, so drilling into the underlying POs carries
|
|
||||||
* "that period" onto PO History's `approvedFrom`/`approvedTo` (spend is dated by
|
|
||||||
* `approvedAt`). Mirrors the on-screen period label:
|
|
||||||
* - weekly → the focused FY month
|
|
||||||
* - monthly → the whole selected FY (Apr–Mar)
|
|
||||||
* - yearly → the full span of FYs in the dataset
|
|
||||||
*/
|
|
||||||
export function periodRange(
|
|
||||||
gran: Granularity,
|
|
||||||
fy: number,
|
|
||||||
month: number,
|
|
||||||
fys: number[],
|
|
||||||
): { from: string; to: string } {
|
|
||||||
const iso = (y: number, m: number, d: number) =>
|
|
||||||
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
||||||
if (gran === "yearly") {
|
|
||||||
const first = fys[0] ?? fy;
|
|
||||||
const last = fys[fys.length - 1] ?? fy;
|
|
||||||
return { from: iso(first, 4, 1), to: iso(last + 1, 3, 31) };
|
|
||||||
}
|
|
||||||
if (gran === "weekly") {
|
|
||||||
const cal = (month + 3) % 12; // FY-month index (Apr=0) → calendar month 0–11
|
|
||||||
const year = fy + (month >= 9 ? 1 : 0); // Jan–Mar roll into the next calendar year
|
|
||||||
const lastDay = new Date(year, cal + 1, 0).getDate();
|
|
||||||
return { from: iso(year, cal + 1, 1), to: iso(year, cal + 1, lastDay) };
|
|
||||||
}
|
|
||||||
return { from: iso(fy, 4, 1), to: iso(fy + 1, 3, 31) };
|
|
||||||
}
|
|
||||||
|
|
@ -89,9 +89,18 @@ export function getManagerRecipients(): Promise<User[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify the office that a requisition was auto-raised. Call AFTER the
|
/**
|
||||||
* creating transaction commits (notifications are not part of the atomic write). */
|
* System auto-raise: an OPEN requisition with no human actor (autoRaised), then
|
||||||
export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promise<void> {
|
* notifies the office. Sign-off, end-of-contract and the leave-clash detector
|
||||||
|
* (later phases) all funnel through here. See spec §5.2/§5.3 (R6).
|
||||||
|
*/
|
||||||
|
export async function autoRaiseRequisition(
|
||||||
|
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">
|
||||||
|
): Promise<RequisitionWithRefs> {
|
||||||
|
const requisition = await db.$transaction((tx) =>
|
||||||
|
createRequisitionTx(tx, { ...input, raisedById: null, autoRaised: true })
|
||||||
|
);
|
||||||
|
|
||||||
const recipients = await getOfficeRecipients();
|
const recipients = await getOfficeRecipients();
|
||||||
const loc = requisitionLocationLabel(requisition);
|
const loc = requisitionLocationLabel(requisition);
|
||||||
await notifyCrew({
|
await notifyCrew({
|
||||||
|
|
@ -101,28 +110,6 @@ export async function notifyAutoRaised(requisition: RequisitionWithRefs): Promis
|
||||||
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
|
body: `A ${requisition.rank.name} vacancy on ${loc} was auto-raised (${requisition.code}) — reason: ${requisition.reason}.`,
|
||||||
link: `/crewing/requisitions/${requisition.id}`,
|
link: `/crewing/requisitions/${requisition.id}`,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System auto-raise: an OPEN requisition with no human actor (autoRaised).
|
|
||||||
* Sign-off, end-of-contract and the leave-clash detector funnel through here.
|
|
||||||
* See spec §5.2/§5.3 (R6).
|
|
||||||
*
|
|
||||||
* Pass `tx` to create the backfill **atomically inside the caller's transaction**
|
|
||||||
* (so an approved leave / sign-off can never commit without its backfill) — the
|
|
||||||
* caller then owns the post-commit `notifyAutoRaised`. Called without `tx`, it
|
|
||||||
* runs its own transaction and notifies itself.
|
|
||||||
*/
|
|
||||||
export async function autoRaiseRequisition(
|
|
||||||
input: Omit<NewRequisitionInput, "raisedById" | "autoRaised">,
|
|
||||||
tx?: Tx
|
|
||||||
): Promise<RequisitionWithRefs> {
|
|
||||||
const data = { ...input, raisedById: null, autoRaised: true };
|
|
||||||
if (tx) {
|
|
||||||
// Caller's transaction — caller is responsible for notifyAutoRaised after commit.
|
|
||||||
return createRequisitionTx(tx, data);
|
|
||||||
}
|
|
||||||
const requisition = await db.$transaction((t) => createRequisitionTx(t, data));
|
|
||||||
await notifyAutoRaised(requisition);
|
|
||||||
return requisition;
|
return requisition;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ export async function generateDownloadUrl(
|
||||||
export function buildStorageKey(
|
export function buildStorageKey(
|
||||||
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
// Crewing adds "cv" (Phase 3a); "crew-document" / "contract" follow in later
|
||||||
// phases — see Crewing-Implementation-Spec §4.5.
|
// phases — see Crewing-Implementation-Spec §4.5.
|
||||||
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract" | "po-pdf",
|
type: "po-document" | "receipt" | "cv" | "crew-document" | "contract",
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
fileName: string
|
fileName: string
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -59,16 +59,6 @@ export function buildSignatureKey(userId: string, ext: string): string {
|
||||||
return `signatures/${userId}.${ext}`;
|
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).
|
* Storage key for a company branding asset (logo or stamp/seal).
|
||||||
* Deterministic per company+type so a re-upload overwrites the previous file.
|
* Deterministic per company+type so a re-upload overwrites the previous file.
|
||||||
|
|
@ -116,36 +106,6 @@ 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).
|
* Fetch a stored file as a Buffer (server-side).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue