Compare commits
107 commits
feat/compa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a08ed68569 | |||
| 56497a0d20 | |||
| 7d4ad6a9b8 | |||
|
|
55ae1d46d0 | ||
| 21df005ab6 | |||
| 502411afe6 | |||
| 2e8fd67805 | |||
| 29118aa88e | |||
| d25a600566 | |||
| 7245bb1962 | |||
| c710fe5d73 | |||
|
|
c503f839e8 | ||
| 2bdf3a6536 | |||
| d7b455ab7d | |||
| 70f3230c36 | |||
| 85805754b5 | |||
| 3babfe26ef | |||
| fced7cc307 | |||
| f4c8ec7585 | |||
| 6e8d05e34e | |||
| a99b2ed5df | |||
|
|
5cefe8f7ed | ||
| 7fe46c2448 | |||
| 470523a7a6 | |||
| cc7161d5ed | |||
| cd2bcefdbd | |||
| 2ac11d7528 | |||
| 6b0210078a | |||
| 8206483f88 | |||
| 3edd1ffcc5 | |||
| 144d44ccca | |||
| dc9ab327b8 | |||
| 5aae45299b | |||
| 8a2c592f6f | |||
| 0e0e377718 | |||
| 455d268925 | |||
| 99c928213b | |||
| afa5937429 | |||
| e7888a0886 | |||
| db4c2096ec | |||
| 6da6c277ad | |||
| e951a44a67 | |||
| dfefd86832 | |||
|
|
964af311f8 | ||
| 561ff8acf4 | |||
| 81744d1fa8 | |||
| f7e38fc60c | |||
| 451709ba26 | |||
| 9cac83013e | |||
| c32fb6979c | |||
| 1ef0c53ff0 | |||
| 93d13a415c | |||
| df950c7253 | |||
| 0679883273 | |||
| 184250f903 | |||
| d796e81efc | |||
| 06ff587024 | |||
| 53fbdb5c53 | |||
| e193e26368 | |||
| df3b4bdc97 | |||
| c14a22588e | |||
| 8982118eee | |||
| 712e040fc2 | |||
| 4e71863c57 | |||
| bb5f4126b0 | |||
| 040a66488d | |||
| aac31c6755 | |||
| 37b1debc9d | |||
| c82efa71af | |||
| 3ec3a2b4ef | |||
| be6db075dc | |||
| 0b2ed9ac07 | |||
| 4528c059aa | |||
| ff0539de92 | |||
| d0006a8fc7 | |||
| 2fd3709b22 | |||
| da2d856b73 | |||
| 6e25d701d2 | |||
| 2de883c70f | |||
| e4c4c370f6 | |||
| 65a9335de1 | |||
| cb661949d9 | |||
| 610c9aa56d | |||
| 6677ef4fcf | |||
| 4fee393c84 | |||
| 3b9bc0be1b | |||
| 0fdd899096 | |||
| 43d139234e | |||
|
|
cb25d2e5fd | ||
| 9de60200f9 | |||
| a8d772d63b | |||
| a197b966b1 | |||
| 058ba1d12e | |||
| 0b10ba5e54 | |||
| fbdc7b2235 | |||
| 9e787fd15f | |||
| 8ee077e548 | |||
| 991b7ca5dd | |||
| 4c53aeecb0 | |||
| b70eec261b | |||
| d9394e6afb | |||
| 4712fafb4b | |||
| e388ec917e | |||
| 9d08ca1990 | |||
| 6137d11e5f | |||
|
|
defd6e7a18 | ||
| 74d20cd452 |
242 changed files with 20656 additions and 576 deletions
|
|
@ -40,6 +40,70 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
name: PR checks
|
name: PR checks
|
||||||
|
|
||||||
# Enforces the contribution policy on every PR into master (all gates hard):
|
# Enforces the contribution policy on every PR into master — plus the crewing
|
||||||
|
# stack branches (feat/crewing-*), which collect the stacked, feature-flagged
|
||||||
|
# crewing phases (foundations → requisitions → candidates → …) before they merge
|
||||||
|
# to master. Same hard gates:
|
||||||
# - code changes must ship with tests (docs/config/automation are exempt)
|
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||||
# - type-check is clean across the whole project (tests included)
|
# - type-check is clean across the whole project (tests included)
|
||||||
# - unit tests pass
|
# - unit tests pass
|
||||||
|
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||||
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||||
|
#
|
||||||
|
# Note: the workflow is evaluated from the branch under test, so the trigger list
|
||||||
|
# must match it. The feat/crewing-* glob covers every branch in the stack so each
|
||||||
|
# stacked phase PR is checked without further edits to this file.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master, "feat/crewing-*"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
checks:
|
checks:
|
||||||
|
|
@ -56,3 +64,45 @@ jobs:
|
||||||
set -e
|
set -e
|
||||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
||||||
|
|
||||||
|
integration:
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Checkout PR
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Integration tests (ephemeral Postgres)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
# Throwaway Postgres per run — isolated from prod / pelagia_test / staging.
|
||||||
|
# A random host port avoids collisions with the host DB and concurrent runs.
|
||||||
|
PG="ci-pg-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||||
|
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
docker rm -f "$PG" >/dev/null 2>&1 || true
|
||||||
|
docker run -d --name "$PG" \
|
||||||
|
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=pelagia_ci \
|
||||||
|
-p 127.0.0.1::5432 postgres:16 >/dev/null
|
||||||
|
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
docker exec "$PG" pg_isready -U ci -d pelagia_ci >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
PORT=$(docker inspect --format '{{ (index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort }}' "$PG")
|
||||||
|
export DATABASE_URL="postgresql://ci:ci@127.0.0.1:${PORT}/pelagia_ci"
|
||||||
|
# Non-secret placeholders so auth.ts (reads these at module load) boots in dev mode.
|
||||||
|
export NEXTAUTH_SECRET="ci-secret"
|
||||||
|
export NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
export AZURE_AD_CLIENT_ID="placeholder"
|
||||||
|
export AZURE_AD_CLIENT_SECRET="placeholder"
|
||||||
|
export AZURE_AD_TENANT_ID="placeholder"
|
||||||
|
|
||||||
|
cd App
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate:deploy # apply migrations to the fresh DB
|
||||||
|
pnpm db:seed # dev seed — integration tests rely on it
|
||||||
|
pnpm test:integration # node + real DB — must pass
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,36 @@ 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.
|
||||||
|
|
|
||||||
130
App/CLAUDE.md
130
App/CLAUDE.md
|
|
@ -98,6 +98,29 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
|
||||||
|
|
||||||
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
|
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
|
||||||
|
|
||||||
|
### Delivery Locations (issue #19)
|
||||||
|
|
||||||
|
`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete).
|
||||||
|
|
||||||
|
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
|
||||||
|
|
||||||
|
### Terms & Conditions catalogue (issue #11)
|
||||||
|
|
||||||
|
Admin-managed T&C with **user-defined categories** (not a fixed set) feeding a **dynamic PO editor**.
|
||||||
|
|
||||||
|
- **Models:** `TermsCategory` (`name` unique + `sortOrder` + `isActive`) and `TermsCondition` (`categoryId` FK + `text` + `isDefault` + `isActive` + `sortOrder`). Managed at `/admin/terms` (gated by **`manage_terms`** — Manager + SuperUser + Admin). The migration **seeds every standard PO T&C line** as a clause: the five named slots keep their wording, the previously-fixed boilerplate lines live under a **"General"** category, and an empty **"Others"** category is provided. `isDefault` clauses pre-fill new POs.
|
||||||
|
- **Admin** (`/admin/terms`): the Add/Edit clause form's category is a combobox — typing a new name **creates the category** ("add a new category along with the clause"). `isDefault` is a checkbox.
|
||||||
|
- **PO editor** (`components/po/po-terms-editor.tsx`, used by all three PO forms): a dynamic list — **"+ Add term"** appends a row; each row is a category combobox + a clause combobox (both `<input list>` so you can pick a catalogued value or type a one-off). New POs pre-fill from `getDefaultPoTerms()`; editing a PO loads `po.terms`, or (for pre-feature POs) `legacyPoTerms()` maps the old `tc*` columns + fixed lines onto rows.
|
||||||
|
- **Storage:** the chosen rows are a JSON **snapshot** on `PurchaseOrder.terms` (`[{ category, text }]`). It **supersedes** the legacy `tc*` columns for the export (`route.ts`) and PO detail; old POs with null `terms` still render from `tc*` + the fixed lines. `lib/terms.ts` `parsePoTerms` validates the JSON; `lib/terms-data.ts` exposes `getTermsCatalogue` / `getDefaultPoTerms`. No "work order" type — POs only (per the issue's steer).
|
||||||
|
|
||||||
|
### Unsaved-changes prompt (issue #18)
|
||||||
|
|
||||||
|
The PO **create** (`new-po-form`) and **edit** (`edit-po-form`) screens guard against losing in-progress work. `components/po/unsaved-changes-guard.tsx` `<UnsavedChangesGuard>` arms once the form is `dirty` (any `onInput`/`onChange` on the form, plus the React-state editors — line items, terms, files, accounting code) and:
|
||||||
|
- **Hard navigations** (refresh, tab close, external link) → the browser's native "Leave site?" prompt (`beforeunload`; browsers can't render custom buttons here, so save-as-draft isn't offered on this path).
|
||||||
|
- **In-app navigations** (sidebar / header / any internal `<a>`) → a capture-phase click interceptor opens an `AdminDialog` offering **Save as draft** (runs the form's draft save, which redirects to the PO) / **Discard changes** (navigates to the intended URL) / **Stay on page**.
|
||||||
|
|
||||||
|
`dirty` is reset before the form's own successful-submit redirect so saving never trips the guard. The SPA **back button** (popstate) is not intercepted — only `beforeunload` covers it. The manager inline-edit panel on `/approvals/[id]` is out of scope (it saves in place via `router.refresh()` with no draft concept).
|
||||||
|
|
||||||
### PO Numbering (`lib/po-number.ts`)
|
### 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.
|
||||||
|
|
@ -106,18 +129,119 @@ 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.**
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
|
||||||
|
- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`.
|
||||||
|
- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin.
|
||||||
|
- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks.
|
||||||
|
- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card.
|
||||||
|
|
||||||
|
**Phase 2 — Requisitions + relief (spec §5.2/§8.2–8.3):**
|
||||||
|
|
||||||
|
- **Models:** `Requisition` (lifecycle `OPEN → SHORTLISTING → PROPOSING → INTERVIEWING → SELECTED → FILLED`, `→ CANCELLED`), `ReliefRequest` (site-flagged gap the office converts), and `CrewAction` (the crewing audit trail — the `POAction` mirror). `Requisition.autoRaised` marks system-raised vacancies.
|
||||||
|
- **State machine:** `lib/requisition-state-machine.ts` mirrors `po-state-machine.ts` (`TRANSITIONS`, `canPerformAction`, `getAvailableActions`; orthogonal `CANCEL_ROLES`/`canCancel`). Final selection is Manager-only; withdraw is allowed from OPEN/SHORTLISTING by `cancel_requisition` holders (MPO + Manager, per §6). Codes (`REQ-9000…`) come from `lib/requisition-number.ts`.
|
||||||
|
- **Actions** (`app/(portal)/crewing/requisitions/actions.ts`): `raiseRequisition`, `cancelRequisition`, `transitionRequisition`, `requestReliefCover`, `convertReliefToRequisition` — each guards flag + permission + state, writes a `CrewAction`, and notifies via `notifyCrew`. The shared `autoRaiseRequisition()` in `lib/requisition-service.ts` is the backfill entry point sign-off / leave-clash (later phases) will call.
|
||||||
|
- **Screens:** `/crewing/requisitions` (list + Raise modal + "Relief requests from sites" convert) and `/crewing/requisitions/[id]` (detail; the recruitment pipeline is a later phase). **Requisitions** is in the flag-gated sidebar **Crewing** section (`CREWING_ITEMS`, Manager + MPO). The Ranks link stays under Administration.
|
||||||
|
- **Notifications:** `lib/notifier.ts` `notifyCrew()` is the PO-independent path (writes `Notification` rows with a null `poId`); `CrewNotificationEvent` covers `REQUISITION_RAISED` / `RELIEF_REQUESTED` / `RELIEF_CONVERTED`.
|
||||||
|
- **Deferred:** sign-off / experience-record (Epic K) is part of spec §12 item 2 but depends on the crew/assignment models from Phase 3/4, so it lands with those. `autoRaiseRequisition()` is already in place for it.
|
||||||
|
|
||||||
|
**Phase 3a — Candidates (Epic B; spec §8.6):** Phase 3 (candidate intake + 7-stage pipeline + onboarding) ships as **stacked sub-PRs** — 3a candidates, 3b pipeline, 3c onboarding.
|
||||||
|
|
||||||
|
- **Model:** `CrewMember` is the talent-pool spine — one row per person, created on first contact and kept through `CANDIDATE → EMPLOYEE → EX_HAND` (`CrewStatus`). `employeeId` is assigned only at onboarding (3c). `CandidateType` (NEW/EX_HAND) and `CandidateSource` derive from the chosen source; `currentRankId` (rank held) + `appliedRankId` (rank applied for). `CrewAction` gained a nullable `crewMemberId` (it now references at most one entity).
|
||||||
|
- **Actions** (`app/(portal)/crewing/candidates/actions.ts`): `addCandidate` / `updateCandidate` — guard flag + `manage_candidates`, write a `CrewAction`, optional CV upload via `buildStorageKey("cv", …)` + `uploadBuffer`. An EX_HAND source maps to `type/status = EX_HAND`; an edit never downgrades an `EMPLOYEE`.
|
||||||
|
- **Screens:** `/crewing/candidates` (master list with search / source / rank-applied / min-experience filters rendered as removable chips + match count + Clear all; Add-candidate modal) and `/crewing/candidates/[id]` (profile; the 7-stage pipeline/stepper is 3b). **Candidates** added to the flag-gated Crewing nav (Manager + MPO).
|
||||||
|
- **Deferred:** the public careers intake API (A2, §13 open question) — 3a uses the internal Add-candidate modal only; CVs are stored but not parsed.
|
||||||
|
|
||||||
|
**Phase 3b — Recruitment pipeline (Epic C; spec §5.1/§8.4–8.5/§8.13):**
|
||||||
|
|
||||||
|
- **Models:** `Application` (one per requisition+candidate) drives the 7-stage `ApplicationStage` (`SHORTLISTED → COMPETENCY_AND_REFERENCES → DOC_VERIFICATION → SALARY_AGREEMENT → PROPOSED → INTERVIEW → SELECTED`; `→ REJECTED`; `ONBOARDED` is 3c). `ApplicationGate` records each vetting gate — `SALARY` / `SELECTION` / `WAIVER` gates with `result=PENDING` are the Manager's queue items. `ReferenceCheck`, effective-dated `SalaryStructure` (attached to the Application in 3b; bound to the assignment in 3c), and minimal `BankDetail` / `EpfDetail` captured at DOC_VERIFICATION (PII encryption deferred to Phase 4). `CrewAction` gained `applicationId`.
|
||||||
|
- **State machine:** `lib/application-pipeline.ts` (mirrors po/requisition machines) — sourcing advances are MPO/Manager; `approve_salary` and `select` are Manager-only; `canReject` is orthogonal. `BOARD_STAGES` is the 7 columns.
|
||||||
|
- **Actions** (`app/(portal)/crewing/applications/actions.ts`): `addApplication` (first candidate moves the requisition OPEN→SHORTLISTING), `advanceStage`, `recordReferenceCheck`, `verifyDocuments` (captures bank/EPF), `agreeSalary`→`approveSalary`/`returnSalary`, `recordInterviewResult`, `requestInterviewWaiver`→`approveInterviewWaiver`/`declineInterviewWaiver`, `selectCandidate`/`returnSelection` (sets requisition→SELECTED), `rejectApplication`. Waiver is **never automatic** (R2). Notifications: `SALARY_FOR_APPROVAL` / `SELECTION_FOR_APPROVAL` / `WAIVER_REQUESTED` (+ `CANDIDATE_PROPOSED`).
|
||||||
|
- **Screens:** pipeline board per requisition (`/crewing/requisitions/[id]/pipeline`, 7 columns + Add-candidate), the application workhorse (`/crewing/applications/[id]` — 7-step stepper + adaptive per-stage action card), and an **"Open pipeline"** action on the requisition detail.
|
||||||
|
- **Central approvals (§8.13 R8):** `/approvals` now also lists pending crewing gates (Salary / Selection / Waiver) with inline Approve/Return, alongside POs — one unified Manager queue.
|
||||||
|
|
||||||
|
**Phase 3c — Onboarding (Epic D; spec §8.5/§9/§11):**
|
||||||
|
|
||||||
|
- **Models:** `CrewAssignment` (a tour of duty, `AssignmentStatus` ACTIVE/ON_LEAVE/SIGNED_OFF — leave/sign-off are Phase 4) and `ContractLetter` (`salaryRestricted`). `SalaryStructure` gained `assignmentId` (bound at onboarding). `CrewActionType += CREW_ONBOARDED`. Employee numbers `CRW-xxxx` via `lib/employee-number.ts`.
|
||||||
|
- **Action** (`onboardCandidate`, `onboard_crew`): one transaction off a `SELECTED` application — assign `employeeId`, create `CrewAssignment(ACTIVE, signOnDate)`, bind the approved `SalaryStructure` (`assignmentId` + `effectiveFrom`), `Application → ONBOARDED`, `Requisition → FILLED`, `CrewMember → EMPLOYEE` (+ `currentRank`); contract letter stored after. Onboarded crew leave the Candidates pool (the Crew directory is Phase 4).
|
||||||
|
- **Screen:** the SELECTED action card's **Onboard to crew** modal (joining date, contract upload, starts-automatically chips); the assigned `CRW-` number shows on the ONBOARDED card.
|
||||||
|
- **Deferred:** SITE_STAFF **login creation** for management ranks (grantsLogin) is a follow-up; attendance/experience/PPE records (the "starts automatically" chips) begin in Phase 4.
|
||||||
|
|
||||||
|
**Phase 4a — Crew records & profile + PPE (Epics E + F; spec §8.7–8.8):** Phase 4 (crew records, PPE, leave/attendance + sign-off) ships as **stacked sub-PRs** — 4a records/profile/PPE, 4b leave/attendance, 4c sign-off/experience.
|
||||||
|
|
||||||
|
- **Models:** `SeafarerDocument`, `NextOfKin` (`isEmergency`), `ExperienceRecord`, `PpeIssue` (`PpeItem` enum) — all on `CrewMember`. `CrewActionType += DOCUMENT_UPLOADED / RECORD_UPDATED / PPE_ISSUED / PPE_RETURNED / EXPERIENCE_ADDED`. (`BankDetail`/`EpfDetail` already exist from 3b.)
|
||||||
|
- **PII masking** (`lib/crew-pii.ts`, spec §6/§8.8): bank account number + Aadhaar are full only for **Accounts/SuperUser**, masked (`•••• 1234`) otherwise; salary hidden from **site staff**. Masking is applied **server-side** before data crosses to the client.
|
||||||
|
- **Actions** (`app/(portal)/crewing/crew/actions.ts`): `uploadDocument`/`deleteDocument`, `saveBankEpf`, `addNextOfKin`/`deleteNextOfKin`, `issuePpe`/`returnPpe`, `addExperience` — guarded by `upload_crew_records` / `issue_ppe`, each writes a `CrewAction`. Document/contract files via `buildStorageKey("crew-document", …)`.
|
||||||
|
- **Screens:** `/crewing/crew` (directory — active `EMPLOYEE` crew, search + vessel filter; ex-hands excluded) and `/crewing/crew/[id]` (tabbed profile: Documents · Bank & EPF · Next of kin · PPE · Experience · Pay status). **Crew** added to the flag-gated nav (MGR/MPO/Site/Accounts).
|
||||||
|
- **Deferred:** site-staff **own-site scoping** (needs a User↔Site link, not modelled — all crew show for now); the records **verify queue** (§8.11, Phase 5); the Pay-status tab shows the salary structure only until wage reports (Phase 6).
|
||||||
|
|
||||||
|
**Phase 4b — Leave & attendance (Epic G; spec §5.3/§8.9–8.10):**
|
||||||
|
|
||||||
|
- **Models:** `LeaveRequest` (`LeaveType`, `LeaveStatus`) and `Attendance` (`AttendanceStatus`, `@@unique([assignmentId, date])`) hang off `CrewAssignment`. `CrewActionType += LEAVE_APPLIED / LEAVE_DECIDED / ATTENDANCE_RECORDED`.
|
||||||
|
- **Leave (R1):** **Site staff apply on behalf** (`apply_leave`); the **Manager decides** (`decide_leave`) — the **MPO has no leave role**. On approval the assignment goes `ON_LEAVE`. Leave approvals also surface in the central `/approvals` queue (§8.13 "Leave" kind, inline Approve/Decline). Notification `LEAVE_FOR_APPROVAL`.
|
||||||
|
- **Clash auto-backfill (R6, Option A):** `VesselRankRequirement{vesselId, rankId, minStrength}` configures required crew strength per rank per vessel. `lib/leave-clash.ts` flags a clash when approving a leave would drop the **active same-rank cover over the window below `minStrength`** (default **1** when unconfigured) → auto-raises a `LEAVE` requisition via the Phase-2 `autoRaiseRequisition`. The requirement is managed by the office (`manage_crew`).
|
||||||
|
- **Attendance (R5):** daily month calendar, **site staff record** (`record_attendance`), **Manager views** (`view_attendance`) but cannot edit, **MPO has neither**. `saveAttendance(assignmentId, marks)` bulk-upserts the dirty cells.
|
||||||
|
- **Screens:** `/crewing/leave` (apply-on-behalf modal + requests list with Manager Approve/Decline) and `/crewing/attendance` (crew dropdown + month grid, tap-to-cycle Present/Absent/Leave/Half-day, Save). **Leave** + **Attendance** added to the flag-gated nav (Manager + Site staff only).
|
||||||
|
- **Deferred:** the 6-month leave-planner timeline with clash bars (§8.9) is a lightweight list for now; hours/overtime attendance (A7) stays deferred.
|
||||||
|
|
||||||
|
**Crewing admin (office/admin management):** a new `manage_crew` permission (Manager + SuperUser + Admin) gates a small Administration surface:
|
||||||
|
- **Crew management** (`/admin/crew`): full CRUD over `CrewMember` (any status), and **direct placement** — `placeCrew` assigns a crew member to a vessel/site **without a requisition** (creates an `ACTIVE` `CrewAssignment`; promotes a candidate to `EMPLOYEE` with a `CRW-` number; blocked if they already have an active assignment).
|
||||||
|
- **Crew strength** (`/admin/crew-strength`): CRUD over `VesselRankRequirement` (the `minStrength` that drives R6 leave-clash detection).
|
||||||
|
- Both links sit under **Administration** (flag-gated, Manager/Admin/SuperUser).
|
||||||
|
|
||||||
|
**Phase 4c — Sign-off & experience (Epic K; spec §5.3):** completes Phase 4 (and the Epic K piece deferred from Phase 2).
|
||||||
|
|
||||||
|
- **`signOffCrew(assignmentId, date, remarks)`** (`crewing/crew/actions.ts`, `sign_off_crew`): one transaction — assignment → `SIGNED_OFF` (+ `signOffDate`), append an internal `ExperienceRecord` (rank, on/off dates, computed `durationMonths`), flip the **same `CrewMember`** `EMPLOYEE → EX_HAND` (so they return to the Candidates pool as a returning hand), `CrewAction CREW_SIGNED_OFF`; then auto-raise a `SIGN_OFF` backfill requisition via `autoRaiseRequisition`. (`CrewActionType += CREW_SIGNED_OFF`.)
|
||||||
|
- **Screen:** a **Sign off** button on the crew-profile header (`/crewing/crew/[id]`, `sign_off_crew` holders — Site staff / MPO / Manager); on success it redirects to the Crew directory (the member is no longer `EMPLOYEE`).
|
||||||
|
- This closes **Phase 4** (E/F/G + K). Remaining roadmap: Phase 5 (verification + appraisal), Phase 6 (payroll, dashboards, notifications).
|
||||||
|
|
||||||
|
**Phase 5a — Verification (Epic I; spec §8.11/R11):** the office queue for site-entered records (Phase 5 ships as 5a verification → 5b appraisal).
|
||||||
|
|
||||||
|
- **Actions** (`crewing/verification/actions.ts`): `verifyDocument(id, approve, remarks)` (`verify_site_records` — MPO/Manager) sets a `SeafarerDocument`'s `verificationStatus` + `verifiedById`; `verifyBankEpf(crewMemberId, "bank"|"epf", approve, remarks)` (`verify_bank_epf` — Accounts) does the same for `BankDetail`/`EpfDetail`. Rejection requires remarks; both write a `CrewAction` (`RECORD_VERIFIED`/`RECORD_REJECTED`). No new models — the verification fields already existed (3b/4a).
|
||||||
|
- **Screen:** `/crewing/verification` — role-aware (MPO sees pending documents with expiry flags; Accounts sees pending bank/EPF), Verify / Reject-with-remarks. **Leave is not here** (it's a Manager approval, R11). Added to nav (MPO + Accounts + SuperUser, §7).
|
||||||
|
- **Deferred (per decision):** PPE / next-of-kin verification gates (low-risk; no `verificationStatus` on those models).
|
||||||
|
|
||||||
|
**Phase 5b — Appraisal (Epic H; spec §5.4/§8.14):** completes Phase 5.
|
||||||
|
|
||||||
|
- **Model:** `Appraisal` (on `CrewAssignment`) + `AppraisalStatus` (`DRAFT → SUBMITTED → MPO_VERIFIED → MANAGER_APPROVED`; `→ REJECTED`). `ratings` is a small JSON (competence/conduct/safety). `CrewActionType += APPRAISAL_SUBMITTED/VERIFIED/APPROVED/REJECTED`.
|
||||||
|
- **State machine** `lib/appraisal-state-machine.ts`: `verify` (SUBMITTED→MPO_VERIFIED, MPO/Manager) and `approve` (MPO_VERIFIED→MANAGER_APPROVED, Manager); orthogonal reject.
|
||||||
|
- **Actions** (`crewing/appraisals/actions.ts`): `raiseAppraisal` (`raise_appraisal` — PM/site staff; creates `SUBMITTED`), `verifyAppraisal` (`verify_appraisal` — MPO), `approveAppraisal` (`approve_appraisal` — Manager); reject paths require remarks; notifications `APPRAISAL_FOR_VERIFICATION` / `APPRAISAL_FOR_APPROVAL`.
|
||||||
|
- **Three surfaces** (§8.14): the PM raises + sees status on the crew profile **Appraisals** tab; the MPO verifies in the **Verification** queue (Appraisals section); the Manager approves in the central **/approvals** queue (Appraisal kind).
|
||||||
|
- This completes **Phase 5** (I + H). Remaining roadmap: **Phase 6** — payroll (Pay-status tab + Approvals "Wage"), dashboards, notifications (J, M).
|
||||||
|
|
||||||
|
**Crewing follow-ups (resolved deferrals):** the self-contained deferrals from earlier phases are now done:
|
||||||
|
- **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.
|
||||||
|
- **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).
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
||||||
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
|
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
|
||||||
|
|
@ -141,7 +265,13 @@ 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_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.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal file
55
App/app/(portal)/admin/crew-strength/actions.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
const PATH = "/admin/crew-strength";
|
||||||
|
|
||||||
|
async function guard(): Promise<{ error: string } | { ok: true }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
vesselId: z.string().min(1, "Vessel is required"),
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
minStrength: z.coerce.number().int().min(0, "Strength must be 0 or more").max(999),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-vessel, per-rank required strength (drives leave-clash detection, R6).
|
||||||
|
export async function upsertRequirement(formData: FormData): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if ("error" in denied) return denied;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse({
|
||||||
|
vesselId: formData.get("vesselId"),
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
minStrength: formData.get("minStrength"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
await db.vesselRankRequirement.upsert({
|
||||||
|
where: { vesselId_rankId: { vesselId: d.vesselId, rankId: d.rankId } },
|
||||||
|
update: { minStrength: d.minStrength },
|
||||||
|
create: { vesselId: d.vesselId, rankId: d.rankId, minStrength: d.minStrength },
|
||||||
|
});
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRequirement(id: string): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if ("error" in denied) return denied;
|
||||||
|
await db.vesselRankRequirement.delete({ where: { id } }).catch(() => {});
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { upsertRequirement, deleteRequirement } from "./actions";
|
||||||
|
|
||||||
|
const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||||
|
|
||||||
|
type Opt = { id: string; name: string };
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
type Req = { id: string; vessel: string; rank: string; minStrength: number };
|
||||||
|
|
||||||
|
export function CrewStrengthManager({ requirements, vessels, ranks }: { requirements: Req[]; vessels: Opt[]; ranks: RankOpt[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [f, setF] = useState({ vesselId: "", rankId: "", minStrength: "1" });
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("vesselId", f.vesselId); fd.set("rankId", f.rankId); fd.set("minStrength", f.minStrength);
|
||||||
|
const res = await upsertRequirement(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setF({ vesselId: "", rankId: "", minStrength: "1" }); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Crew strength</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Required crew per rank, per vessel. Drives the leave-clash backfill — a leave that drops cover below the required strength auto-raises a requisition.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="mb-5 flex flex-wrap items-end gap-3 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel</label>
|
||||||
|
<select className={INPUT} value={f.vesselId} onChange={(e) => setF({ ...f, vesselId: e.target.value })} required><option value="">— Vessel —</option>{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank</label>
|
||||||
|
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value="">— Rank —</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Min strength</label>
|
||||||
|
<input className={`${INPUT} w-28`} type="number" min={0} value={f.minStrength} onChange={(e) => setF({ ...f, minStrength: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<button className={BTN} disabled={pending || !f.vesselId || !f.rankId}>{pending ? "Saving…" : "Set requirement"}</button>
|
||||||
|
{error && <p className="w-full text-sm text-danger-700">{error}</p>}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Vessel</th>
|
||||||
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
<th className="px-4 py-3">Min strength</th>
|
||||||
|
<th className="px-4 py-3 w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requirements.length === 0 ? (
|
||||||
|
<tr><td colSpan={4} className="px-4 py-12 text-center text-neutral-400">No requirements set. Unconfigured rank/vessel pairs default to a strength of 1.</td></tr>
|
||||||
|
) : requirements.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 text-neutral-800">{r.vessel}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{r.rank}</td>
|
||||||
|
<td className="px-4 py-3 font-semibold text-neutral-900">{r.minStrength}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button className="text-xs font-medium text-danger-600 hover:underline" onClick={async () => { await deleteRequirement(r.id); router.refresh(); }}>Remove</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal file
34
App/app/(portal)/admin/crew-strength/page.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CrewStrengthManager } from "./crew-strength-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Crew strength" };
|
||||||
|
|
||||||
|
export default async function CrewStrengthPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [requirements, vessels, ranks] = await Promise.all([
|
||||||
|
db.vesselRankRequirement.findMany({
|
||||||
|
orderBy: [{ vessel: { name: "asc" } }, { rank: { name: "asc" } }],
|
||||||
|
include: { vessel: { select: { name: true } }, rank: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrewStrengthManager
|
||||||
|
requirements={requirements.map((r) => ({ id: r.id, vessel: r.vessel.name, rank: r.rank.name, minStrength: r.minStrength }))}
|
||||||
|
vessels={vessels}
|
||||||
|
ranks={ranks}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
App/app/(portal)/admin/crew/actions.ts
Normal file
167
App/app/(portal)/admin/crew/actions.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { generateEmployeeId } from "@/lib/employee-number";
|
||||||
|
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||||
|
import { CrewStatus, CandidateType, CandidateSource } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
const PATH = "/admin/crew";
|
||||||
|
|
||||||
|
async function guard(): Promise<{ error: string } | { userId: string }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "manage_crew")) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
status: z.nativeEnum(CrewStatus).default("CANDIDATE"),
|
||||||
|
type: z.nativeEnum(CandidateType).default("NEW"),
|
||||||
|
source: z.nativeEnum(CandidateSource).default("CAREERS"),
|
||||||
|
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
appliedRankId: z.string().optional(),
|
||||||
|
currentRankId: z.string().optional(),
|
||||||
|
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parse(formData: FormData) {
|
||||||
|
return crewSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
status: (formData.get("status") as string) || undefined,
|
||||||
|
type: (formData.get("type") as string) || undefined,
|
||||||
|
source: (formData.get("source") as string) || undefined,
|
||||||
|
email: (formData.get("email") as string) || undefined,
|
||||||
|
phone: (formData.get("phone") as string) || undefined,
|
||||||
|
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
|
||||||
|
currentRankId: (formData.get("currentRankId") as string) || undefined,
|
||||||
|
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCrewMember(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const crew = await db.crewMember.create({
|
||||||
|
data: {
|
||||||
|
name: d.name, status: d.status, type: d.type, source: d.source,
|
||||||
|
email: d.email || null, phone: d.phone || null,
|
||||||
|
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return { ok: true, id: crew.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCrewMember(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
if (!id) return { error: "Crew ID is required" };
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await db.crewMember.findUnique({ where: { id }, select: { id: true } }))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
await db.crewMember.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: d.name, status: d.status, type: d.type, source: d.source,
|
||||||
|
email: d.email || null, phone: d.phone || null,
|
||||||
|
appliedRankId: d.appliedRankId || null, currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCrewMember(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const crew = await db.crewMember.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { _count: { select: { assignments: true, applications: true } } },
|
||||||
|
});
|
||||||
|
if (!crew) return { error: "Crew member not found" };
|
||||||
|
if (crew._count.assignments > 0 || crew._count.applications > 0) {
|
||||||
|
return { error: "Cannot delete: this crew member has assignments or applications. Remove those first." };
|
||||||
|
}
|
||||||
|
await db.crewAction.deleteMany({ where: { crewMemberId: id } });
|
||||||
|
await db.crewMember.delete({ where: { id } });
|
||||||
|
revalidatePath(PATH);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Direct placement (Manager) — assign crew to a vessel/site, no requisition ──
|
||||||
|
|
||||||
|
const placeSchema = z
|
||||||
|
.object({
|
||||||
|
crewMemberId: z.string().min(1, "Crew member is required"),
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
vesselId: z.string().optional(),
|
||||||
|
siteId: z.string().optional(),
|
||||||
|
signOnDate: z.string().min(1, "Joining date is required"),
|
||||||
|
})
|
||||||
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), { message: "A vessel or site is required" });
|
||||||
|
|
||||||
|
export async function placeCrew(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = placeSchema.safeParse({
|
||||||
|
crewMemberId: formData.get("crewMemberId"),
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
|
signOnDate: formData.get("signOnDate"),
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const crew = await db.crewMember.findUnique({
|
||||||
|
where: { id: d.crewMemberId },
|
||||||
|
include: { assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!crew) return { error: "Crew member not found" };
|
||||||
|
if (crew.assignments.length > 0) return { error: "This crew member already has an active assignment" };
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.crewAssignment.create({
|
||||||
|
data: {
|
||||||
|
status: "ACTIVE",
|
||||||
|
signOnDate: new Date(d.signOnDate),
|
||||||
|
crewMemberId: crew.id,
|
||||||
|
rankId: d.rankId,
|
||||||
|
vesselId: d.vesselId || null,
|
||||||
|
siteId: d.siteId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Promote a candidate/ex-hand to active crew (employee no.) on first placement.
|
||||||
|
const data: { status: "EMPLOYEE"; currentRankId: string; employeeId?: string } = { status: "EMPLOYEE", currentRankId: d.rankId };
|
||||||
|
if (!crew.employeeId) data.employeeId = await generateEmployeeId(tx);
|
||||||
|
await tx.crewMember.update({ where: { id: crew.id }, data });
|
||||||
|
await tx.crewAction.create({ data: { actionType: "CREW_ONBOARDED", actorId: g.userId, crewMemberId: crew.id, metadata: { direct: true } } });
|
||||||
|
// Management ranks (grantsLogin) become a SITE_STAFF login on placement.
|
||||||
|
await maybeCreateSiteStaffLogin(tx, { name: crew.name, email: crew.email, employeeId: data.employeeId ?? crew.employeeId }, d.rankId, d.siteId || null);
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(PATH);
|
||||||
|
revalidatePath("/crewing/crew");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
203
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal file
203
App/app/(portal)/admin/crew/admin-crew-manager.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { createCrewMember, updateCrewMember, deleteCrewMember, placeCrew } 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";
|
||||||
|
const BTN = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||||
|
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50";
|
||||||
|
|
||||||
|
const STATUSES: CrewStatus[] = ["PROSPECT", "CANDIDATE", "EMPLOYEE", "EX_HAND", "BLACKLISTED"];
|
||||||
|
const SOURCES: CandidateSource[] = ["CAREERS", "EX_HAND", "WALK_IN", "REFERRAL", "OTHER"];
|
||||||
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
|
type Opt = { id: string; name: string };
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
type Crew = {
|
||||||
|
id: string; name: string; status: CrewStatus; type: CandidateType; source: CandidateSource;
|
||||||
|
email: string | null; phone: string | null; employeeId: string | null;
|
||||||
|
appliedRankId: string | null; currentRankId: string | null; currentRank: string | null;
|
||||||
|
experienceMonths: number; hasActiveAssignment: boolean; removable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<CrewStatus, "outline" | "default" | "success" | "secondary" | "danger"> = {
|
||||||
|
PROSPECT: "outline", CANDIDATE: "default", EMPLOYEE: "success", EX_HAND: "secondary", BLACKLISTED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminCrewManager({ crew, ranks, vessels, sites }: { crew: Crew[]; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return crew.filter((c) => !q || `${c.name} ${c.employeeId ?? ""}`.toLowerCase().includes(q));
|
||||||
|
}, [crew, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Crew management</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} crew records · create, edit, place onto a vessel/site, or remove</p>
|
||||||
|
</div>
|
||||||
|
<CrewFormButton ranks={ranks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input className={`${INPUT} mb-4 max-w-sm`} placeholder="Search name or employee no…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3">Employee</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
<th className="px-4 py-3 w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">No crew records.</td></tr>
|
||||||
|
) : filtered.map((c) => <Row key={c.id} c={c} ranks={ranks} vessels={vessels} sites={sites} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ c, ranks, vessels, sites }: { c: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[] }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [placeOpen, setPlaceOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{c.name}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{label(c.status)}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{c.currentRank ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
{!c.hasActiveAssignment && <RowActionsItem onClick={() => setPlaceOpen(true)}>Place onto vessel/site</RowActionsItem>}
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
<CrewFormButton ranks={ranks} editing={c} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<PlaceDialog crew={c} ranks={ranks} vessels={vessels} sites={sites} open={placeOpen} onOpenChange={setPlaceOpen} />
|
||||||
|
<DeleteConfirmDialog open={deleteOpen} onOpenChange={setDeleteOpen} label={c.name} onConfirm={() => deleteCrewMember(c.id)} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CrewFormButton({ ranks, editing, open, onOpenChange }: { ranks: RankOpt[]; editing?: Crew; open?: boolean; onOpenChange?: (v: boolean) => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const isControlled = open !== undefined;
|
||||||
|
const isOpen = isControlled ? open : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [f, setF] = useState({
|
||||||
|
name: editing?.name ?? "", status: editing?.status ?? "CANDIDATE", type: editing?.type ?? "NEW", source: editing?.source ?? "CAREERS",
|
||||||
|
email: editing?.email ?? "", phone: editing?.phone ?? "", appliedRankId: editing?.appliedRankId ?? "", currentRankId: editing?.currentRankId ?? "",
|
||||||
|
experienceMonths: String(editing?.experienceMonths ?? 0),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
if (editing) fd.set("id", editing.id);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v !== "" && fd.set(k, String(v)));
|
||||||
|
const res = await (editing ? updateCrewMember(fd) : createCrewMember(fd));
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isControlled && <button className={BTN} onClick={() => setOpen(true)}>+ Add crew</button>}
|
||||||
|
<AdminDialog title={editing ? "Edit crew member" : "Add crew member"} open={isOpen} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||||
|
<select className={INPUT} value={f.status} onChange={(e) => setF({ ...f, status: e.target.value as CrewStatus })}>{STATUSES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
|
<select className={INPUT} value={f.source} onChange={(e) => setF({ ...f, source: e.target.value as CandidateSource })}>{SOURCES.map((s) => <option key={s} value={s}>{label(s)}</option>)}</select>
|
||||||
|
<label className="flex items-center gap-2 px-1 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" checked={f.type === "EX_HAND"} onChange={(e) => setF({ ...f, type: e.target.checked ? "EX_HAND" : "NEW" })} />
|
||||||
|
Ex-hand (returning crew)
|
||||||
|
</label>
|
||||||
|
<select className={INPUT} value={f.appliedRankId} onChange={(e) => setF({ ...f, appliedRankId: e.target.value })}><option value="">Rank applied…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
|
<select className={INPUT} value={f.currentRankId} onChange={(e) => setF({ ...f, currentRankId: e.target.value })}><option value="">Rank held…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
|
<input className={INPUT} placeholder="Email" value={f.email} onChange={(e) => setF({ ...f, email: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||||
|
<input className={INPUT} type="number" min={0} placeholder="Experience (months)" value={f.experienceMonths} onChange={(e) => setF({ ...f, experienceMonths: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !f.name} className={BTN}>{pending ? "Saving…" : editing ? "Save changes" : "Add crew"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceDialog({ crew, ranks, vessels, sites, open, onOpenChange }: { crew: Crew; ranks: RankOpt[]; vessels: Opt[]; sites: Opt[]; open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [f, setF] = useState({ rankId: crew.currentRankId ?? crew.appliedRankId ?? "", location: "", signOnDate: "" });
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crew.id);
|
||||||
|
fd.set("rankId", f.rankId);
|
||||||
|
if (f.location.startsWith("v:")) fd.set("vesselId", f.location.slice(2));
|
||||||
|
else if (f.location.startsWith("s:")) fd.set("siteId", f.location.slice(2));
|
||||||
|
fd.set("signOnDate", f.signOnDate);
|
||||||
|
const res = await placeCrew(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { onOpenChange(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog title={`Place ${crew.name}`} open={open} onClose={() => onOpenChange(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-3">
|
||||||
|
<p className="text-sm text-neutral-600">Assign this crew member directly to a vessel/site — no requisition needed. A candidate is promoted to active crew with an employee number.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
|
||||||
|
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })} required><option value="">— Rank —</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
|
||||||
|
<select className={INPUT} value={f.location} onChange={(e) => setF({ ...f, location: e.target.value })} required>
|
||||||
|
<option value="">— Select —</option>
|
||||||
|
{vessels.length > 0 && <optgroup label="Vessels">{vessels.map((v) => <option key={v.id} value={`v:${v.id}`}>{v.name}</option>)}</optgroup>}
|
||||||
|
{sites.length > 0 && <optgroup label="Sites">{sites.map((s) => <option key={s.id} value={`s:${s.id}`}>{s.name}</option>)}</optgroup>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
|
||||||
|
<input type="date" className={INPUT} value={f.signOnDate} onChange={(e) => setF({ ...f, signOnDate: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => onOpenChange(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !f.rankId || !f.location || !f.signOnDate} className={BTN}>{pending ? "Placing…" : "Place crew"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
App/app/(portal)/admin/crew/page.tsx
Normal file
56
App/app/(portal)/admin/crew/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { AdminCrewManager } from "./admin-crew-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Crew management" };
|
||||||
|
|
||||||
|
export default async function AdminCrewPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_crew")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [crew, ranks, vessels, sites] = await Promise.all([
|
||||||
|
db.crewMember.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
currentRank: { select: { name: true } },
|
||||||
|
appliedRank: { select: { name: true } },
|
||||||
|
assignments: { where: { status: { not: "SIGNED_OFF" } }, select: { id: true }, take: 1 },
|
||||||
|
_count: { select: { assignments: true, applications: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminCrewManager
|
||||||
|
crew={crew.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
status: c.status,
|
||||||
|
type: c.type,
|
||||||
|
source: c.source,
|
||||||
|
email: c.email,
|
||||||
|
phone: c.phone,
|
||||||
|
employeeId: c.employeeId,
|
||||||
|
appliedRankId: c.appliedRankId,
|
||||||
|
currentRankId: c.currentRankId,
|
||||||
|
currentRank: c.currentRank?.name ?? null,
|
||||||
|
experienceMonths: c.experienceMonths,
|
||||||
|
hasActiveAssignment: c.assignments.length > 0,
|
||||||
|
removable: c._count.assignments === 0 && c._count.applications === 0,
|
||||||
|
}))}
|
||||||
|
ranks={ranks}
|
||||||
|
vessels={vessels}
|
||||||
|
sites={sites}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
companyId: z.string().min(1, "Company is required"),
|
||||||
|
address: z.string().trim().min(1, "Delivery address is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Result = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_delivery_locations")) {
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDeliveryLocation(formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
// Guard against a dangling FK if the company was removed concurrently.
|
||||||
|
const company = await db.company.findUnique({ where: { id: parsed.data.companyId }, select: { id: true } });
|
||||||
|
if (!company) return { error: "Selected company no longer exists." };
|
||||||
|
|
||||||
|
await db.deliveryLocation.create({
|
||||||
|
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeliveryLocation(id: string, formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
await db.deliveryLocation.update({
|
||||||
|
where: { id },
|
||||||
|
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleDeliveryLocationActive(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const loc = await db.deliveryLocation.findUnique({ where: { id }, select: { isActive: true } });
|
||||||
|
if (!loc) return { error: "Not found" };
|
||||||
|
await db.deliveryLocation.update({ where: { id }, data: { isActive: !loc.isActive } });
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDeliveryLocation(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
// Safe to delete: POs keep their place-of-delivery as a text snapshot, so no
|
||||||
|
// purchase order references this row.
|
||||||
|
await db.deliveryLocation.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/delivery-locations");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { createDeliveryLocation, updateDeliveryLocation } from "./actions";
|
||||||
|
|
||||||
|
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
export type CompanyOption = { id: string; name: string };
|
||||||
|
export type DeliveryLocationRow = {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
address: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Fields({ companies, location }: { companies: CompanyOption[]; location?: DeliveryLocationRow }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Company *</label>
|
||||||
|
<select name="companyId" defaultValue={location?.companyId ?? ""} required className={INPUT}>
|
||||||
|
<option value="" disabled>Select a company…</option>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Delivery address *</label>
|
||||||
|
<textarea name="address" defaultValue={location?.address ?? ""} rows={3} required className={INPUT} placeholder="e.g. Reti Bundar, Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddDeliveryLocationButton({ companies }: { companies: CompanyOption[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await createDeliveryLocation(new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
|
+ Add Delivery Location
|
||||||
|
</button>
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Delivery Location">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields companies={companies} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDeliveryLocationButton({
|
||||||
|
companies,
|
||||||
|
location,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
companies: CompanyOption[];
|
||||||
|
location: DeliveryLocationRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await updateDeliveryLocation(location.id, new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Delivery Location">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields companies={companies} location={location} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import {
|
||||||
|
AddDeliveryLocationButton,
|
||||||
|
EditDeliveryLocationButton,
|
||||||
|
type CompanyOption,
|
||||||
|
type DeliveryLocationRow,
|
||||||
|
} from "./delivery-location-form";
|
||||||
|
import { deleteDeliveryLocation, toggleDeliveryLocationActive } from "./actions";
|
||||||
|
|
||||||
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function LocationActionsMenu({ companies, location }: { companies: CompanyOption[]; location: DeliveryLocationRow }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
|
{location.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditDeliveryLocationButton companies={companies} location={location} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={`${location.companyName} — ${location.address}`}
|
||||||
|
onConfirm={() => deleteDeliveryLocation(location.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toggleOpen}
|
||||||
|
onOpenChange={setToggleOpen}
|
||||||
|
title={location.isActive ? "Deactivate location?" : "Activate location?"}
|
||||||
|
description={
|
||||||
|
location.isActive
|
||||||
|
? "It will no longer appear in the Place of Delivery dropdown."
|
||||||
|
: "It will appear in the Place of Delivery dropdown again."
|
||||||
|
}
|
||||||
|
confirmLabel={location.isActive ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => toggleDeliveryLocationActive(location.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeliveryLocationsTable({
|
||||||
|
locations,
|
||||||
|
companies,
|
||||||
|
}: {
|
||||||
|
locations: DeliveryLocationRow[];
|
||||||
|
companies: CompanyOption[];
|
||||||
|
}) {
|
||||||
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
|
useTableControls<DeliveryLocationRow>({
|
||||||
|
rows: locations,
|
||||||
|
defaultSortKey: "companyName",
|
||||||
|
searchText: (l) => [l.companyName, l.address, l.isActive ? "active" : "inactive"].join(" "),
|
||||||
|
chipMatch: (l, chip) => {
|
||||||
|
if (chip.toLowerCase() === "active") return l.isActive;
|
||||||
|
if (chip.toLowerCase() === "inactive") return !l.isActive;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
sortValue: (l, key) => {
|
||||||
|
if (key === "isActive") return l.isActive ? "Active" : "Inactive";
|
||||||
|
const val = l[key as keyof DeliveryLocationRow];
|
||||||
|
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Delivery Locations</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Destinations that populate the PO “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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { DeliveryLocationsTable } from "./delivery-locations-table";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Delivery Locations" };
|
||||||
|
|
||||||
|
export default async function DeliveryLocationsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_delivery_locations")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [locations, companies] = await Promise.all([
|
||||||
|
db.deliveryLocation.findMany({
|
||||||
|
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
|
||||||
|
include: { company: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DeliveryLocationsTable
|
||||||
|
companies={companies}
|
||||||
|
locations={locations.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
companyId: l.companyId,
|
||||||
|
companyName: l.company.name,
|
||||||
|
address: l.address,
|
||||||
|
isActive: l.isActive,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)/inventory/items/[id]/item-price-chart";
|
import { ItemPriceChart } from "@/app/(portal)/catalogue/items/[id]/item-price-chart";
|
||||||
import { SiteSelect } from "@/components/inventory/site-select";
|
import { 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 = "/inventory/items",
|
detailBase = "/catalogue/items",
|
||||||
}: {
|
}: {
|
||||||
products: ProductRow[];
|
products: ProductRow[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
|
|
||||||
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { RankCategory, SeafarerDocType } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
async function guard(): Promise<{ error: string } | null> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_ranks")) {
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankSchema = z.object({
|
||||||
|
code: z.string().trim().min(1, "Code is required").max(16, "Code is too long"),
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
category: z.nativeEnum(RankCategory),
|
||||||
|
isSeafarer: z.boolean(),
|
||||||
|
grantsLogin: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseRank(formData: FormData) {
|
||||||
|
return rankSchema.safeParse({
|
||||||
|
code: formData.get("code"),
|
||||||
|
name: formData.get("name"),
|
||||||
|
description: (formData.get("description") as string) || undefined,
|
||||||
|
parentId: (formData.get("parentId") as string) || undefined,
|
||||||
|
category: formData.get("category"),
|
||||||
|
isSeafarer: formData.get("isSeafarer") === "on" || formData.get("isSeafarer") === "true",
|
||||||
|
grantsLogin: formData.get("grantsLogin") === "on" || formData.get("grantsLogin") === "true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `candidateParentId` is `rankId` itself or one of its descendants —
|
||||||
|
// setting it as the parent would create a cycle.
|
||||||
|
async function wouldCycle(rankId: string, candidateParentId: string): Promise<boolean> {
|
||||||
|
if (rankId === candidateParentId) return true;
|
||||||
|
const all = await db.rank.findMany({ select: { id: true, parentId: true } });
|
||||||
|
const childrenOf = new Map<string, string[]>();
|
||||||
|
for (const r of all) {
|
||||||
|
if (r.parentId) {
|
||||||
|
const list = childrenOf.get(r.parentId) ?? [];
|
||||||
|
list.push(r.id);
|
||||||
|
childrenOf.set(r.parentId, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stack = [rankId];
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop()!;
|
||||||
|
if (cur === candidateParentId) return true;
|
||||||
|
stack.push(...(childrenOf.get(cur) ?? []));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRank(formData: FormData): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const parsed = parseRank(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
const exists = await db.rank.findUnique({ where: { code: data.code } });
|
||||||
|
if (exists) return { error: "A rank with that code already exists" };
|
||||||
|
|
||||||
|
await db.rank.create({
|
||||||
|
data: {
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
parentId: data.parentId ?? null,
|
||||||
|
category: data.category,
|
||||||
|
isSeafarer: data.isSeafarer,
|
||||||
|
grantsLogin: data.grantsLogin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRank(formData: FormData): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
if (!id) return { error: "Rank ID is required" };
|
||||||
|
|
||||||
|
const parsed = parseRank(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
const conflict = await db.rank.findFirst({ where: { code: data.code, id: { not: id } } });
|
||||||
|
if (conflict) return { error: "Another rank already uses that code" };
|
||||||
|
|
||||||
|
if (data.parentId && (await wouldCycle(id, data.parentId))) {
|
||||||
|
return { error: "A rank cannot report to itself or one of its sub-ranks" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.rank.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
code: data.code,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description ?? null,
|
||||||
|
parentId: data.parentId ?? null,
|
||||||
|
category: data.category,
|
||||||
|
isSeafarer: data.isSeafarer,
|
||||||
|
grantsLogin: data.grantsLogin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRank(id: string): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const hasChildren = await db.rank.findFirst({ where: { parentId: id } });
|
||||||
|
if (hasChildren) return { error: "Cannot delete: this rank has sub-ranks. Reassign or remove them first." };
|
||||||
|
|
||||||
|
// Document requirements cascade on delete.
|
||||||
|
await db.rank.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleRankActive(id: string): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const rank = await db.rank.findUnique({ where: { id }, select: { isActive: true } });
|
||||||
|
if (!rank) return { error: "Rank not found" };
|
||||||
|
|
||||||
|
await db.rank.update({ where: { id }, data: { isActive: !rank.isActive } });
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const docReqSchema = z.object({
|
||||||
|
rankId: z.string().min(1),
|
||||||
|
docType: z.nativeEnum(SeafarerDocType),
|
||||||
|
isMandatory: z.boolean(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addRankDocRequirement(formData: FormData): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
const parsed = docReqSchema.safeParse({
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
docType: formData.get("docType"),
|
||||||
|
isMandatory: formData.get("isMandatory") === "on" || formData.get("isMandatory") === "true",
|
||||||
|
note: (formData.get("note") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
await db.rankDocRequirement.upsert({
|
||||||
|
where: { rankId_docType: { rankId: data.rankId, docType: data.docType } },
|
||||||
|
update: { isMandatory: data.isMandatory, note: data.note ?? null },
|
||||||
|
create: { rankId: data.rankId, docType: data.docType, isMandatory: data.isMandatory, note: data.note ?? null },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeRankDocRequirement(id: string): Promise<ActionResult> {
|
||||||
|
const denied = await guard();
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
|
await db.rankDocRequirement.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/ranks");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { RanksManager } from "./ranks-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ranks & Documents" };
|
||||||
|
|
||||||
|
export default async function AdminRanksPage() {
|
||||||
|
// Dark unless the crewing module is switched on.
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_ranks")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const ranks = await db.rank.findMany({
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
include: { docRequirements: { orderBy: { docType: "asc" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flatten to plain props (no Date/Decimal crosses the server→client boundary).
|
||||||
|
const rows = ranks.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
code: r.code,
|
||||||
|
name: r.name,
|
||||||
|
description: r.description,
|
||||||
|
category: r.category,
|
||||||
|
isSeafarer: r.isSeafarer,
|
||||||
|
grantsLogin: r.grantsLogin,
|
||||||
|
isActive: r.isActive,
|
||||||
|
parentId: r.parentId,
|
||||||
|
docRequirements: r.docRequirements.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
docType: d.docType,
|
||||||
|
isMandatory: d.isMandatory,
|
||||||
|
note: d.note,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <RanksManager ranks={rows} />;
|
||||||
|
}
|
||||||
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { SeafarerDocType } from "@prisma/client";
|
||||||
|
import type { RankRow } from "./ranks-manager";
|
||||||
|
import { addRankDocRequirement, removeRankDocRequirement } from "./actions";
|
||||||
|
|
||||||
|
// Listed (not imported as a runtime enum) to keep @prisma/client out of the client bundle.
|
||||||
|
const DOC_TYPES: { value: SeafarerDocType; label: string }[] = [
|
||||||
|
{ value: "STCW", label: "STCW" },
|
||||||
|
{ value: "AADHAAR", label: "Aadhaar" },
|
||||||
|
{ value: "PAN", label: "PAN" },
|
||||||
|
{ value: "PASSPORT", label: "Passport" },
|
||||||
|
{ value: "CDC", label: "CDC" },
|
||||||
|
{ value: "COC", label: "COC" },
|
||||||
|
{ value: "PHOTOGRAPH", label: "Photograph" },
|
||||||
|
{ value: "DRIVING_LICENSE", label: "Driving licence" },
|
||||||
|
{ value: "MEDICAL_FITNESS", label: "Medical fitness" },
|
||||||
|
{ value: "CONTRACT_LETTER", label: "Contract letter" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DOC_LABEL = Object.fromEntries(DOC_TYPES.map((d) => [d.value, d.label])) as Record<SeafarerDocType, string>;
|
||||||
|
|
||||||
|
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 function RankDocPanel({ rank }: { rank: RankRow | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
if (!rank) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-400">
|
||||||
|
Select a rank to manage its required documents.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdd(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
fd.set("rankId", rank!.id);
|
||||||
|
const result = await addRankDocRequirement(fd);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setPending(false);
|
||||||
|
} else {
|
||||||
|
setPending(false);
|
||||||
|
setAdding(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(id: string) {
|
||||||
|
await removeRankDocRequirement(id);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Required documents</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">{rank.code} — {rank.name}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setAdding((v) => !v)}
|
||||||
|
className="rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100"
|
||||||
|
>
|
||||||
|
{adding ? "Close" : "+ Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<form onSubmit={handleAdd} className="px-4 py-3 border-b border-neutral-100 bg-neutral-50/50 space-y-2">
|
||||||
|
<select name="docType" className={INPUT} defaultValue={DOC_TYPES[0].value}>
|
||||||
|
{DOC_TYPES.map((d) => (
|
||||||
|
<option key={d.value} value={d.value}>{d.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" name="isMandatory" defaultChecked className="h-4 w-4" />
|
||||||
|
Mandatory (uncheck for conditional)
|
||||||
|
</label>
|
||||||
|
<input name="note" className={INPUT} placeholder="Note (optional)" />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Saving…" : "Add requirement"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rank.docRequirements.length === 0 ? (
|
||||||
|
<p className="px-4 py-8 text-center text-sm text-neutral-400">No required documents for this rank.</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{rank.docRequirements.map((d) => (
|
||||||
|
<div key={d.id} className="flex items-center gap-2 px-4 py-2.5 border-b border-neutral-100 last:border-0">
|
||||||
|
<span className="text-sm text-neutral-900 flex-1">{DOC_LABEL[d.docType] ?? d.docType}</span>
|
||||||
|
{d.note && <span className="text-xs text-neutral-400 max-w-[10rem] truncate">{d.note}</span>}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
d.isMandatory
|
||||||
|
? "rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-xs font-medium"
|
||||||
|
: "rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs font-medium"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.isMandatory ? "Mandatory" : "Conditional"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemove(d.id)}
|
||||||
|
className="text-xs text-danger-700 hover:underline"
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { createRank, updateRank } from "./actions";
|
||||||
|
import type { RankRow } from "./ranks-manager";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
function RankFormFields({ rank, allRanks }: { rank?: RankRow; allRanks: RankRow[] }) {
|
||||||
|
const parentOptions = allRanks.filter((r) => !rank || r.id !== rank.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
|
||||||
|
<input name="code" defaultValue={rank?.code} required maxLength={16} placeholder="e.g. SDO" className={INPUT} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||||
|
<input name="name" defaultValue={rank?.name} required placeholder="e.g. Sr. Dredge Operator" className={INPUT} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reports to</label>
|
||||||
|
<select name="parentId" defaultValue={rank?.parentId ?? ""} className={INPUT}>
|
||||||
|
<option value="">— Top of the org —</option>
|
||||||
|
{parentOptions.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.code} — {r.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Category</label>
|
||||||
|
<select name="category" defaultValue={rank?.category ?? "OPERATIONAL"} className={INPUT}>
|
||||||
|
<option value="OPERATIONAL">Operational</option>
|
||||||
|
<option value="SUPPORT">Support</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 pt-1">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" name="isSeafarer" defaultChecked={rank?.isSeafarer ?? false} className="h-4 w-4" />
|
||||||
|
Seafarer (holds STCW / CDC etc.)
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" name="grantsLogin" defaultChecked={rank?.grantsLogin ?? false} className="h-4 w-4" />
|
||||||
|
Grants portal login
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
|
||||||
|
<input name="description" defaultValue={rank?.description ?? ""} className={INPUT} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddRankButton({ allRanks }: { allRanks: RankRow[] }) {
|
||||||
|
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 createRank(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.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add Rank
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Add Rank" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<RankFormFields allRanks={allRanks} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Creating…" : "Create Rank"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditRankButton({
|
||||||
|
rank,
|
||||||
|
allRanks,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
rank: RankRow;
|
||||||
|
allRanks: RankRow[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
fd.set("id", rank.id);
|
||||||
|
const result = await updateRank(fd);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
setPending(false);
|
||||||
|
} else {
|
||||||
|
setPending(false);
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog title="Edit Rank" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<RankFormFields rank={rank} allRanks={allRanks} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? "Saving…" : "Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { RankCategory, SeafarerDocType } from "@prisma/client";
|
||||||
|
import { AddRankButton, EditRankButton } from "./rank-form";
|
||||||
|
import { RankDocPanel } from "./rank-doc-panel";
|
||||||
|
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 { deleteRank, toggleRankActive } from "./actions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type DocReqRow = {
|
||||||
|
id: string;
|
||||||
|
docType: SeafarerDocType;
|
||||||
|
isMandatory: boolean;
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RankRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: RankCategory;
|
||||||
|
isSeafarer: boolean;
|
||||||
|
grantsLogin: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
parentId: string | null;
|
||||||
|
docRequirements: DocReqRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TreeNode = RankRow & { children: TreeNode[] };
|
||||||
|
|
||||||
|
function buildTree(ranks: RankRow[]): TreeNode[] {
|
||||||
|
const byId = new Map<string, TreeNode>();
|
||||||
|
ranks.forEach((r) => byId.set(r.id, { ...r, children: [] }));
|
||||||
|
const roots: TreeNode[] = [];
|
||||||
|
byId.forEach((node) => {
|
||||||
|
if (node.parentId && byId.has(node.parentId)) {
|
||||||
|
byId.get(node.parentId)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sortRec = (nodes: TreeNode[]) => {
|
||||||
|
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
nodes.forEach((n) => sortRec(n.children));
|
||||||
|
};
|
||||||
|
sortRec(roots);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankActionsMenu({ rank, allRanks }: { rank: RankRow; allRanks: RankRow[] }) {
|
||||||
|
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)}>
|
||||||
|
{rank.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
<EditRankButton rank={rank} allRanks={allRanks} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={`${rank.code} — ${rank.name}`}
|
||||||
|
onConfirm={() => deleteRank(rank.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toggleOpen}
|
||||||
|
onOpenChange={setToggleOpen}
|
||||||
|
title={rank.isActive ? `Deactivate ${rank.name}?` : `Activate ${rank.name}?`}
|
||||||
|
description={
|
||||||
|
rank.isActive
|
||||||
|
? `${rank.name} will be hidden from new requisitions and crew records.`
|
||||||
|
: `${rank.name} will become available again.`
|
||||||
|
}
|
||||||
|
confirmLabel={rank.isActive ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => toggleRankActive(rank.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankRowView({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
allRanks,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
node: TreeNode;
|
||||||
|
depth: number;
|
||||||
|
allRanks: RankRow[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const isSelected = node.id === selectedId;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 border-b border-neutral-100 last:border-0 cursor-pointer",
|
||||||
|
isSelected ? "bg-primary-50" : "hover:bg-neutral-50"
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: 12 + depth * 20 }}
|
||||||
|
onClick={() => onSelect(node.id)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs text-neutral-400 w-12 shrink-0">{node.code}</span>
|
||||||
|
<span className={cn("text-sm flex-1", node.isActive ? "text-neutral-900" : "text-neutral-400 line-through")}>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
{node.grantsLogin && (
|
||||||
|
<span className="rounded-full bg-primary-100 text-primary-700 px-2 py-0.5 text-xs font-medium">Login</span>
|
||||||
|
)}
|
||||||
|
{node.isSeafarer && (
|
||||||
|
<span className="rounded-full bg-neutral-100 text-neutral-600 px-2 py-0.5 text-xs font-medium">Seafarer</span>
|
||||||
|
)}
|
||||||
|
<span className="rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs">{node.category}</span>
|
||||||
|
<span className="text-xs text-neutral-400 w-16 text-right shrink-0">
|
||||||
|
{node.docRequirements.length} doc{node.docRequirements.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RankActionsMenu rank={node} allRanks={allRanks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<RankRowView
|
||||||
|
key={child.id}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
allRanks={allRanks}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RanksManager({ ranks }: { ranks: RankRow[] }) {
|
||||||
|
const tree = buildTree(ranks);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(ranks[0]?.id ?? null);
|
||||||
|
const selected = ranks.find((r) => r.id === selectedId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Ranks & Documents</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">
|
||||||
|
{ranks.length} ranks · the crew org chart and the documents each rank must hold
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AddRankButton allRanks={ranks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
{/* Rank hierarchy card */}
|
||||||
|
<div className="lg:col-span-3 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Rank hierarchy</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">
|
||||||
|
The org chart. <span className="text-primary-700 font-medium">Login</span> ranks (PM, Assistant PM, Site
|
||||||
|
In-charge) map to a portal account; all others are crew records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{tree.length === 0 ? (
|
||||||
|
<p className="px-4 py-12 text-center text-neutral-400">No ranks yet. Add a top-level rank to begin.</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{tree.map((node) => (
|
||||||
|
<RankRowView
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
depth={0}
|
||||||
|
allRanks={ranks}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required documents card */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<RankDocPanel rank={selected} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
||||||
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
||||||
SUBMITTED: "Submitted", REJECTED: "Rejected",
|
SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
SUPERUSER: "SuperUser",
|
SUPERUSER: "SuperUser",
|
||||||
AUDITOR: "Auditor",
|
AUDITOR: "Auditor",
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
|
SITE_STAFF: "Site Staff",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function SuperUserRequestsPage() {
|
export default async function SuperUserRequestsPage() {
|
||||||
|
|
|
||||||
99
App/app/(portal)/admin/terms/actions.ts
Normal file
99
App/app/(portal)/admin/terms/actions.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
// A category NAME — picked from the existing list or typed to create a new one.
|
||||||
|
categoryName: z.string().trim().min(1, "Category is required"),
|
||||||
|
text: z.string().trim().min(1, "Clause text is required"),
|
||||||
|
isDefault: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Result = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
async function guard(): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_terms")) {
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(formData: FormData) {
|
||||||
|
return schema.safeParse({
|
||||||
|
categoryName: formData.get("categoryName"),
|
||||||
|
text: formData.get("text"),
|
||||||
|
isDefault: formData.get("isDefault") === "on" || formData.get("isDefault") === "true",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a category by name (case-insensitive), creating it (appended to the end)
|
||||||
|
// if it doesn't exist — this is how new categories are added "along with clauses".
|
||||||
|
async function ensureCategory(name: string): Promise<string> {
|
||||||
|
const existing = await db.termsCategory.findFirst({
|
||||||
|
where: { name: { equals: name, mode: "insensitive" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) return existing.id;
|
||||||
|
const max = await db.termsCategory.aggregate({ _max: { sortOrder: true } });
|
||||||
|
const created = await db.termsCategory.create({
|
||||||
|
data: { name, sortOrder: (max._max.sortOrder ?? 0) + 1 },
|
||||||
|
});
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTerm(formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
const categoryId = await ensureCategory(parsed.data.categoryName);
|
||||||
|
await db.termsCondition.create({
|
||||||
|
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/terms");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTerm(id: string, formData: FormData): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
const categoryId = await ensureCategory(parsed.data.categoryName);
|
||||||
|
await db.termsCondition.update({
|
||||||
|
where: { id },
|
||||||
|
data: { categoryId, text: parsed.data.text, isDefault: parsed.data.isDefault },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/terms");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleTermActive(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } });
|
||||||
|
if (!term) return { error: "Not found" };
|
||||||
|
await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } });
|
||||||
|
revalidatePath("/admin/terms");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTerm(id: string): Promise<Result> {
|
||||||
|
const g = await guard();
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
// Safe to delete: POs keep their T&C as a JSON snapshot, so no PO references this row.
|
||||||
|
await db.termsCondition.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/terms");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
35
App/app/(portal)/admin/terms/page.tsx
Normal file
35
App/app/(portal)/admin/terms/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TermsTable } from "./terms-table";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Terms & Conditions" };
|
||||||
|
|
||||||
|
export default async function TermsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [terms, categories] = await Promise.all([
|
||||||
|
db.termsCondition.findMany({
|
||||||
|
orderBy: [{ category: { sortOrder: "asc" } }, { isActive: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||||
|
include: { category: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.termsCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }], select: { name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TermsTable
|
||||||
|
categoryNames={categories.map((c) => c.name)}
|
||||||
|
terms={terms.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
categoryName: t.category.name,
|
||||||
|
text: t.text,
|
||||||
|
isDefault: t.isDefault,
|
||||||
|
isActive: t.isActive,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
App/app/(portal)/admin/terms/terms-form.tsx
Normal file
122
App/app/(portal)/admin/terms/terms-form.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { createTerm, updateTerm } from "./actions";
|
||||||
|
|
||||||
|
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
export type TermRow = {
|
||||||
|
id: string;
|
||||||
|
categoryName: string;
|
||||||
|
text: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
|
||||||
|
<input
|
||||||
|
name="categoryName"
|
||||||
|
list="tc-category-list"
|
||||||
|
defaultValue={term?.categoryName ?? ""}
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
className={INPUT}
|
||||||
|
placeholder="Pick a category or type a new one…"
|
||||||
|
/>
|
||||||
|
<datalist id="tc-category-list">
|
||||||
|
{categoryNames.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">Type a new name to create a category.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label>
|
||||||
|
<textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||||
|
<input type="checkbox" name="isDefault" defaultChecked={term?.isDefault ?? false} className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" />
|
||||||
|
Pre-add to new POs by default
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await createTerm(new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
|
+ Add Clause
|
||||||
|
</button>
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields categoryNames={categoryNames} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditTermButton({
|
||||||
|
term,
|
||||||
|
categoryNames,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
term: TermRow;
|
||||||
|
categoryNames: string[];
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault(); setPending(true); setError("");
|
||||||
|
const result = await updateTerm(term.id, new FormData(e.currentTarget));
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Fields term={term} categoryNames={categoryNames} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
App/app/(portal)/admin/terms/terms-table.tsx
Normal file
134
App/app/(portal)/admin/terms/terms-table.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { AddTermButton, EditTermButton, type TermRow } from "./terms-form";
|
||||||
|
import { deleteTerm, toggleTermActive } from "./actions";
|
||||||
|
|
||||||
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
|
function TermActionsMenu({ term, categoryNames }: { term: TermRow; categoryNames: string[] }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
|
{term.isActive ? "Deactivate" : "Activate"}
|
||||||
|
</RowActionsItem>
|
||||||
|
<RowActionsSeparator />
|
||||||
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditTermButton term={term} categoryNames={categoryNames} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
label={term.text}
|
||||||
|
onConfirm={() => deleteTerm(term.id)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={toggleOpen}
|
||||||
|
onOpenChange={setToggleOpen}
|
||||||
|
title={term.isActive ? "Deactivate clause?" : "Activate clause?"}
|
||||||
|
description={
|
||||||
|
term.isActive
|
||||||
|
? "It will no longer be suggested in the PO Terms & Conditions editor."
|
||||||
|
: "It will be suggested in the PO Terms & Conditions editor again."
|
||||||
|
}
|
||||||
|
confirmLabel={term.isActive ? "Deactivate" : "Activate"}
|
||||||
|
onConfirm={() => toggleTermActive(term.id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TermsTable({ terms, categoryNames }: { terms: TermRow[]; categoryNames: string[] }) {
|
||||||
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
|
useTableControls<TermRow>({
|
||||||
|
rows: terms,
|
||||||
|
defaultSortKey: "categoryName",
|
||||||
|
searchText: (t) => [t.categoryName, t.text, t.isActive ? "active" : "inactive"].join(" "),
|
||||||
|
chipMatch: (t, chip) => {
|
||||||
|
if (chip.toLowerCase() === "active") return t.isActive;
|
||||||
|
if (chip.toLowerCase() === "inactive") return !t.isActive;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
sortValue: (t, key) => {
|
||||||
|
if (key === "isActive") return t.isActive ? "Active" : "Inactive";
|
||||||
|
if (key === "isDefault") return t.isDefault ? "Yes" : "No";
|
||||||
|
const val = t[key as keyof TermRow];
|
||||||
|
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Terms & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
SUPERUSER: "SuperUser",
|
SUPERUSER: "SuperUser",
|
||||||
AUDITOR: "Auditor",
|
AUDITOR: "Auditor",
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
|
SITE_STAFF: "Site Staff",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
||||||
|
|
|
||||||
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
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("/inventory/vendors");
|
revalidatePath("/catalogue/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("/inventory/vendors");
|
revalidatePath("/catalogue/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 { useState } from "react";
|
import { useEffect, 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,6 +113,44 @@ function ContactsEditor({ initial }: { initial?: ContactRow[] }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CAPTCHA popup — overlays the vendor form (which is itself an AdminDialog at z-50) so the
|
||||||
|
// CAPTCHA never grows the form and pushes its footer buttons off-screen. Sits at z-[60] and
|
||||||
|
// handles Escape on the capture phase so closing it does NOT also close the underlying form.
|
||||||
|
function CaptchaPopup({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") { e.stopImmediatePropagation(); onClose(); }
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey, true);
|
||||||
|
return () => document.removeEventListener("keydown", onKey, true);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-white shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">GSTIN CAPTCHA</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="text-neutral-400 hover:text-neutral-600 transition-colors text-lg leading-none"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendor?: VendorRow; suggestedVendorId?: string; simple?: boolean }) {
|
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 ?? "");
|
||||||
|
|
@ -149,13 +187,19 @@ 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();
|
||||||
if (data.error) { setGstError(data.error); setCaptchaStep("idle"); return; }
|
// Keep the popup open on error so the user sees it in context and can retry / get a new image.
|
||||||
|
if (data.error) { setGstError(data.error); setCaptchaStep("ready"); return; }
|
||||||
setName(data.tradeName || data.legalName);
|
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("idle"); }
|
} catch { setGstError("Lookup failed"); setCaptchaStep("ready"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the CAPTCHA popup without touching the vendor form fields.
|
||||||
|
function closeCaptcha() {
|
||||||
|
setCaptchaStep("idle"); setCaptchaAnswer(""); setGstError("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -183,31 +227,46 @@ function VendorFormFields({ vendor, suggestedVendorId, simple = false }: { vendo
|
||||||
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
{captchaStep === "loading" ? "Loading…" : "Look up"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{captchaStep === "ready" && captchaB64 && (
|
<CaptchaPopup open={captchaStep !== "idle"} onClose={closeCaptcha}>
|
||||||
<div className="mt-2 rounded-lg border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
{captchaStep === "loading" ? (
|
||||||
|
<p className="py-4 text-center text-sm text-neutral-500">Loading CAPTCHA…</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-neutral-600">Enter the code shown in the image:</p>
|
<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"
|
||||||
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={captchaStep === "verifying"}
|
||||||
|
className="w-28 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-mono tracking-widest focus:border-primary-500 focus:outline-none disabled:opacity-60"
|
||||||
autoFocus
|
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}
|
<button type="button" onClick={submitSearch} disabled={captchaAnswer.length !== 6 || captchaStep === "verifying"}
|
||||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-50">
|
||||||
Verify
|
{captchaStep === "verifying" ? "Verifying…" : "Verify"}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={fetchCaptcha} className="text-xs text-neutral-500 hover:underline">
|
<button type="button" onClick={fetchCaptcha} disabled={captchaStep === "verifying"}
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
{captchaStep === "verifying" && <p className="mt-1 text-xs text-neutral-500">Verifying…</p>}
|
</CaptchaPopup>
|
||||||
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
{/* Errors before the popup opens (e.g. invalid GSTIN) show inline; in-popup errors show in context above. */}
|
||||||
|
{captchaStep === "idle" && gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
|
||||||
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
{gstSuccess && <p className="mt-1 text-xs text-success-700">{gstSuccess}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -12,14 +14,21 @@ 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 },
|
||||||
|
|
@ -35,17 +44,28 @@ 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -65,6 +85,12 @@ 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,21 +3,38 @@
|
||||||
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.");
|
||||||
|
|
@ -26,8 +43,10 @@ export function ApprovalActions({
|
||||||
setPending(action);
|
setPending(action);
|
||||||
setError("");
|
setError("");
|
||||||
let result: { ok: true } | { error: string } | undefined;
|
let result: { ok: true } | { error: string } | undefined;
|
||||||
if (action === "approve") result = await approvePo({ poId, note });
|
// Approvals carry the Manager's advance decision (resolved amount, not %).
|
||||||
else if (action === "approve_note") result = await approvePo({ poId, note, withNote: true });
|
if (action === "approve") result = await approvePo({ poId, note, suggestedAdvancePayment: advance });
|
||||||
|
else if (action === "approve_note")
|
||||||
|
result = await approvePo({ poId, note, withNote: true, suggestedAdvancePayment: advance });
|
||||||
else if (action === "reject") result = await rejectPo({ poId, note });
|
else if (action === "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 });
|
||||||
|
|
@ -45,6 +64,37 @@ 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,11 +4,14 @@ 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 { PoTermsEditor } from "@/components/po/po-terms-editor";
|
||||||
|
import type { CatalogueCategory, PoTerm } from "@/lib/terms";
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +42,9 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
|
termsCatalogue: CatalogueCategory[];
|
||||||
|
initialTerms: PoTerm[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
|
|
@ -51,12 +57,13 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
||||||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }: Props) {
|
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms }: Props) {
|
||||||
const router = useRouter();
|
const 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;
|
||||||
|
|
@ -98,6 +105,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
||||||
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) {
|
||||||
|
|
@ -230,21 +238,14 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
||||||
<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>
|
||||||
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
<DeliveryLocationField options={deliveryOptions} current={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>
|
||||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT}>
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||||
<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 */}
|
||||||
|
|
@ -258,38 +259,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
||||||
{/* 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>
|
||||||
<div className="space-y-2.5">
|
<PoTermsEditor value={terms} onChange={setTerms} catalogue={termsCatalogue} accent="amber" />
|
||||||
<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,6 +4,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 { 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(
|
||||||
|
|
@ -68,6 +69,10 @@ 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
|
||||||
|
|
@ -130,6 +135,7 @@ 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,6 +6,9 @@ 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";
|
||||||
|
|
||||||
|
|
@ -29,7 +32,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
|
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -52,12 +55,17 @@ 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 } } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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 termsCatalogue = await getTermsCatalogue();
|
||||||
|
const savedTerms = parsePoTerms(po.terms);
|
||||||
|
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -98,12 +106,20 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
|
termsCatalogue={termsCatalogue}
|
||||||
|
initialTerms={initialTerms}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 md:mt-6">
|
<div className="mt-4 md:mt-6">
|
||||||
{hasSignature ? (
|
{hasSignature ? (
|
||||||
<ApprovalActions poId={po.id} poStatus={po.status} />
|
<ApprovalActions
|
||||||
|
poId={po.id}
|
||||||
|
poStatus={po.status}
|
||||||
|
totalAmount={Number(po.totalAmount)}
|
||||||
|
currency={po.currency}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
|
<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>
|
||||||
|
|
|
||||||
120
App/app/(portal)/approvals/crewing-approvals.tsx
Normal file
120
App/app/(portal)/approvals/crewing-approvals.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import {
|
||||||
|
approveSalary,
|
||||||
|
returnSalary,
|
||||||
|
selectCandidate,
|
||||||
|
returnSelection,
|
||||||
|
approveInterviewWaiver,
|
||||||
|
declineInterviewWaiver,
|
||||||
|
} from "../crewing/applications/actions";
|
||||||
|
import { decideLeave } from "../crewing/leave/actions";
|
||||||
|
import { approveAppraisal } from "../crewing/appraisals/actions";
|
||||||
|
|
||||||
|
export type CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER" | "LEAVE" | "APPRAISAL";
|
||||||
|
|
||||||
|
export type CrewApprovalItem = {
|
||||||
|
id: string; // applicationId, or leaveRequestId for LEAVE
|
||||||
|
kind: CrewApprovalKind;
|
||||||
|
candidateName: string;
|
||||||
|
rank: string;
|
||||||
|
requisitionCode: string;
|
||||||
|
detail: string;
|
||||||
|
link: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave", APPRAISAL: "Appraisal" };
|
||||||
|
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning", APPRAISAL: "default" } as const;
|
||||||
|
|
||||||
|
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
|
||||||
|
SALARY: approveSalary,
|
||||||
|
SELECTION: selectCandidate,
|
||||||
|
WAIVER: approveInterviewWaiver,
|
||||||
|
LEAVE: (id) => decideLeave(id, true),
|
||||||
|
APPRAISAL: (id) => approveAppraisal(id, true),
|
||||||
|
};
|
||||||
|
const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
|
||||||
|
SALARY: returnSalary,
|
||||||
|
SELECTION: returnSelection,
|
||||||
|
WAIVER: declineInterviewWaiver,
|
||||||
|
LEAVE: (id, reason) => decideLeave(id, false, reason),
|
||||||
|
APPRAISAL: (id, reason) => approveAppraisal(id, false, reason),
|
||||||
|
};
|
||||||
|
|
||||||
|
function Row({ item }: { item: CrewApprovalItem }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [returnOpen, setReturnOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await approveFn[item.kind](item.id);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else router.refresh();
|
||||||
|
}
|
||||||
|
async function doReturn(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await returnFn[item.kind](item.id, reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link>
|
||||||
|
<span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
|
||||||
|
<button onClick={() => setReturnOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Return</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||||
|
<AdminDialog title={`Return ${KIND_LABEL[item.kind].toLowerCase()}`} open={returnOpen} onClose={() => setReturnOpen(false)}>
|
||||||
|
<form onSubmit={doReturn} className="space-y-4 text-left">
|
||||||
|
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setReturnOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Return</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900 mb-1">Crewing approvals</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mb-3">{items.length} item{items.length === 1 ? "" : "s"} awaiting your decision</p>
|
||||||
|
<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>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Kind</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Candidate</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Detail</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-100">
|
||||||
|
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ 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 { ApprovalsSearch } from "./approvals-search";
|
import { ApprovalsSearch } from "./approvals-search";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { CrewingApprovals, type CrewApprovalItem, type CrewApprovalKind } from "./crewing-approvals";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -49,6 +51,88 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Crewing approvals (spec §8.13 R8) — the same unified Manager queue. Pending
|
||||||
|
// SALARY / SELECTION / WAIVER gates surface here alongside POs.
|
||||||
|
const role = session.user.role;
|
||||||
|
const showCrewing =
|
||||||
|
CREWING_ENABLED &&
|
||||||
|
(hasPermission(role, "approve_salary_structure") ||
|
||||||
|
hasPermission(role, "select_candidate") ||
|
||||||
|
hasPermission(role, "approve_interview_waiver") ||
|
||||||
|
hasPermission(role, "decide_leave") ||
|
||||||
|
hasPermission(role, "approve_appraisal"));
|
||||||
|
|
||||||
|
const crewGates = showCrewing
|
||||||
|
? await db.applicationGate.findMany({
|
||||||
|
where: { result: "PENDING", gate: { in: ["SALARY", "SELECTION", "WAIVER"] } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
application: {
|
||||||
|
include: {
|
||||||
|
crewMember: { select: { name: true } },
|
||||||
|
requisition: { select: { code: true, rank: { select: { name: true } } } },
|
||||||
|
salaryStructures: { where: { approvedById: null }, orderBy: { createdAt: "desc" }, take: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const crewItems: CrewApprovalItem[] = crewGates.map((g) => {
|
||||||
|
const sal = g.application.salaryStructures[0];
|
||||||
|
const detail =
|
||||||
|
g.gate === "SALARY" && sal
|
||||||
|
? `${sal.currency} ${Number(sal.basic).toLocaleString("en-IN")} / ${sal.rateBasis.toLowerCase()}`
|
||||||
|
: g.gate === "WAIVER"
|
||||||
|
? "Returning crew — interview waiver"
|
||||||
|
: "Interview cleared";
|
||||||
|
return {
|
||||||
|
id: g.applicationId,
|
||||||
|
kind: g.gate as CrewApprovalKind,
|
||||||
|
candidateName: g.application.crewMember.name,
|
||||||
|
rank: g.application.requisition.rank.name,
|
||||||
|
requisitionCode: g.application.requisition.code,
|
||||||
|
detail,
|
||||||
|
link: `/crewing/applications/${g.applicationId}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending leave requests (Manager decides) — the §8.13 "Leave" queue kind.
|
||||||
|
const leaveItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "decide_leave"))
|
||||||
|
? (await db.leaveRequest.findMany({
|
||||||
|
where: { status: "APPLIED" },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||||
|
})).map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
kind: "LEAVE" as CrewApprovalKind,
|
||||||
|
candidateName: l.assignment.crewMember.name,
|
||||||
|
rank: l.assignment.rank.name,
|
||||||
|
requisitionCode: `${l.fromDate.toLocaleDateString()}–${l.toDate.toLocaleDateString()}`,
|
||||||
|
detail: l.type.toLowerCase(),
|
||||||
|
link: "/crewing/leave",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// MPO-verified appraisals awaiting Manager approval (§8.13/§8.14).
|
||||||
|
const appraisalItems: CrewApprovalItem[] = (showCrewing && hasPermission(role, "approve_appraisal"))
|
||||||
|
? (await db.appraisal.findMany({
|
||||||
|
where: { status: "MPO_VERIFIED" },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||||
|
})).map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
kind: "APPRAISAL" as CrewApprovalKind,
|
||||||
|
candidateName: a.assignment.crewMember.name,
|
||||||
|
rank: a.assignment.rank.name,
|
||||||
|
requisitionCode: a.period,
|
||||||
|
detail: "MPO-verified appraisal",
|
||||||
|
link: "/approvals",
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const allCrewItems = [...crewItems, ...leaveItems, ...appraisalItems];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -137,6 +221,8 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = `/inventory/items/${id}`;
|
const baseHref = `/catalogue/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="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
<Link href="/catalogue/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 ? `/inventory/items?siteId=${id}` : "/inventory/items");
|
router.push(id ? `/catalogue/items?siteId=${id}` : "/catalogue/items");
|
||||||
}}
|
}}
|
||||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
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={`/inventory/vendors/${vendor.vendorId}`}
|
href={`/catalogue/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 /inventory/items
|
// canManage lets managers/admins see the Edit/Delete controls even from /catalogue/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="/inventory/vendors" className="hover:text-neutral-700">Vendors</Link>
|
<Link href="/catalogue/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>
|
||||||
|
|
@ -41,6 +41,7 @@ export function VendorsTable({
|
||||||
? vendors.filter(
|
? vendors.filter(
|
||||||
(v) =>
|
(v) =>
|
||||||
v.name.toLowerCase().includes(q) ||
|
v.name.toLowerCase().includes(q) ||
|
||||||
|
(v.vendorId && v.vendorId.toLowerCase().includes(q)) ||
|
||||||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
||||||
(v.address && v.address.toLowerCase().includes(q))
|
(v.address && v.address.toLowerCase().includes(q))
|
||||||
)
|
)
|
||||||
|
|
@ -67,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 ? `/inventory/vendors?siteId=${id}` : "/inventory/vendors");
|
router.push(id ? `/catalogue/vendors?siteId=${id}` : "/catalogue/vendors");
|
||||||
}}
|
}}
|
||||||
className="flex-1 max-w-xs rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
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"
|
||||||
>
|
>
|
||||||
|
|
@ -89,7 +90,7 @@ export function VendorsTable({
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search by name, GSTIN or address…"
|
placeholder="Search by name, ID, GSTIN or address…"
|
||||||
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
|
|
@ -148,9 +149,12 @@ 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={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
<Link href={`/catalogue/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||||
{vendor.name}
|
{vendor.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{vendor.vendorId && (
|
||||||
|
<span className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-500">{vendor.vendorId}</span>
|
||||||
|
)}
|
||||||
{vendor.isVerified && (
|
{vendor.isVerified && (
|
||||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||||
)}
|
)}
|
||||||
144
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal file
144
App/app/(portal)/crewing/applications/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft, Check } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ApplicationActionCard } from "../application-action-card";
|
||||||
|
import { STAGE_ORDER, STAGE_LABEL, STAGE_VARIANT, stageIndex } from "../application-ui";
|
||||||
|
import { experienceLabel } from "../../candidates/candidate-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Application" };
|
||||||
|
|
||||||
|
export default async function ApplicationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
if (!hasPermission(role, "view_requisitions") && !hasPermission(role, "manage_candidates")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const app = await db.application.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
requisition: { include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
||||||
|
crewMember: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } } },
|
||||||
|
gates: true,
|
||||||
|
salaryStructures: { orderBy: { createdAt: "desc" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!app) notFound();
|
||||||
|
|
||||||
|
const gate = (t: string) => app.gates.find((g) => g.gate === t);
|
||||||
|
const salaryPending = gate("SALARY")?.result === "PENDING";
|
||||||
|
const waiverPending = gate("WAIVER")?.result === "PENDING";
|
||||||
|
const selectionPending = gate("SELECTION")?.result === "PENDING";
|
||||||
|
const proposed = app.salaryStructures.find((s) => !s.approvedById) ?? app.salaryStructures[0] ?? null;
|
||||||
|
|
||||||
|
const loc = app.requisition.vessel?.name ?? app.requisition.site?.name ?? "—";
|
||||||
|
const curIdx = stageIndex(app.stage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href={`/crewing/requisitions/${app.requisition.id}/pipeline`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Pipeline · {app.requisition.code}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{app.crewMember.name}</h1>
|
||||||
|
<Badge variant={STAGE_VARIANT[app.stage]}>{STAGE_LABEL[app.stage]}</Badge>
|
||||||
|
{app.crewMember.type === "EX_HAND" && (
|
||||||
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 -mt-4 mb-6">
|
||||||
|
{app.requisition.rank.name} · {loc} · <span className="font-mono">{app.requisition.code}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 7-step stepper */}
|
||||||
|
<div className="mb-6 flex flex-wrap gap-2">
|
||||||
|
{STAGE_ORDER.map((s, i) => {
|
||||||
|
const done = curIdx > i || app.stage === "ONBOARDED";
|
||||||
|
const current = curIdx === i;
|
||||||
|
return (
|
||||||
|
<div key={s} className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium",
|
||||||
|
done ? "bg-success-100 text-success-700" : current ? "bg-primary-100 text-primary-700" : "bg-neutral-100 text-neutral-400"
|
||||||
|
)}>
|
||||||
|
{done && <Check className="h-3 w-3" />}
|
||||||
|
{STAGE_LABEL[s]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Adaptive action card */}
|
||||||
|
<ApplicationActionCard
|
||||||
|
id={app.id}
|
||||||
|
stage={app.stage}
|
||||||
|
isExHand={app.crewMember.type === "EX_HAND"}
|
||||||
|
interviewResult={app.interviewResult}
|
||||||
|
interviewWaived={app.interviewWaived}
|
||||||
|
rejectedReason={app.rejectedReason}
|
||||||
|
salaryPending={salaryPending}
|
||||||
|
waiverPending={waiverPending}
|
||||||
|
selectionPending={selectionPending}
|
||||||
|
employeeNo={app.crewMember.employeeId}
|
||||||
|
salary={proposed ? {
|
||||||
|
rateBasis: proposed.rateBasis,
|
||||||
|
basic: Number(proposed.basic),
|
||||||
|
victualingPerDay: Number(proposed.victualingPerDay),
|
||||||
|
currency: proposed.currency,
|
||||||
|
approved: Boolean(proposed.approvedById),
|
||||||
|
} : null}
|
||||||
|
perms={{
|
||||||
|
manage: hasPermission(role, "manage_candidates"),
|
||||||
|
recordReference: hasPermission(role, "record_reference_check"),
|
||||||
|
recordInterview: hasPermission(role, "record_interview_result"),
|
||||||
|
requestWaiver: hasPermission(role, "request_interview_waiver"),
|
||||||
|
approveSalary: hasPermission(role, "approve_salary_structure"),
|
||||||
|
approveWaiver: hasPermission(role, "approve_interview_waiver"),
|
||||||
|
select: hasPermission(role, "select_candidate"),
|
||||||
|
onboard: hasPermission(role, "onboard_crew"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Profile */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden h-fit">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-neutral-100">
|
||||||
|
{([
|
||||||
|
["Rank applied", app.crewMember.appliedRank?.name ?? app.requisition.rank.name],
|
||||||
|
["Last rank held", app.crewMember.currentRank?.name ?? "—"],
|
||||||
|
["Experience", experienceLabel(app.crewMember.experienceMonths)],
|
||||||
|
["Source", app.crewMember.source],
|
||||||
|
] as [string, string][]).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
||||||
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
||||||
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{app.crewMember.type === "EX_HAND" && (
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100 text-xs text-purple-700 bg-purple-50">
|
||||||
|
Returning crew — prior docs/bank/tour on file; interview may be waived with Manager approval.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100">
|
||||||
|
<Link href={`/crewing/candidates/${app.crewMember.id}`} className="text-sm text-primary-600 hover:underline">
|
||||||
|
View full candidate profile →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
680
App/app/(portal)/crewing/applications/actions.ts
Normal file
680
App/app/(portal)/crewing/applications/actions.ts
Normal file
|
|
@ -0,0 +1,680 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import {
|
||||||
|
canPerformAction,
|
||||||
|
canReject,
|
||||||
|
getTransition,
|
||||||
|
type ApplicationAction,
|
||||||
|
} from "@/lib/application-pipeline";
|
||||||
|
import { getManagerRecipients } from "@/lib/requisition-service";
|
||||||
|
import { generateEmployeeId } from "@/lib/employee-number";
|
||||||
|
import { maybeCreateSiteStaffLogin } from "@/lib/crew-login";
|
||||||
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import { SalaryRateBasis } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const appPath = (id: string) => `/crewing/applications/${id}`;
|
||||||
|
|
||||||
|
async function guard(
|
||||||
|
permission: Permission
|
||||||
|
): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load an application with the bits the actions need; null if missing.
|
||||||
|
async function loadApp(id: string) {
|
||||||
|
return db.application.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
requisition: { select: { id: true, status: true, code: true, rank: { select: { name: true } } } },
|
||||||
|
crewMember: { select: { id: true, name: true, type: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidateApp(applicationId: string, requisitionId: string) {
|
||||||
|
revalidatePath(appPath(applicationId));
|
||||||
|
revalidatePath(`/crewing/requisitions/${requisitionId}/pipeline`);
|
||||||
|
revalidatePath("/approvals");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add a candidate to a requisition's pipeline ────────────────────────────────
|
||||||
|
|
||||||
|
export async function addApplication(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const requisitionId = formData.get("requisitionId") as string;
|
||||||
|
const crewMemberId = formData.get("crewMemberId") as string;
|
||||||
|
if (!requisitionId || !crewMemberId) return { error: "Requisition and candidate are required" };
|
||||||
|
|
||||||
|
const [requisition, candidate, existing] = await Promise.all([
|
||||||
|
db.requisition.findUnique({ where: { id: requisitionId }, select: { status: true } }),
|
||||||
|
db.crewMember.findUnique({ where: { id: crewMemberId }, select: { type: true } }),
|
||||||
|
db.application.findUnique({ where: { requisitionId_crewMemberId: { requisitionId, crewMemberId } }, select: { id: true } }),
|
||||||
|
]);
|
||||||
|
if (!requisition) return { error: "Requisition not found" };
|
||||||
|
if (!candidate) return { error: "Candidate not found" };
|
||||||
|
if (requisition.status === "CANCELLED" || requisition.status === "FILLED") {
|
||||||
|
return { error: `Cannot add candidates to a ${requisition.status} requisition` };
|
||||||
|
}
|
||||||
|
if (existing) return { error: "This candidate is already in the pipeline for this requisition" };
|
||||||
|
|
||||||
|
const application = await db.application.create({
|
||||||
|
data: {
|
||||||
|
requisitionId,
|
||||||
|
crewMemberId,
|
||||||
|
type: candidate.type,
|
||||||
|
stage: "SHORTLISTED",
|
||||||
|
actions: { create: { actionType: "APPLICATION_CREATED", actorId: g.userId, crewMemberId, requisitionId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// First candidate moves the requisition from OPEN into sourcing.
|
||||||
|
if (requisition.status === "OPEN") {
|
||||||
|
await db.requisition.update({
|
||||||
|
where: { id: requisitionId },
|
||||||
|
data: {
|
||||||
|
status: "SHORTLISTING",
|
||||||
|
actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SHORTLISTING" } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidateApp(application.id, requisitionId);
|
||||||
|
return { ok: true, id: application.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sourcing stage advances (MPO/Manager) ──────────────────────────────────────
|
||||||
|
// start_competency, verify_competency, propose_accepted. verify_docs / approve_salary /
|
||||||
|
// select have dedicated actions below.
|
||||||
|
|
||||||
|
export async function advanceStage(id: string, action: ApplicationAction): Promise<ActionResult> {
|
||||||
|
if (action !== "start_competency" && action !== "verify_competency" && action !== "propose_accepted") {
|
||||||
|
return { error: "Use the dedicated action for this step" };
|
||||||
|
}
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
const transition = getTransition(app.stage, action);
|
||||||
|
if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
|
||||||
|
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({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
stage: transition.to,
|
||||||
|
// Completing the competency & references stage records its gate.
|
||||||
|
...(action === "verify_competency"
|
||||||
|
? { gates: { create: { gate: "COMPETENCY_REFERENCE", result: "VERIFIED", decidedById: g.userId } } }
|
||||||
|
: {}),
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: action === "verify_competency" ? "GATE_PASSED" : action === "propose_accepted" ? "CANDIDATE_PROPOSED" : "GATE_PASSED",
|
||||||
|
actorId: g.userId,
|
||||||
|
crewMemberId: app.crewMemberId,
|
||||||
|
metadata: { from: app.stage, to: transition.to },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceSchema = z.object({
|
||||||
|
refereeName: z.string().trim().min(1, "Referee name is required"),
|
||||||
|
refereeContact: z.string().optional(),
|
||||||
|
outcome: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function recordReferenceCheck(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("record_reference_check");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("applicationId") as string;
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
||||||
|
const parsed = referenceSchema.safeParse({
|
||||||
|
refereeName: formData.get("refereeName"),
|
||||||
|
refereeContact: (formData.get("refereeContact") as string) || undefined,
|
||||||
|
outcome: (formData.get("outcome") as string) || undefined,
|
||||||
|
note: (formData.get("note") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
|
await db.referenceCheck.create({
|
||||||
|
data: {
|
||||||
|
applicationId: id,
|
||||||
|
refereeName: parsed.data.refereeName,
|
||||||
|
refereeContact: parsed.data.refereeContact ?? null,
|
||||||
|
outcome: parsed.data.outcome ?? null,
|
||||||
|
note: parsed.data.note ?? null,
|
||||||
|
recordedById: g.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: { actionType: "REFERENCE_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMemberId },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DOC_VERIFICATION: capture bank/EPF + verify documents → SALARY_AGREEMENT ────
|
||||||
|
|
||||||
|
const docsSchema = z.object({
|
||||||
|
accountName: z.string().optional(),
|
||||||
|
accountNumber: z.string().optional(),
|
||||||
|
ifsc: z.string().optional(),
|
||||||
|
bankName: z.string().optional(),
|
||||||
|
uan: z.string().optional(),
|
||||||
|
aadhaarLast4: z.string().optional(),
|
||||||
|
pfNumber: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function verifyDocuments(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("applicationId") as string;
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
const transition = getTransition(app.stage, "verify_docs");
|
||||||
|
if (!transition) return { error: `Cannot verify documents from ${app.stage}` };
|
||||||
|
|
||||||
|
const parsed = docsSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
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) => {
|
||||||
|
// Capture bank / EPF (PII — encryption deferred to Phase 4).
|
||||||
|
await tx.bankDetail.upsert({
|
||||||
|
where: { crewMemberId },
|
||||||
|
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||||
|
create: { crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||||
|
});
|
||||||
|
await tx.epfDetail.upsert({
|
||||||
|
where: { crewMemberId },
|
||||||
|
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||||
|
create: { crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||||
|
});
|
||||||
|
await tx.application.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
stage: transition.to,
|
||||||
|
gates: {
|
||||||
|
create: { gate: "DOCUMENT", result: "VERIFIED", decidedById: g.userId, note: d.note ?? null },
|
||||||
|
},
|
||||||
|
actions: { create: { actionType: "GATE_PASSED", actorId: g.userId, crewMemberId, metadata: { gate: "DOCUMENT" } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SALARY_AGREEMENT: MPO agrees → Manager approves ────────────────────────────
|
||||||
|
|
||||||
|
const salarySchema = z.object({
|
||||||
|
rateBasis: z.nativeEnum(SalaryRateBasis).default("MONTHLY"),
|
||||||
|
basic: z.coerce.number().positive("Basic must be greater than 0"),
|
||||||
|
victualingPerDay: z.coerce.number().min(0).default(0),
|
||||||
|
currency: z.string().default("INR"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function agreeSalary(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("applicationId") as string;
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (app.stage !== "SALARY_AGREEMENT") return { error: `Salary can only be agreed at SALARY_AGREEMENT (currently ${app.stage})` };
|
||||||
|
|
||||||
|
const parsed = salarySchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// One live proposed structure per application — replace any prior draft.
|
||||||
|
await tx.salaryStructure.deleteMany({ where: { applicationId: id, approvedById: null } });
|
||||||
|
await tx.salaryStructure.create({
|
||||||
|
data: {
|
||||||
|
applicationId: id,
|
||||||
|
rateBasis: d.rateBasis,
|
||||||
|
basic: d.basic,
|
||||||
|
victualingPerDay: d.victualingPerDay,
|
||||||
|
currency: d.currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Salary gate goes PENDING for the Manager's queue.
|
||||||
|
await tx.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
|
||||||
|
update: { result: "PENDING", decidedById: null, note: null },
|
||||||
|
create: { applicationId: id, gate: "SALARY", result: "PENDING" },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({
|
||||||
|
data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const managers = await getManagerRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "SALARY_FOR_APPROVAL",
|
||||||
|
recipients: managers,
|
||||||
|
subject: `Salary for approval — ${app.crewMember.name}`,
|
||||||
|
body: `${app.crewMember.name}'s salary for ${app.requisition.rank.name} (${app.requisition.code}) is ready for your approval.`,
|
||||||
|
link: appPath(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveSalary(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("approve_salary_structure");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (!canPerformAction(app.stage, "approve_salary", g.role)) return { error: `Cannot approve salary from ${app.stage}` };
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.salaryStructure.updateMany({ where: { applicationId: id, approvedById: null }, data: { approvedById: g.userId } });
|
||||||
|
await tx.applicationGate.update({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "SALARY" } },
|
||||||
|
data: { result: "VERIFIED", decidedById: g.userId },
|
||||||
|
});
|
||||||
|
await tx.application.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
stage: "PROPOSED",
|
||||||
|
actions: { create: { actionType: "SALARY_APPROVED", actorId: g.userId, crewMemberId: app.crewMember.id } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnSalary(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("approve_salary_structure");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!reason?.trim()) return { error: "A reason is required to return for revision" };
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
||||||
|
await db.applicationGate.updateMany({
|
||||||
|
where: { applicationId: id, gate: "SALARY" },
|
||||||
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
|
});
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
|
||||||
|
});
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── INTERVIEW: MPO records result / requests waiver → Manager selects ──────────
|
||||||
|
|
||||||
|
export async function recordInterviewResult(id: string, accepted: boolean, note?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("record_interview_result");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (app.stage !== "INTERVIEW") return { error: `Interview results are recorded at the INTERVIEW stage (currently ${app.stage})` };
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
// A failed interview rejects the application.
|
||||||
|
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, note?.trim() || "Interview not passed");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.application.update({ where: { id }, data: { interviewResult: "ACCEPTED" } });
|
||||||
|
await tx.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "INTERVIEW" } },
|
||||||
|
update: { result: "VERIFIED", decidedById: g.userId },
|
||||||
|
create: { applicationId: id, gate: "INTERVIEW", result: "VERIFIED", decidedById: g.userId },
|
||||||
|
});
|
||||||
|
// Selection now pending for the Manager.
|
||||||
|
await tx.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||||
|
update: { result: "PENDING", decidedById: null },
|
||||||
|
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: note?.trim() || null } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const managers = await getManagerRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "SELECTION_FOR_APPROVAL",
|
||||||
|
recipients: managers,
|
||||||
|
subject: `Selection for approval — ${app.crewMember.name}`,
|
||||||
|
body: `${app.crewMember.name} passed the interview for ${app.requisition.rank.name} (${app.requisition.code}) and awaits your selection.`,
|
||||||
|
link: appPath(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestInterviewWaiver(id: string, note?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("request_interview_waiver");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (app.crewMember.type !== "EX_HAND") return { error: "Interview waivers are only for returning crew (ex-hands)" };
|
||||||
|
if (app.stage !== "INTERVIEW") return { error: `Waivers are requested at the INTERVIEW stage (currently ${app.stage})` };
|
||||||
|
|
||||||
|
await db.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
|
||||||
|
update: { result: "PENDING", decidedById: null, note: note?.trim() || null },
|
||||||
|
create: { applicationId: id, gate: "WAIVER", result: "PENDING", note: note?.trim() || null },
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
|
||||||
|
|
||||||
|
const managers = await getManagerRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "WAIVER_REQUESTED",
|
||||||
|
recipients: managers,
|
||||||
|
subject: `Interview waiver requested — ${app.crewMember.name}`,
|
||||||
|
body: `An interview waiver is requested for returning crew ${app.crewMember.name} (${app.requisition.code}). Approve or decline.`,
|
||||||
|
link: appPath(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveInterviewWaiver(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("approve_interview_waiver");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.application.update({ where: { id }, data: { interviewWaived: true } });
|
||||||
|
await tx.applicationGate.update({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "WAIVER" } },
|
||||||
|
data: { result: "VERIFIED", decidedById: g.userId },
|
||||||
|
});
|
||||||
|
// Waived → selection is now pending.
|
||||||
|
await tx.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||||
|
update: { result: "PENDING", decidedById: null },
|
||||||
|
create: { applicationId: id, gate: "SELECTION", result: "PENDING" },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({ data: { actionType: "WAIVER_APPROVED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function declineInterviewWaiver(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("approve_interview_waiver");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!reason?.trim()) return { error: "A reason is required to decline" };
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
||||||
|
await db.applicationGate.updateMany({
|
||||||
|
where: { applicationId: id, gate: "WAIVER" },
|
||||||
|
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
|
||||||
|
});
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
|
||||||
|
});
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectCandidate(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("select_candidate");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (!canPerformAction(app.stage, "select", g.role)) return { error: `Cannot select from ${app.stage}` };
|
||||||
|
|
||||||
|
const full = await db.application.findUniqueOrThrow({ where: { id }, select: { interviewResult: true, interviewWaived: true } });
|
||||||
|
if (full.interviewResult !== "ACCEPTED" && !full.interviewWaived) {
|
||||||
|
return { error: "Record an interview result (or a Manager-approved waiver) before selecting" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.applicationGate.upsert({
|
||||||
|
where: { applicationId_gate: { applicationId: id, gate: "SELECTION" } },
|
||||||
|
update: { result: "VERIFIED", decidedById: g.userId },
|
||||||
|
create: { applicationId: id, gate: "SELECTION", result: "VERIFIED", decidedById: g.userId },
|
||||||
|
});
|
||||||
|
await tx.application.update({
|
||||||
|
where: { id },
|
||||||
|
data: { stage: "SELECTED", actions: { create: { actionType: "CANDIDATE_SELECTED", actorId: g.userId, crewMemberId: app.crewMember.id } } },
|
||||||
|
});
|
||||||
|
// The requisition moves to SELECTED (onboarding flips it to FILLED in 3c).
|
||||||
|
await tx.requisition.update({
|
||||||
|
where: { id: app.requisition.id },
|
||||||
|
data: { status: "SELECTED", actions: { create: { actionType: "REQUISITION_ADVANCED", actorId: g.userId, metadata: { to: "SELECTED" } } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnSelection(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("select_candidate");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!reason?.trim()) return { error: "A reason is required to return" };
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
|
||||||
|
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.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()}` } });
|
||||||
|
});
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rejection (orthogonal) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function rejectApplicationInternal(
|
||||||
|
id: string,
|
||||||
|
crewMemberId: string,
|
||||||
|
requisitionId: string,
|
||||||
|
userId: string,
|
||||||
|
reason: string
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
await db.application.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
stage: "REJECTED",
|
||||||
|
rejectedReason: reason,
|
||||||
|
rejectedAt: new Date(),
|
||||||
|
actions: { create: { actionType: "APPLICATION_REJECTED", actorId: userId, crewMemberId, note: reason } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidateApp(id, requisitionId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectApplication(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!reason?.trim()) return { error: "A reason is required to reject" };
|
||||||
|
|
||||||
|
const app = await loadApp(id);
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
if (!canReject(app.stage, g.role)) return { error: `Cannot reject from ${app.stage}` };
|
||||||
|
|
||||||
|
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onboarding (Phase 3c, Epic D) ──────────────────────────────────────────────
|
||||||
|
// One transaction off a SELECTED application: assign the employee number, create
|
||||||
|
// the ACTIVE assignment, bind the approved salary, flip the application to
|
||||||
|
// ONBOARDED and the requisition to FILLED, and promote the candidate to EMPLOYEE.
|
||||||
|
// Login-account creation for management ranks is a deferred follow-up.
|
||||||
|
|
||||||
|
export async function onboardCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("onboard_crew");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("applicationId") as string;
|
||||||
|
const joiningStr = formData.get("joiningDate") as string;
|
||||||
|
if (!joiningStr) return { error: "A joining date is required" };
|
||||||
|
|
||||||
|
const app = await db.application.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
requisition: { select: { id: true, rankId: true, vesselId: true, siteId: true } },
|
||||||
|
crewMember: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!app) return { error: "Application not found" };
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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 employeeId = await generateEmployeeId(tx);
|
||||||
|
const assignment = await tx.crewAssignment.create({
|
||||||
|
data: {
|
||||||
|
status: "ACTIVE",
|
||||||
|
signOnDate: joiningDate,
|
||||||
|
crewMemberId: app.crewMember.id,
|
||||||
|
rankId: app.requisition.rankId,
|
||||||
|
vesselId: app.requisition.vesselId,
|
||||||
|
siteId: app.requisition.siteId,
|
||||||
|
requisitionId: app.requisition.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Bind the Manager-approved salary structure to the new assignment.
|
||||||
|
await tx.salaryStructure.updateMany({
|
||||||
|
where: { applicationId: id, approvedById: { not: null }, assignmentId: null },
|
||||||
|
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({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
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({
|
||||||
|
where: { id: app.requisition.id },
|
||||||
|
data: { status: "FILLED", filledAt: new Date(), actions: { create: { actionType: "REQUISITION_FILLED", actorId: g.userId } } },
|
||||||
|
});
|
||||||
|
await tx.crewMember.update({
|
||||||
|
where: { id: app.crewMember.id },
|
||||||
|
data: { status: "EMPLOYEE", employeeId, currentRankId: app.requisition.rankId },
|
||||||
|
});
|
||||||
|
// Management ranks (grantsLogin) become a SITE_STAFF login on onboarding.
|
||||||
|
await maybeCreateSiteStaffLogin(tx, { name: app.crewMember.name, email: app.crewMember.email, employeeId }, app.requisition.rankId, app.requisition.siteId);
|
||||||
|
return { assignmentId: assignment.id, employeeId };
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidateApp(id, app.requisition.id);
|
||||||
|
return { ok: true, id: result.employeeId };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,400 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { ApplicationStage, InterviewOutcome, SalaryRateBasis } from "@prisma/client";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import {
|
||||||
|
advanceStage,
|
||||||
|
agreeSalary,
|
||||||
|
approveSalary,
|
||||||
|
returnSalary,
|
||||||
|
verifyDocuments,
|
||||||
|
recordReferenceCheck,
|
||||||
|
recordInterviewResult,
|
||||||
|
requestInterviewWaiver,
|
||||||
|
approveInterviewWaiver,
|
||||||
|
selectCandidate,
|
||||||
|
returnSelection,
|
||||||
|
rejectApplication,
|
||||||
|
onboardCandidate,
|
||||||
|
} 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";
|
||||||
|
const PRIMARY = "rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||||
|
const SECONDARY = "rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60";
|
||||||
|
const DANGER = "rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50 disabled:opacity-60";
|
||||||
|
|
||||||
|
export type ActionCardProps = {
|
||||||
|
id: string;
|
||||||
|
stage: ApplicationStage;
|
||||||
|
isExHand: boolean;
|
||||||
|
interviewResult: InterviewOutcome;
|
||||||
|
interviewWaived: boolean;
|
||||||
|
rejectedReason: string | null;
|
||||||
|
salaryPending: boolean;
|
||||||
|
waiverPending: boolean;
|
||||||
|
selectionPending: boolean;
|
||||||
|
employeeNo: string | null;
|
||||||
|
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
|
||||||
|
perms: {
|
||||||
|
manage: boolean;
|
||||||
|
recordReference: boolean;
|
||||||
|
recordInterview: boolean;
|
||||||
|
requestWaiver: boolean;
|
||||||
|
approveSalary: boolean;
|
||||||
|
approveWaiver: boolean;
|
||||||
|
select: boolean;
|
||||||
|
onboard: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function useAction() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function run(fn: () => Promise<{ ok: true } | { error: string }>) {
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const res = await fn();
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error);
|
||||||
|
else router.refresh();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return { pending, error, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, sub, children }: { title: string; sub?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
|
||||||
|
{sub && <p className="text-xs text-neutral-500 mt-0.5">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RejectButton({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await rejectApplication(id, reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={DANGER} onClick={() => setOpen(true)}>Reject</button>
|
||||||
|
<AdminDialog title="Reject candidate" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">Rejecting removes this candidate from the pipeline. The reason is recorded.</p>
|
||||||
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Rejecting…" : "Reject"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Err({ msg }: { msg: string }) {
|
||||||
|
return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApplicationActionCard(p: ActionCardProps) {
|
||||||
|
const { run, pending, error } = useAction();
|
||||||
|
const canReject = p.perms.manage && !["SELECTED", "ONBOARDED", "REJECTED"].includes(p.stage);
|
||||||
|
|
||||||
|
// Reference-check form state (COMPETENCY_AND_REFERENCES).
|
||||||
|
const [ref, setRef] = useState({ refereeName: "", refereeContact: "", outcome: "positive", note: "" });
|
||||||
|
// Bank/EPF form state (DOC_VERIFICATION).
|
||||||
|
const [docs, setDocs] = useState({ accountName: "", accountNumber: "", ifsc: "", bankName: "", uan: "", aadhaarLast4: "", pfNumber: "" });
|
||||||
|
// Salary form state (SALARY_AGREEMENT).
|
||||||
|
const [sal, setSal] = useState({ rateBasis: "MONTHLY", basic: "", victualingPerDay: "0", currency: "INR" });
|
||||||
|
|
||||||
|
function fdFrom(obj: Record<string, string>, extra?: Record<string, string>) {
|
||||||
|
const fd = new FormData();
|
||||||
|
Object.entries({ ...obj, ...extra }).forEach(([k, v]) => fd.set(k, v));
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Err msg={error} />
|
||||||
|
{canReject && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<RejectButton id={p.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (p.stage) {
|
||||||
|
case "SHORTLISTED":
|
||||||
|
return (
|
||||||
|
<Card title="Shortlisted" sub="Begin vetting: competency & references.">
|
||||||
|
{p.perms.manage && (
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "start_competency"))}>
|
||||||
|
Start competency & references
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "COMPETENCY_AND_REFERENCES":
|
||||||
|
return (
|
||||||
|
<Card title="Competency & references" sub="Record reference checks, then verify to continue.">
|
||||||
|
{p.perms.recordReference && (
|
||||||
|
<div className="space-y-2 rounded-md border border-neutral-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-neutral-600">Add a reference check</p>
|
||||||
|
<input className={INPUT} placeholder="Referee name" value={ref.refereeName} onChange={(e) => setRef({ ...ref, refereeName: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Referee contact (optional)" value={ref.refereeContact} onChange={(e) => setRef({ ...ref, refereeContact: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Note (optional)" value={ref.note} onChange={(e) => setRef({ ...ref, note: e.target.value })} />
|
||||||
|
<button className={SECONDARY} disabled={pending || !ref.refereeName} onClick={() => run(() => recordReferenceCheck(fdFrom(ref, { applicationId: p.id }))).then((r) => { if ("ok" in r) setRef({ refereeName: "", refereeContact: "", outcome: "positive", note: "" }); })}>
|
||||||
|
Save reference
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.perms.manage && (
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "verify_competency"))}>
|
||||||
|
Verify & continue to documents
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "DOC_VERIFICATION":
|
||||||
|
return (
|
||||||
|
<Card title="Documents" sub="MPO collects & verifies documents, bank and EPF.">
|
||||||
|
{p.perms.manage ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<input className={INPUT} placeholder="Account name" value={docs.accountName} onChange={(e) => setDocs({ ...docs, accountName: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Account number" value={docs.accountNumber} onChange={(e) => setDocs({ ...docs, accountNumber: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="IFSC" value={docs.ifsc} onChange={(e) => setDocs({ ...docs, ifsc: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Bank name" value={docs.bankName} onChange={(e) => setDocs({ ...docs, bankName: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="UAN" value={docs.uan} onChange={(e) => setDocs({ ...docs, uan: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Aadhaar (last 4)" value={docs.aadhaarLast4} onChange={(e) => setDocs({ ...docs, aadhaarLast4: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="PF number" value={docs.pfNumber} onChange={(e) => setDocs({ ...docs, pfNumber: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => verifyDocuments(fdFrom(docs, { applicationId: p.id })))}>
|
||||||
|
Verify & continue to salary
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">Awaiting document verification by the MPO.</p>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "SALARY_AGREEMENT":
|
||||||
|
if (p.salaryPending) {
|
||||||
|
return (
|
||||||
|
<Card title="Salary" sub="Office-only; the Manager approves.">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Proposed: <strong>{p.salary?.currency} {p.salary?.basic}</strong> / {p.salary?.rateBasis.toLowerCase()} · victualing {p.salary?.currency} {p.salary?.victualingPerDay}/day
|
||||||
|
</p>
|
||||||
|
{p.perms.approveSalary ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveSalary(p.id))}>Approve salary</button>
|
||||||
|
<ReturnButton label="Return salary" onReturn={(reason) => returnSalary(p.id, reason)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Awaiting Manager approval.</p>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Card title="Salary" sub="Office-only; the Manager approves.">
|
||||||
|
{p.perms.manage ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<select className={INPUT} value={sal.rateBasis} onChange={(e) => setSal({ ...sal, rateBasis: e.target.value })}>
|
||||||
|
<option value="MONTHLY">Per month</option>
|
||||||
|
<option value="DAILY">Per day</option>
|
||||||
|
</select>
|
||||||
|
<input className={INPUT} type="number" placeholder="Basic" value={sal.basic} onChange={(e) => setSal({ ...sal, basic: e.target.value })} />
|
||||||
|
<input className={INPUT} type="number" placeholder="Victualing / day" value={sal.victualingPerDay} onChange={(e) => setSal({ ...sal, victualingPerDay: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<button className={PRIMARY} disabled={pending || !sal.basic} onClick={() => run(() => agreeSalary(fdFrom(sal, { applicationId: p.id })))}>
|
||||||
|
Agree salary & send for approval
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">Awaiting the MPO to agree the salary.</p>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "PROPOSED":
|
||||||
|
return (
|
||||||
|
<Card title="Proposed" sub="Awaiting the candidate's acceptance.">
|
||||||
|
{p.perms.manage && (
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => advanceStage(p.id, "propose_accepted"))}>
|
||||||
|
Candidate accepted — schedule interview
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "INTERVIEW":
|
||||||
|
return (
|
||||||
|
<Card title="Interview" sub="MPO records the result; the Manager approves the selection.">
|
||||||
|
{/* Interview result row */}
|
||||||
|
{p.interviewResult === "PENDING" && !p.interviewWaived && p.perms.recordInterview && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, true))}>Interview passed</button>
|
||||||
|
<button className={DANGER} disabled={pending} onClick={() => run(() => recordInterviewResult(p.id, false))}>Interview failed</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Waiver (ex-hand) */}
|
||||||
|
{p.isExHand && !p.interviewWaived && p.interviewResult === "PENDING" && !p.waiverPending && p.perms.requestWaiver && (
|
||||||
|
<button className={SECONDARY} disabled={pending} onClick={() => run(() => requestInterviewWaiver(p.id))}>Request interview waiver → Manager</button>
|
||||||
|
)}
|
||||||
|
{p.waiverPending && (
|
||||||
|
p.perms.approveWaiver ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-warning-700">Waiver requested.</span>
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => approveInterviewWaiver(p.id))}>Approve waiver</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-warning-700 bg-warning-50 rounded-lg px-3 py-2">Interview waiver awaiting Manager approval.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{/* Selection row */}
|
||||||
|
{(p.interviewResult === "ACCEPTED" || p.interviewWaived) && (
|
||||||
|
p.perms.select ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className={PRIMARY} disabled={pending} onClick={() => run(() => selectCandidate(p.id))}>Approve — select</button>
|
||||||
|
<ReturnButton label="Return" onReturn={(reason) => returnSelection(p.id, reason)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">{p.interviewWaived ? "Interview waived" : "Interview passed"} — awaiting Manager selection.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "SELECTED":
|
||||||
|
return (
|
||||||
|
<Card title="Selected" sub="Ready to onboard.">
|
||||||
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">Candidate selected.</p>
|
||||||
|
{p.perms.onboard && <OnboardButton id={p.id} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "REJECTED":
|
||||||
|
return (
|
||||||
|
<Card title="Rejected">
|
||||||
|
<p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{p.rejectedReason ?? "This candidate was rejected."}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Card title="Onboarded">
|
||||||
|
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">
|
||||||
|
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function OnboardButton({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [joiningDate, setJoiningDate] = useState("");
|
||||||
|
const [contract, setContract] = useState<File | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("applicationId", id);
|
||||||
|
fd.set("joiningDate", joiningDate);
|
||||||
|
if (contract) fd.set("contract", contract);
|
||||||
|
const res = await onboardCandidate(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button className={PRIMARY} onClick={() => setOpen(true)}>Onboard to crew</button>
|
||||||
|
<AdminDialog title="Onboard to crew" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Joining date *</label>
|
||||||
|
<input type="date" className={INPUT} value={joiningDate} onChange={(e) => setJoiningDate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Contract letter (optional)</label>
|
||||||
|
<input type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" onChange={(e) => setContract(e.target.files?.[0] ?? null)} />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-neutral-50 border border-neutral-200 p-3">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 mb-1">Starts automatically on confirm</p>
|
||||||
|
<p className="text-xs text-neutral-500">Employee number · salary & victualing · attendance · experience · EPF/PF · PPE. (Attendance, experience and PPE records begin in a later phase.)</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !joiningDate} className={PRIMARY}>{pending ? "Onboarding…" : "Confirm onboarding"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await onReturn(reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => setOpen(true)}>{label}</button>
|
||||||
|
<AdminDialog title={label} open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for returning" />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className={SECONDARY} onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className={PRIMARY}>{pending ? "Returning…" : "Return"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal file
47
App/app/(portal)/crewing/applications/application-ui.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { ApplicationStage } from "@prisma/client";
|
||||||
|
import type { BadgeProps } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||||
|
|
||||||
|
// The 7 board columns in order (mirrors lib/application-pipeline BOARD_STAGES;
|
||||||
|
// kept here as a client-safe constant for the stepper/board UI).
|
||||||
|
export const STAGE_ORDER: ApplicationStage[] = [
|
||||||
|
"SHORTLISTED",
|
||||||
|
"COMPETENCY_AND_REFERENCES",
|
||||||
|
"DOC_VERIFICATION",
|
||||||
|
"SALARY_AGREEMENT",
|
||||||
|
"PROPOSED",
|
||||||
|
"INTERVIEW",
|
||||||
|
"SELECTED",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STAGE_LABEL: Record<ApplicationStage, string> = {
|
||||||
|
SHORTLISTED: "Shortlisted",
|
||||||
|
COMPETENCY_AND_REFERENCES: "Competency & references",
|
||||||
|
DOC_VERIFICATION: "Documents",
|
||||||
|
SALARY_AGREEMENT: "Salary",
|
||||||
|
PROPOSED: "Proposed",
|
||||||
|
INTERVIEW: "Interview",
|
||||||
|
SELECTED: "Selected",
|
||||||
|
REJECTED: "Rejected",
|
||||||
|
ONBOARDED: "Onboarded",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STAGE_VARIANT: Record<ApplicationStage, Variant> = {
|
||||||
|
SHORTLISTED: "outline",
|
||||||
|
COMPETENCY_AND_REFERENCES: "default",
|
||||||
|
DOC_VERIFICATION: "default",
|
||||||
|
SALARY_AGREEMENT: "warning",
|
||||||
|
PROPOSED: "default",
|
||||||
|
INTERVIEW: "warning",
|
||||||
|
SELECTED: "success",
|
||||||
|
REJECTED: "danger",
|
||||||
|
ONBOARDED: "success",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Index of a stage within the 7-step flow (−1 for REJECTED; 7 for ONBOARDED).
|
||||||
|
export function stageIndex(stage: ApplicationStage): number {
|
||||||
|
if (stage === "REJECTED") return -1;
|
||||||
|
if (stage === "ONBOARDED") return STAGE_ORDER.length;
|
||||||
|
return STAGE_ORDER.indexOf(stage);
|
||||||
|
}
|
||||||
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal file
146
App/app/(portal)/crewing/appraisals/actions.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { canPerformAction, canReject } from "@/lib/appraisal-state-machine";
|
||||||
|
import { getManagerRecipients, getMpoRecipients } from "@/lib/requisition-service";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAppraisal(id: string) {
|
||||||
|
return db.appraisal.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { assignment: { include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidate(crewMemberId: string) {
|
||||||
|
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||||
|
revalidatePath("/crewing/verification");
|
||||||
|
revalidatePath("/approvals");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raise an appraisal (PM / site staff) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const raiseSchema = z.object({
|
||||||
|
assignmentId: z.string().min(1, "Crew assignment is required"),
|
||||||
|
period: z.string().trim().min(1, "Period is required"),
|
||||||
|
comments: z.string().optional(),
|
||||||
|
competence: z.coerce.number().int().min(1).max(5).optional(),
|
||||||
|
conduct: z.coerce.number().int().min(1).max(5).optional(),
|
||||||
|
safety: z.coerce.number().int().min(1).max(5).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function raiseAppraisal(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("raise_appraisal");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = raiseSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const assignment = await db.crewAssignment.findUnique({
|
||||||
|
where: { id: d.assignmentId },
|
||||||
|
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!assignment) return { error: "Crew assignment not found" };
|
||||||
|
|
||||||
|
const appraisal = await db.appraisal.create({
|
||||||
|
data: {
|
||||||
|
assignmentId: d.assignmentId,
|
||||||
|
period: d.period,
|
||||||
|
comments: d.comments ?? null,
|
||||||
|
ratings: { competence: d.competence ?? null, conduct: d.conduct ?? null, safety: d.safety ?? null },
|
||||||
|
status: "SUBMITTED",
|
||||||
|
addedById: g.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_SUBMITTED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
|
||||||
|
|
||||||
|
const mpos = await getMpoRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "APPRAISAL_FOR_VERIFICATION",
|
||||||
|
recipients: mpos,
|
||||||
|
subject: `Appraisal to verify — ${assignment.crewMember.name}`,
|
||||||
|
body: `An appraisal for ${assignment.crewMember.name} (${assignment.rank.name}, ${d.period}) awaits MPO verification.`,
|
||||||
|
link: "/crewing/verification",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidate(assignment.crewMember.id);
|
||||||
|
return { ok: true, id: appraisal.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verify (MPO) ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function verifyAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("verify_appraisal");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const a = await loadAppraisal(id);
|
||||||
|
if (!a) return { error: "Appraisal not found" };
|
||||||
|
|
||||||
|
if (!approve) {
|
||||||
|
if (!canReject(a.status)) return { error: `Cannot reject from ${a.status}` };
|
||||||
|
if (!remarks?.trim()) return { error: "A reason is required to reject" };
|
||||||
|
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
||||||
|
revalidate(a.assignment.crewMember.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPerformAction(a.status, "verify", g.role)) return { error: `Cannot verify from ${a.status}` };
|
||||||
|
await db.appraisal.update({ where: { id }, data: { status: "MPO_VERIFIED", verifiedById: g.userId } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_VERIFIED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
||||||
|
|
||||||
|
const managers = await getManagerRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "APPRAISAL_FOR_APPROVAL",
|
||||||
|
recipients: managers,
|
||||||
|
subject: `Appraisal for approval — ${a.assignment.crewMember.name}`,
|
||||||
|
body: `${a.assignment.crewMember.name}'s appraisal (${a.assignment.rank.name}, ${a.period}) has been MPO-verified and awaits your approval.`,
|
||||||
|
link: "/approvals",
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidate(a.assignment.crewMember.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approve (Manager) ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function approveAppraisal(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("approve_appraisal");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const a = await loadAppraisal(id);
|
||||||
|
if (!a) return { error: "Appraisal not found" };
|
||||||
|
|
||||||
|
if (!approve) {
|
||||||
|
if (!canReject(a.status)) return { error: `Cannot return from ${a.status}` };
|
||||||
|
if (!remarks?.trim()) return { error: "A reason is required to return" };
|
||||||
|
await db.appraisal.update({ where: { id }, data: { status: "REJECTED", rejectedReason: remarks.trim() } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_REJECTED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id, note: remarks.trim() } });
|
||||||
|
revalidate(a.assignment.crewMember.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPerformAction(a.status, "approve", g.role)) return { error: `Cannot approve from ${a.status}` };
|
||||||
|
await db.appraisal.update({ where: { id }, data: { status: "MANAGER_APPROVED", approvedById: g.userId } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "APPRAISAL_APPROVED", actorId: g.userId, crewMemberId: a.assignment.crewMember.id } });
|
||||||
|
|
||||||
|
revalidate(a.assignment.crewMember.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
46
App/app/(portal)/crewing/attendance/actions.ts
Normal file
46
App/app/(portal)/crewing/attendance/actions.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { AttendanceStatus } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
|
const markSchema = z.object({ date: z.string().min(1), status: z.nativeEnum(AttendanceStatus) });
|
||||||
|
|
||||||
|
// Bulk-save the dirty cells from the month calendar (Site staff). One upsert per
|
||||||
|
// (assignment, date); a single ATTENDANCE_RECORDED audit row per save.
|
||||||
|
export async function saveAttendance(assignmentId: string, marks: { date: string; status: AttendanceStatus }[]): Promise<ActionResult> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "record_attendance")) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
if (!assignmentId) return { error: "Crew member is required" };
|
||||||
|
const parsed = z.array(markSchema).max(40).safeParse(marks);
|
||||||
|
if (!parsed.success) return { error: "Invalid attendance data" };
|
||||||
|
if (parsed.data.length === 0) return { ok: true };
|
||||||
|
|
||||||
|
const assignment = await db.crewAssignment.findUnique({ where: { id: assignmentId }, select: { crewMemberId: true } });
|
||||||
|
if (!assignment) return { error: "Crew assignment not found" };
|
||||||
|
|
||||||
|
await db.$transaction(
|
||||||
|
parsed.data.map((m) =>
|
||||||
|
db.attendance.upsert({
|
||||||
|
where: { assignmentId_date: { assignmentId, date: new Date(m.date) } },
|
||||||
|
update: { status: m.status, recordedById: session.user.id },
|
||||||
|
create: { assignmentId, date: new Date(m.date), status: m.status, recordedById: session.user.id },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: { actionType: "ATTENDANCE_RECORDED", actorId: session.user.id, crewMemberId: assignment.crewMemberId, metadata: { count: parsed.data.length } },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/crewing/attendance");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal file
169
App/app/(portal)/crewing/attendance/attendance-calendar.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import type { AttendanceStatus } from "@prisma/client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { saveAttendance } from "./actions";
|
||||||
|
|
||||||
|
type Assignment = { id: string; crewName: string; rank: string; location: string; marks: Record<string, AttendanceStatus> };
|
||||||
|
|
||||||
|
const INPUT = "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";
|
||||||
|
|
||||||
|
// Tap cycle (§8.10): Unmarked → Present → Absent → Leave → Half day → Unmarked.
|
||||||
|
const CYCLE: (AttendanceStatus | null)[] = [null, "PRESENT", "ABSENT", "ON_LEAVE", "HALF_DAY"];
|
||||||
|
const next = (s: AttendanceStatus | null) => CYCLE[(CYCLE.indexOf(s ?? null) + 1) % CYCLE.length];
|
||||||
|
|
||||||
|
const CELL: Record<AttendanceStatus, string> = {
|
||||||
|
PRESENT: "bg-success-100 text-success-700 border-success-200",
|
||||||
|
ABSENT: "bg-danger-100 text-danger-700 border-danger-200",
|
||||||
|
ON_LEAVE: "bg-warning-100 text-warning-700 border-warning-200",
|
||||||
|
HALF_DAY: "bg-primary-100 text-primary-700 border-primary-200",
|
||||||
|
SIGN_OFF: "bg-neutral-200 text-neutral-600 border-neutral-300",
|
||||||
|
};
|
||||||
|
const ABBR: Record<AttendanceStatus, string> = { PRESENT: "P", ABSENT: "A", ON_LEAVE: "L", HALF_DAY: "½", SIGN_OFF: "S" };
|
||||||
|
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||||
|
const iso = (y: number, m: number, d: number) => `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
export function AttendanceCalendar({ assignments, canEdit }: { assignments: Assignment[]; canEdit: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const today = new Date();
|
||||||
|
const [selectedId, setSelectedId] = useState(assignments[0]?.id ?? "");
|
||||||
|
const [y, setY] = useState(today.getFullYear());
|
||||||
|
const [m, setM] = useState(today.getMonth());
|
||||||
|
const [edits, setEdits] = useState<Record<string, Record<string, AttendanceStatus | null>>>({});
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const selected = assignments.find((a) => a.id === selectedId) ?? null;
|
||||||
|
const myEdits = edits[selectedId] ?? {};
|
||||||
|
|
||||||
|
const statusOf = (date: string): AttendanceStatus | null => {
|
||||||
|
if (date in myEdits) return myEdits[date];
|
||||||
|
return selected?.marks[date] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||||
|
const firstWeekday = new Date(y, m, 1).getDay();
|
||||||
|
const days = useMemo(() => Array.from({ length: daysInMonth }, (_, i) => i + 1), [daysInMonth]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
let present = 0, absent = 0, leave = 0;
|
||||||
|
for (const d of days) {
|
||||||
|
const s = (date => (date in myEdits ? myEdits[date] : selected?.marks[date] ?? null))(iso(y, m, d));
|
||||||
|
if (s === "PRESENT") present++; else if (s === "ABSENT") absent++; else if (s === "ON_LEAVE") leave++;
|
||||||
|
}
|
||||||
|
return { present, absent, leave };
|
||||||
|
}, [days, myEdits, selected, y, m]);
|
||||||
|
|
||||||
|
const unmarkedToDate = useMemo(() => {
|
||||||
|
const isCurrentOrPast = y < today.getFullYear() || (y === today.getFullYear() && m <= today.getMonth());
|
||||||
|
if (!isCurrentOrPast) return 0;
|
||||||
|
const lastDay = (y === today.getFullYear() && m === today.getMonth()) ? today.getDate() : daysInMonth;
|
||||||
|
let n = 0;
|
||||||
|
for (let d = 1; d <= lastDay; d++) if (statusOf(iso(y, m, d)) === null) n++;
|
||||||
|
return n;
|
||||||
|
}, [y, m, daysInMonth, myEdits, selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const dirty = Object.keys(myEdits).length > 0;
|
||||||
|
|
||||||
|
function cycleDay(date: string) {
|
||||||
|
if (!canEdit) return;
|
||||||
|
setEdits((e) => ({ ...e, [selectedId]: { ...(e[selectedId] ?? {}), [date]: next(statusOf(date)) } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftMonth(delta: number) {
|
||||||
|
const nm = m + delta;
|
||||||
|
if (nm < 0) { setM(11); setY(y - 1); } else if (nm > 11) { setM(0); setY(y + 1); } else setM(nm);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setPending(true);
|
||||||
|
// Null edits (cleared cells) are skipped — clearing a saved mark isn't supported here.
|
||||||
|
const marks = Object.entries(myEdits).filter(([, s]) => s !== null).map(([date, status]) => ({ date, status: status as AttendanceStatus }));
|
||||||
|
const res = await saveAttendance(selectedId, marks);
|
||||||
|
setPending(false);
|
||||||
|
if ("ok" in res) { setEdits((e) => ({ ...e, [selectedId]: {} })); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900 mb-2">Attendance</h1>
|
||||||
|
<p className="text-neutral-400">No active crew to mark attendance for.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="mb-5 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Attendance</h1>
|
||||||
|
{canEdit && (
|
||||||
|
<button onClick={save} disabled={!dirty || pending} className={cn("rounded-lg px-4 py-2 text-sm font-semibold text-white", dirty ? "bg-primary-600 hover:bg-primary-700" : "bg-neutral-300", "disabled:opacity-60")}>
|
||||||
|
{pending ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<select className={INPUT} value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
|
||||||
|
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => shiftMonth(-1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronLeft className="h-4 w-4" /></button>
|
||||||
|
<span className="text-sm font-medium text-neutral-800 w-36 text-center">{MONTHS[m]} {y}</span>
|
||||||
|
<button onClick={() => shiftMonth(1)} className="rounded-md border border-neutral-300 p-1.5 hover:bg-neutral-50"><ChevronRight className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unmarkedToDate > 0 && (
|
||||||
|
<div className="mb-4 rounded-lg border border-warning-200 bg-warning-50 px-4 py-2 text-sm text-warning-800">{unmarkedToDate} day{unmarkedToDate === 1 ? "" : "s"} still need marking.</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-3">
|
||||||
|
{([["Present", summary.present], ["Absent", summary.absent], ["On leave", summary.leave]] as const).map(([k, v]) => (
|
||||||
|
<div key={k} className="rounded-lg border border-neutral-200 bg-white p-3 text-center">
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{v}</p>
|
||||||
|
<p className="text-xs text-neutral-500">{k}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
<div className="grid grid-cols-7 gap-1 mb-1 text-center text-xs font-medium text-neutral-400">
|
||||||
|
{["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].map((d) => <div key={d}>{d}</div>)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{Array.from({ length: firstWeekday }).map((_, i) => <div key={`pad${i}`} />)}
|
||||||
|
{days.map((d) => {
|
||||||
|
const date = iso(y, m, d);
|
||||||
|
const s = statusOf(date);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => cycleDay(date)}
|
||||||
|
disabled={!canEdit}
|
||||||
|
className={cn(
|
||||||
|
"aspect-square rounded-md border text-sm flex flex-col items-center justify-center",
|
||||||
|
s ? CELL[s] : "border-dashed border-neutral-200 text-neutral-400",
|
||||||
|
canEdit ? "hover:ring-2 hover:ring-primary-200 cursor-pointer" : "cursor-default"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] leading-none">{d}</span>
|
||||||
|
{s && <span className="text-xs font-semibold leading-none mt-0.5">{ABBR[s]}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-neutral-500">
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded bg-success-100 border border-success-200 align-middle" /> Present</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded bg-danger-100 border border-danger-200 align-middle" /> Absent</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded bg-warning-100 border border-warning-200 align-middle" /> Leave</span>
|
||||||
|
<span><span className="inline-block w-3 h-3 rounded bg-primary-100 border border-primary-200 align-middle" /> Half day</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!canEdit && <p className="mt-3 text-xs text-neutral-400">View only — attendance is marked by site staff.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
App/app/(portal)/crewing/attendance/page.tsx
Normal file
46
App/app/(portal)/crewing/attendance/page.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { AttendanceCalendar } from "./attendance-calendar";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Attendance" };
|
||||||
|
|
||||||
|
export default async function AttendancePage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
if (!hasPermission(role, "view_attendance")) redirect("/dashboard"); // MPO has no attendance (R5)
|
||||||
|
|
||||||
|
const cutoff = new Date();
|
||||||
|
cutoff.setMonth(cutoff.getMonth() - 4);
|
||||||
|
|
||||||
|
const assignments = await db.crewAssignment.findMany({
|
||||||
|
where: { status: { not: "SIGNED_OFF" } },
|
||||||
|
orderBy: { crewMember: { name: "asc" } },
|
||||||
|
include: {
|
||||||
|
crewMember: { select: { name: true } },
|
||||||
|
rank: { select: { name: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
attendance: { where: { date: { gte: cutoff } }, select: { date: true, status: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttendanceCalendar
|
||||||
|
assignments={assignments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
crewName: a.crewMember.name,
|
||||||
|
rank: a.rank.name,
|
||||||
|
location: a.vessel?.name ?? a.site?.name ?? "—",
|
||||||
|
marks: Object.fromEntries(a.attendance.map((m) => [m.date.toISOString().slice(0, 10), m.status])),
|
||||||
|
}))}
|
||||||
|
canEdit={hasPermission(role, "record_attendance")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
137
App/app/(portal)/crewing/candidates/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { SOURCE_LABEL, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "../candidate-ui";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Candidate" };
|
||||||
|
|
||||||
|
export default async function CandidateDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const c = await db.crewMember.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
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();
|
||||||
|
|
||||||
|
const profile: [string, string][] = [
|
||||||
|
["Rank applied", c.appliedRank?.name ?? "—"],
|
||||||
|
["Last rank held", c.currentRank?.name ?? "—"],
|
||||||
|
["Experience", experienceLabel(c.experienceMonths)],
|
||||||
|
["Vessel type", c.vesselTypeExperience ?? "—"],
|
||||||
|
["Source", SOURCE_LABEL[c.source]],
|
||||||
|
["Email", c.email ?? "—"],
|
||||||
|
["Phone", c.phone ?? "—"],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href="/crewing/candidates" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Candidates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
|
||||||
|
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
|
||||||
|
{c.type === "EX_HAND" && (
|
||||||
|
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.type === "EX_HAND" && (
|
||||||
|
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
|
||||||
|
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "}
|
||||||
|
{c.experienceRecords.length === 0 && c.documents.length === 0 ? (
|
||||||
|
<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 className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Profile */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Profile</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-neutral-100">
|
||||||
|
{profile.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
||||||
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
||||||
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{c.notes && (
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100">
|
||||||
|
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
|
||||||
|
<p className="text-sm text-neutral-700">{c.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recruitment pipeline — Phase 3b */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Recruitment</h2>
|
||||||
|
</div>
|
||||||
|
<p className="px-4 py-12 text-center text-sm text-neutral-400">
|
||||||
|
The 7-stage recruitment pipeline (shortlist → competency & references → docs →
|
||||||
|
salary → proposed → interview → selected) arrives in the next phase. Applications
|
||||||
|
against requisitions will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
App/app/(portal)/crewing/candidates/actions.ts
Normal file
173
App/app/(portal)/crewing/candidates/actions.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
|
import { CandidateSource } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const LIST_PATH = "/crewing/candidates";
|
||||||
|
|
||||||
|
async function guard(
|
||||||
|
permission: Permission
|
||||||
|
): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
source: z.nativeEnum(CandidateSource).default("CAREERS"),
|
||||||
|
appliedRankId: z.string().optional(),
|
||||||
|
currentRankId: z.string().optional(),
|
||||||
|
experienceMonths: z.coerce.number().int().min(0).max(720).default(0),
|
||||||
|
vesselTypeExperience: z.string().optional(),
|
||||||
|
email: z.string().trim().email("Enter a valid email").optional().or(z.literal("")),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parse(formData: FormData) {
|
||||||
|
return candidateSchema.safeParse({
|
||||||
|
name: formData.get("name"),
|
||||||
|
source: (formData.get("source") as string) || undefined,
|
||||||
|
appliedRankId: (formData.get("appliedRankId") as string) || undefined,
|
||||||
|
currentRankId: (formData.get("currentRankId") as string) || undefined,
|
||||||
|
experienceMonths: (formData.get("experienceMonths") as string) || undefined,
|
||||||
|
vesselTypeExperience: (formData.get("vesselTypeExperience") as string) || undefined,
|
||||||
|
email: (formData.get("email") as string) || undefined,
|
||||||
|
phone: (formData.get("phone") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store an optional CV upload and return its storage key (null if none).
|
||||||
|
async function storeCv(formData: FormData, crewMemberId: string): Promise<string | null> {
|
||||||
|
const file = formData.get("cv");
|
||||||
|
if (!(file instanceof File) || file.size === 0) return null;
|
||||||
|
const key = buildStorageKey("cv", crewMemberId, file.name);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await uploadBuffer(key, buffer, file.type || "application/octet-stream");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
data: {
|
||||||
|
name: d.name,
|
||||||
|
source: d.source,
|
||||||
|
// The candidate form always intakes a fresh NEW candidate. Ex-hand status
|
||||||
|
// is an office/admin designation set on the crew record, not here.
|
||||||
|
type: "NEW",
|
||||||
|
status: "CANDIDATE",
|
||||||
|
appliedRankId: d.appliedRankId || null,
|
||||||
|
currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || null,
|
||||||
|
email: d.email || null,
|
||||||
|
phone: d.phone || null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
actions: { create: { actionType: "CANDIDATE_ADDED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cvKey = await storeCv(formData, candidate.id);
|
||||||
|
if (cvKey) await db.crewMember.update({ where: { id: candidate.id }, data: { cvKey } });
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: candidate.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCandidate(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("manage_candidates");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
if (!id) return { error: "Candidate ID is required" };
|
||||||
|
|
||||||
|
const parsed = parse(formData);
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const existing = await db.crewMember.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!existing) return { error: "Candidate not found" };
|
||||||
|
|
||||||
|
const cvKey = await storeCv(formData, id);
|
||||||
|
|
||||||
|
await db.crewMember.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: d.name,
|
||||||
|
source: d.source,
|
||||||
|
// type/status are left untouched — ex-hand / employee designation is owned
|
||||||
|
// by the office (admin crew record + sign-off), never by a candidate edit.
|
||||||
|
appliedRankId: d.appliedRankId || null,
|
||||||
|
currentRankId: d.currentRankId || null,
|
||||||
|
experienceMonths: d.experienceMonths,
|
||||||
|
vesselTypeExperience: d.vesselTypeExperience || null,
|
||||||
|
email: d.email || null,
|
||||||
|
phone: d.phone || null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
...(cvKey ? { cvKey } : {}),
|
||||||
|
actions: { create: { actionType: "CANDIDATE_UPDATED", actorId: g.userId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true, id };
|
||||||
|
}
|
||||||
258
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal file
258
App/app/(portal)/crewing/candidates/candidate-form.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { CandidateSource } from "@prisma/client";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { addCandidate, updateCandidate } from "./actions";
|
||||||
|
import { FORM_SOURCE_OPTIONS, SOURCE_LABEL } from "./candidate-ui";
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
export type EditableCandidate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
appliedRankId: string | null;
|
||||||
|
currentRankId: string | null;
|
||||||
|
experienceMonths: number;
|
||||||
|
vesselTypeExperience: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function CandidateFields({
|
||||||
|
ranks,
|
||||||
|
state,
|
||||||
|
set,
|
||||||
|
fileRef,
|
||||||
|
}: {
|
||||||
|
ranks: RankOpt[];
|
||||||
|
state: FieldState;
|
||||||
|
set: <K extends keyof FieldState>(k: K, v: FieldState[K]) => void;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||||
|
<input className={INPUT} value={state.name} onChange={(e) => set("name", e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Source</label>
|
||||||
|
<select className={INPUT} value={state.source} onChange={(e) => set("source", e.target.value as CandidateSource)}>
|
||||||
|
{FORM_SOURCE_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank applied for</label>
|
||||||
|
<select className={INPUT} value={state.appliedRankId} onChange={(e) => set("appliedRankId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank held</label>
|
||||||
|
<select className={INPUT} value={state.currentRankId} onChange={(e) => set("currentRankId", e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Experience (months)</label>
|
||||||
|
<input type="number" min={0} className={INPUT} value={state.experienceMonths} onChange={(e) => set("experienceMonths", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel type</label>
|
||||||
|
<input className={INPUT} value={state.vesselTypeExperience} onChange={(e) => set("vesselTypeExperience", e.target.value)} placeholder="e.g. Dredger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
|
||||||
|
<input type="email" className={INPUT} value={state.email} onChange={(e) => set("email", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Phone</label>
|
||||||
|
<input className={INPUT} value={state.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">CV (PDF/DOC, optional)</label>
|
||||||
|
<input ref={fileRef} type="file" accept=".pdf,.doc,.docx" className="block w-full text-sm text-neutral-600 file:mr-3 file:rounded-md file:border-0 file:bg-neutral-100 file:px-3 file:py-1.5 file:text-sm file:font-medium" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={state.notes} onChange={(e) => set("notes", e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldState = {
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
appliedRankId: string;
|
||||||
|
currentRankId: string;
|
||||||
|
experienceMonths: string;
|
||||||
|
vesselTypeExperience: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyState(): FieldState {
|
||||||
|
return {
|
||||||
|
name: "", source: "CAREERS", appliedRankId: "", currentRankId: "",
|
||||||
|
experienceMonths: "0", vesselTypeExperience: "", email: "", phone: "", notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateFrom(c: EditableCandidate): FieldState {
|
||||||
|
return {
|
||||||
|
name: c.name,
|
||||||
|
// Ex-hand is an admin-only designation; the candidate form only edits origin.
|
||||||
|
// Legacy rows may carry the EX_HAND source — show a sensible origin instead.
|
||||||
|
source: c.source === "EX_HAND" ? "CAREERS" : c.source,
|
||||||
|
appliedRankId: c.appliedRankId ?? "",
|
||||||
|
currentRankId: c.currentRankId ?? "",
|
||||||
|
experienceMonths: String(c.experienceMonths),
|
||||||
|
vesselTypeExperience: c.vesselTypeExperience ?? "",
|
||||||
|
email: c.email ?? "",
|
||||||
|
phone: c.phone ?? "",
|
||||||
|
notes: c.notes ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(state: FieldState, file: File | undefined, id?: string): FormData {
|
||||||
|
const fd = new FormData();
|
||||||
|
if (id) fd.set("id", id);
|
||||||
|
fd.set("name", state.name);
|
||||||
|
fd.set("source", state.source);
|
||||||
|
if (state.appliedRankId) fd.set("appliedRankId", state.appliedRankId);
|
||||||
|
if (state.currentRankId) fd.set("currentRankId", state.currentRankId);
|
||||||
|
fd.set("experienceMonths", state.experienceMonths || "0");
|
||||||
|
if (state.vesselTypeExperience) fd.set("vesselTypeExperience", state.vesselTypeExperience);
|
||||||
|
if (state.email) fd.set("email", state.email);
|
||||||
|
if (state.phone) fd.set("phone", state.phone);
|
||||||
|
if (state.notes) fd.set("notes", state.notes);
|
||||||
|
if (file && file.size > 0) fd.set("cv", file);
|
||||||
|
return fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddCandidateButton({ ranks }: { ranks: RankOpt[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [state, setState] = useState<FieldState>(emptyState);
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await addCandidate(buildFormData(state, fileRef.current?.files?.[0]));
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
setState(emptyState());
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add candidate
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Add candidate" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Adding…" : "Add candidate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditCandidateButton({
|
||||||
|
candidate,
|
||||||
|
ranks,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
candidate: EditableCandidate;
|
||||||
|
ranks: RankOpt[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [state, setState] = useState<FieldState>(() => stateFrom(candidate));
|
||||||
|
const fileRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const set = <K extends keyof FieldState>(k: K, v: FieldState[K]) => setState((s) => ({ ...s, [k]: v }));
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await updateCandidate(buildFormData(state, fileRef.current?.files?.[0], candidate.id));
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
onOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDialog title="Edit candidate" open={open} onClose={() => onOpenChange(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<CandidateFields ranks={ranks} state={state} set={set} fileRef={fileRef} />
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => onOpenChange(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Saving…" : "Save changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal file
43
App/app/(portal)/crewing/candidates/candidate-ui.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { CandidateSource, CrewStatus } from "@prisma/client";
|
||||||
|
import type { BadgeProps } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||||
|
|
||||||
|
export const SOURCE_LABEL: Record<CandidateSource, string> = {
|
||||||
|
CAREERS: "Careers",
|
||||||
|
EX_HAND: "Ex-hand",
|
||||||
|
WALK_IN: "Walk-in",
|
||||||
|
REFERRAL: "Referral",
|
||||||
|
OTHER: "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> = {
|
||||||
|
PROSPECT: "Prospect",
|
||||||
|
CANDIDATE: "Candidate",
|
||||||
|
EMPLOYEE: "Employee",
|
||||||
|
EX_HAND: "Ex-hand",
|
||||||
|
BLACKLISTED: "Blacklisted",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_VARIANT: Record<CrewStatus, Variant> = {
|
||||||
|
PROSPECT: "outline",
|
||||||
|
CANDIDATE: "default",
|
||||||
|
EMPLOYEE: "success",
|
||||||
|
EX_HAND: "secondary",
|
||||||
|
BLACKLISTED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact experience label, e.g. "3y 6m", "8m", "—".
|
||||||
|
export function experienceLabel(months: number): string {
|
||||||
|
if (!months) return "—";
|
||||||
|
const y = Math.floor(months / 12);
|
||||||
|
const m = months % 12;
|
||||||
|
return [y ? `${y}y` : "", m ? `${m}m` : ""].filter(Boolean).join(" ") || "0m";
|
||||||
|
}
|
||||||
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal file
169
App/app/(portal)/crewing/candidates/candidates-manager.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { CandidateSource, CandidateType, CrewStatus } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RowActionsMenu, RowActionsItem } from "@/components/ui/row-actions-menu";
|
||||||
|
import { AddCandidateButton, EditCandidateButton, type EditableCandidate } from "./candidate-form";
|
||||||
|
import { SOURCE_LABEL, SOURCE_OPTIONS, STATUS_LABEL, STATUS_VARIANT, experienceLabel } from "./candidate-ui";
|
||||||
|
|
||||||
|
type CandidateRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: CandidateSource;
|
||||||
|
type: CandidateType;
|
||||||
|
status: CrewStatus;
|
||||||
|
appliedRankId: string | null;
|
||||||
|
appliedRank: string | null;
|
||||||
|
currentRankId: string | null;
|
||||||
|
currentRank: string | null;
|
||||||
|
experienceMonths: number;
|
||||||
|
vesselTypeExperience: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
hasCv: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 text-primary-700 px-2.5 py-1 text-xs font-medium">
|
||||||
|
{label}
|
||||||
|
<button onClick={onClear} className="text-primary-400 hover:text-primary-700" aria-label="Remove filter">✕</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEditable(c: CandidateRow): EditableCandidate {
|
||||||
|
return {
|
||||||
|
id: c.id, name: c.name, source: c.source,
|
||||||
|
appliedRankId: c.appliedRankId, currentRankId: c.currentRankId,
|
||||||
|
experienceMonths: c.experienceMonths, vesselTypeExperience: c.vesselTypeExperience,
|
||||||
|
email: c.email, phone: c.phone, notes: c.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidateRowView({ c, ranks }: { c: CandidateRow; ranks: RankOpt[] }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/crewing/candidates/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||||
|
{c.type === "EX_HAND" && (
|
||||||
|
<span className="ml-2 rounded-full bg-purple-100 text-purple-700 px-2 py-0.5 text-[10px] font-medium align-middle">Ex-hand</span>
|
||||||
|
)}
|
||||||
|
{c.hasCv && <span className="ml-2 text-xs text-neutral-400">CV</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{SOURCE_LABEL[c.source]}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.currentRank ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{c.appliedRank ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600 text-sm">{experienceLabel(c.experienceMonths)}</td>
|
||||||
|
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
|
</RowActionsMenu>
|
||||||
|
</div>
|
||||||
|
<EditCandidateButton candidate={toEditable(c)} ranks={ranks} open={editOpen} onOpenChange={setEditOpen} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CandidatesManager({ candidates, ranks }: { candidates: CandidateRow[]; ranks: RankOpt[] }) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [source, setSource] = useState<"ALL" | CandidateSource>("ALL");
|
||||||
|
const [appliedRankId, setAppliedRankId] = useState("ALL");
|
||||||
|
const [minExp, setMinExp] = useState("");
|
||||||
|
|
||||||
|
const minExpMonths = minExp ? Math.max(0, parseInt(minExp, 10) || 0) : 0;
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return candidates.filter((c) => {
|
||||||
|
if (source !== "ALL" && c.source !== source) return false;
|
||||||
|
if (appliedRankId !== "ALL" && c.appliedRankId !== appliedRankId) return false;
|
||||||
|
if (minExpMonths && c.experienceMonths < minExpMonths) return false;
|
||||||
|
if (q && !`${c.name} ${c.appliedRank ?? ""} ${c.currentRank ?? ""}`.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [candidates, search, source, appliedRankId, minExpMonths]);
|
||||||
|
|
||||||
|
const rankName = (id: string) => ranks.find((r) => r.id === id)?.name ?? id;
|
||||||
|
const hasFilters = Boolean(search) || source !== "ALL" || appliedRankId !== "ALL" || Boolean(minExp);
|
||||||
|
const clearAll = () => { setSearch(""); setSource("ALL"); setAppliedRankId("ALL"); setMinExp(""); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Candidates</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">
|
||||||
|
{candidates.length} in the talent pool · careers applicants, ex-hands, walk-ins and referrals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AddCandidateButton ranks={ranks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
<select className={INPUT} value={source} onChange={(e) => setSource(e.target.value as typeof source)}>
|
||||||
|
<option value="ALL">All sources</option>
|
||||||
|
{SOURCE_OPTIONS.map((s) => <option key={s} value={s}>{SOURCE_LABEL[s]}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className={INPUT} value={appliedRankId} onChange={(e) => setAppliedRankId(e.target.value)}>
|
||||||
|
<option value="ALL">Any rank applied</option>
|
||||||
|
{ranks.map((r) => <option key={r.id} value={r.id}>{r.code} — {r.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="number" min={0} className={`${INPUT} w-40`} placeholder="Min exp (months)" value={minExp} onChange={(e) => setMinExp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filter chips + match count */}
|
||||||
|
{hasFilters && (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
|
{search && <Chip label={`“${search}”`} onClear={() => setSearch("")} />}
|
||||||
|
{source !== "ALL" && <Chip label={`Source: ${SOURCE_LABEL[source]}`} onClear={() => setSource("ALL")} />}
|
||||||
|
{appliedRankId !== "ALL" && <Chip label={`Rank: ${rankName(appliedRankId)}`} onClear={() => setAppliedRankId("ALL")} />}
|
||||||
|
{minExp && <Chip label={`≥ ${minExp} mo`} onClear={() => setMinExp("")} />}
|
||||||
|
<span className="text-xs text-neutral-500">{filtered.length} match{filtered.length === 1 ? "" : "es"}</span>
|
||||||
|
<button onClick={clearAll} className="text-xs font-medium text-primary-600 hover:underline">Clear all</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3">Source</th>
|
||||||
|
<th className="px-4 py-3">Rank held</th>
|
||||||
|
<th className="px-4 py-3">Rank applied</th>
|
||||||
|
<th className="px-4 py-3">Experience</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3 w-12"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
||||||
|
{candidates.length === 0 ? "No candidates yet. Add the first to the pool." : "No candidates match these filters."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => <CandidateRowView key={c.id} c={c} ranks={ranks} />)
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
App/app/(portal)/crewing/candidates/page.tsx
Normal file
55
App/app/(portal)/crewing/candidates/page.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CandidatesManager } from "./candidates-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Candidates" };
|
||||||
|
|
||||||
|
export default async function CandidatesPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "manage_candidates")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [candidates, ranks] = await Promise.all([
|
||||||
|
db.crewMember.findMany({
|
||||||
|
// Active employees live in the Crew directory (Phase 4); the pool is
|
||||||
|
// everyone still a candidate / ex-hand (spec §8.6 R9).
|
||||||
|
where: { status: { not: "EMPLOYEE" } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
appliedRank: { select: { name: true } },
|
||||||
|
currentRank: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = candidates.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
source: c.source,
|
||||||
|
type: c.type,
|
||||||
|
status: c.status,
|
||||||
|
appliedRankId: c.appliedRankId,
|
||||||
|
appliedRank: c.appliedRank?.name ?? null,
|
||||||
|
currentRankId: c.currentRankId,
|
||||||
|
currentRank: c.currentRank?.name ?? null,
|
||||||
|
experienceMonths: c.experienceMonths,
|
||||||
|
vesselTypeExperience: c.vesselTypeExperience,
|
||||||
|
email: c.email,
|
||||||
|
phone: c.phone,
|
||||||
|
notes: c.notes,
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
423
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal file
423
App/app/(portal)/crewing/crew/[id]/crew-profile.tsx
Normal file
|
|
@ -0,0 +1,423 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import type { AssignmentStatus, GateResult, PpeItem, SeafarerDocType, SalaryRateBasis, AppraisalStatus } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
uploadDocument, deleteDocument, saveBankEpf,
|
||||||
|
addNextOfKin, deleteNextOfKin, issuePpe, returnPpe, addExperience, signOffCrew,
|
||||||
|
} from "../actions";
|
||||||
|
import { raiseAppraisal } from "../../appraisals/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";
|
||||||
|
const BTN = "rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60";
|
||||||
|
const LINKBTN = "text-xs font-medium text-danger-600 hover:underline";
|
||||||
|
|
||||||
|
const DOC_TYPES: SeafarerDocType[] = ["STCW","AADHAAR","PAN","PASSPORT","CDC","COC","PHOTOGRAPH","DRIVING_LICENSE","MEDICAL_FITNESS","CONTRACT_LETTER"];
|
||||||
|
const PPE_ITEMS: PpeItem[] = ["BOILER_SUIT","SAFETY_SHOES","HELMET","VEST","GLOVES","MASK","GOGGLES","TIFFIN","TORCH","WALKIE_TALKIE"];
|
||||||
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
||||||
|
|
||||||
|
type Doc = { id: string; docType: SeafarerDocType; number: string | null; issueDate: string | null; expiryDate: string | null; verificationStatus: GateResult; hasFile: boolean };
|
||||||
|
type Nok = { id: string; name: string; relationship: string | null; phone: string | null; address: string | null; isEmergency: boolean };
|
||||||
|
type Ppe = { id: string; item: PpeItem; size: string | null; quantity: number; issuedDate: string; returnedDate: string | null };
|
||||||
|
type Exp = { id: string; vesselType: string | null; rank: string | null; fromDate: string | null; toDate: string | null; durationMonths: number | null; source: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
crew: { id: string; name: string; employeeId: string; rank: string; location: string; status: AssignmentStatus | null };
|
||||||
|
documents: Doc[];
|
||||||
|
bank: { accountName: string | null; accountNumber: string; ifsc: string | null; bankName: string | null };
|
||||||
|
epf: { uan: string | null; aadhaar: string; pfNumber: string | null };
|
||||||
|
nextOfKin: Nok[];
|
||||||
|
ppe: Ppe[];
|
||||||
|
experience: Exp[];
|
||||||
|
paystatus: { showSalary: boolean; salary: { basic: number; rateBasis: SalaryRateBasis; victualingPerDay: number; currency: string } | null };
|
||||||
|
ranks: { id: string; name: string }[];
|
||||||
|
perms: { editRecords: boolean; issuePpe: boolean };
|
||||||
|
signOff: { assignmentId: string | null; canSignOff: boolean };
|
||||||
|
appraisals: Appr[];
|
||||||
|
appraisalCtx: { assignmentId: string | null; canRaise: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
type Appr = { id: string; period: string; status: AppraisalStatus; comments: string | null; ratings: { competence: number | null; conduct: number | null; safety: number | null } | null };
|
||||||
|
|
||||||
|
const TABS = ["Documents", "Bank & EPF", "Next of kin", "PPE", "Experience", "Pay status", "Appraisals"] as const;
|
||||||
|
type Tab = (typeof TABS)[number];
|
||||||
|
|
||||||
|
const APPRAISAL_VARIANT: Record<AppraisalStatus, "outline" | "warning" | "default" | "success" | "danger"> = {
|
||||||
|
DRAFT: "outline", SUBMITTED: "warning", MPO_VERIFIED: "default", MANAGER_APPROVED: "success", REJECTED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CrewProfile(p: Props) {
|
||||||
|
const [tab, setTab] = useState<Tab>("Documents");
|
||||||
|
const router = useRouter();
|
||||||
|
const refresh = () => router.refresh();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href="/crewing/crew" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Crew
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{p.crew.name}</h1>
|
||||||
|
{p.crew.status === "ACTIVE" && <Badge variant="success">Active</Badge>}
|
||||||
|
{p.crew.status === "ON_LEAVE" && <Badge variant="warning">On leave</Badge>}
|
||||||
|
</div>
|
||||||
|
{p.signOff.canSignOff && p.signOff.assignmentId && <SignOffButton assignmentId={p.signOff.assignmentId} crewName={p.crew.name} />}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 mb-6"><span className="font-mono">{p.crew.employeeId}</span> · {p.crew.rank} · {p.crew.location}</p>
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap gap-1 border-b border-neutral-200">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} className={cn("px-3 py-2 text-sm font-medium border-b-2 -mb-px", tab === t ? "border-primary-600 text-primary-700" : "border-transparent text-neutral-500 hover:text-neutral-800")}>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "Documents" && <Documents crewId={p.crew.id} docs={p.documents} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||||
|
{tab === "Bank & EPF" && <BankEpf crewId={p.crew.id} bank={p.bank} epf={p.epf} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||||
|
{tab === "Next of kin" && <NextOfKinTab crewId={p.crew.id} rows={p.nextOfKin} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||||
|
{tab === "PPE" && <PpeTab crewId={p.crew.id} rows={p.ppe} canIssue={p.perms.issuePpe} onDone={refresh} />}
|
||||||
|
{tab === "Experience" && <ExperienceTab crewId={p.crew.id} rows={p.experience} ranks={p.ranks} canEdit={p.perms.editRecords} onDone={refresh} />}
|
||||||
|
{tab === "Pay status" && <PayStatus paystatus={p.paystatus} />}
|
||||||
|
{tab === "Appraisals" && <Appraisals rows={p.appraisals} ctx={p.appraisalCtx} onDone={refresh} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Appraisals({ rows, ctx, onDone }: { rows: Appr[]; ctx: { assignmentId: string | null; canRaise: boolean }; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [f, setF] = useState({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" });
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!ctx.assignmentId) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("assignmentId", ctx.assignmentId);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
run(() => raiseAppraisal(fd), () => setF({ period: "", competence: "3", conduct: "3", safety: "3", comments: "" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{rows.length === 0 ? <p className="text-sm text-neutral-400">No appraisals.</p> : rows.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-900">{a.period} <Badge variant={APPRAISAL_VARIANT[a.status]}>{a.status.replace(/_/g, " ").toLowerCase()}</Badge></p>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{a.ratings ? `Competence ${a.ratings.competence ?? "—"} · Conduct ${a.ratings.conduct ?? "—"} · Safety ${a.ratings.safety ?? "—"}` : "—"}
|
||||||
|
{a.comments ? ` · ${a.comments}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ctx.canRaise && ctx.assignmentId && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<input className={INPUT} placeholder="Period (e.g. 2026 or 2026-Q2)" value={f.period} onChange={(e) => setF({ ...f, period: e.target.value })} required />
|
||||||
|
<input className={INPUT} placeholder="Comments" value={f.comments} onChange={(e) => setF({ ...f, comments: e.target.value })} />
|
||||||
|
{(["competence", "conduct", "safety"] as const).map((k) => (
|
||||||
|
<label key={k} className="text-xs text-neutral-500 capitalize">{k}
|
||||||
|
<select className={INPUT} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })}>{[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.period}>{pending ? "Submitting…" : "Submit appraisal"}</button></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{!ctx.canRaise && <p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Appraisals are raised by the PM and verified by the MPO, then approved by the Manager.</p>}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="rounded-lg border border-neutral-200 bg-white p-4 space-y-3">{children}</div>;
|
||||||
|
}
|
||||||
|
function Err({ msg }: { msg: string }) { return msg ? <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{msg}</p> : null; }
|
||||||
|
|
||||||
|
function useRun(onDone: () => void) {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function run(fn: () => Promise<{ ok: true } | { error: string }>, after?: () => void) {
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await fn();
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { after?.(); onDone(); }
|
||||||
|
}
|
||||||
|
return { pending, error, run };
|
||||||
|
}
|
||||||
|
|
||||||
|
function docStatus(d: Doc): { label: string; variant: "success" | "warning" | "danger" | "secondary" } {
|
||||||
|
if (d.expiryDate && new Date(d.expiryDate) < new Date()) return { label: "Expired", variant: "danger" };
|
||||||
|
if (d.verificationStatus === "VERIFIED") return { label: "Verified", variant: "success" };
|
||||||
|
if (d.verificationStatus === "REJECTED") return { label: "Rejected", variant: "danger" };
|
||||||
|
return { label: "Pending", variant: "warning" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Documents({ crewId, docs, canEdit, onDone }: { crewId: string; docs: Doc[]; canEdit: boolean; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [f, setF] = useState({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" });
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crewId);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
if (file) fd.set("file", file);
|
||||||
|
run(() => uploadDocument(fd), () => { setF({ docType: "PASSPORT", number: "", issueDate: "", expiryDate: "" }); setFile(null); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{docs.length === 0 ? <p className="text-sm text-neutral-400">No documents.</p> : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Document</th><th>Number</th><th>Issued</th><th>Expires</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map((d) => { const s = docStatus(d); return (
|
||||||
|
<tr key={d.id} className="border-b border-neutral-50 last:border-0">
|
||||||
|
<td className="py-2 text-neutral-800">{label(d.docType)}{d.hasFile && <span className="ml-1 text-xs text-neutral-400">file</span>}</td>
|
||||||
|
<td className="text-neutral-600">{d.number ?? "—"}</td>
|
||||||
|
<td className="text-neutral-600">{fmtDate(d.issueDate)}</td>
|
||||||
|
<td className="text-neutral-600">{fmtDate(d.expiryDate)}</td>
|
||||||
|
<td><Badge variant={s.variant}>{s.label}</Badge></td>
|
||||||
|
<td className="text-right">{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteDocument(d.id))}>Remove</button>}</td>
|
||||||
|
</tr>
|
||||||
|
); })}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<select className={INPUT} value={f.docType} onChange={(e) => setF({ ...f, docType: e.target.value })}>
|
||||||
|
{DOC_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
||||||
|
</select>
|
||||||
|
<input className={INPUT} placeholder="Number" value={f.number} onChange={(e) => setF({ ...f, number: e.target.value })} />
|
||||||
|
<label className="text-xs text-neutral-500">Issue date<input type="date" className={INPUT} value={f.issueDate} onChange={(e) => setF({ ...f, issueDate: e.target.value })} /></label>
|
||||||
|
<label className="text-xs text-neutral-500">Expiry date<input type="date" className={INPUT} value={f.expiryDate} onChange={(e) => setF({ ...f, expiryDate: e.target.value })} /></label>
|
||||||
|
<input type="file" className="col-span-2 text-sm" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||||
|
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add document"}</button></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v }: { k: string; v: string | null }) {
|
||||||
|
return <div className="flex justify-between gap-4 py-1.5 border-b border-neutral-50 last:border-0"><span className="text-sm text-neutral-500">{k}</span><span className="text-sm text-neutral-900 font-mono">{v ?? "—"}</span></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BankEpf({ crewId, bank, epf, canEdit, onDone }: { crewId: string; bank: Props["bank"]; epf: Props["epf"]; canEdit: boolean; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [f, setF] = useState({ accountName: bank.accountName ?? "", accountNumber: "", ifsc: bank.ifsc ?? "", bankName: bank.bankName ?? "", uan: epf.uan ?? "", aadhaarLast4: "", pfNumber: epf.pfNumber ?? "" });
|
||||||
|
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crewId);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
run(() => saveBankEpf(fd), () => setEdit(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<div className="rounded-md bg-warning-50 border border-warning-200 px-3 py-2 text-xs text-warning-800">Sensitive — account and Aadhaar numbers are masked unless you are Accounts.</div>
|
||||||
|
<Row k="Account name" v={bank.accountName} />
|
||||||
|
<Row k="Account number" v={bank.accountNumber} />
|
||||||
|
<Row k="IFSC" v={bank.ifsc} />
|
||||||
|
<Row k="Bank" v={bank.bankName} />
|
||||||
|
<Row k="UAN" v={epf.uan} />
|
||||||
|
<Row k="Aadhaar" v={epf.aadhaar} />
|
||||||
|
<Row k="PF number" v={epf.pfNumber} />
|
||||||
|
{canEdit && !edit && <button className="text-sm text-primary-600 hover:underline" onClick={() => setEdit(true)}>Edit bank & EPF</button>}
|
||||||
|
{canEdit && edit && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<input className={INPUT} placeholder="Account name" value={f.accountName} onChange={(e) => setF({ ...f, accountName: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Account number" value={f.accountNumber} onChange={(e) => setF({ ...f, accountNumber: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="IFSC" value={f.ifsc} onChange={(e) => setF({ ...f, ifsc: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Bank name" value={f.bankName} onChange={(e) => setF({ ...f, bankName: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="UAN" value={f.uan} onChange={(e) => setF({ ...f, uan: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Aadhaar (last 4)" value={f.aadhaarLast4} onChange={(e) => setF({ ...f, aadhaarLast4: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="PF number" value={f.pfNumber} onChange={(e) => setF({ ...f, pfNumber: e.target.value })} />
|
||||||
|
<div className="col-span-2"><Err msg={error} /><div className="flex gap-2"><button className={BTN} disabled={pending}>{pending ? "Saving…" : "Save"}</button><button type="button" className="text-sm text-neutral-500" onClick={() => setEdit(false)}>Cancel</button></div></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextOfKinTab({ crewId, rows, canEdit, onDone }: { crewId: string; rows: Nok[]; canEdit: boolean; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [f, setF] = useState({ name: "", relationship: "", phone: "", address: "", isEmergency: false });
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crewId);
|
||||||
|
fd.set("name", f.name); if (f.relationship) fd.set("relationship", f.relationship); if (f.phone) fd.set("phone", f.phone); if (f.address) fd.set("address", f.address); if (f.isEmergency) fd.set("isEmergency", "true");
|
||||||
|
run(() => addNextOfKin(fd), () => setF({ name: "", relationship: "", phone: "", address: "", isEmergency: false }));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{rows.length === 0 ? <p className="text-sm text-neutral-400">No next of kin recorded.</p> : rows.map((n) => (
|
||||||
|
<div key={n.id} className="flex items-start justify-between border-b border-neutral-50 last:border-0 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-900">{n.name} {n.isEmergency && <Badge variant="danger">Emergency</Badge>}</p>
|
||||||
|
<p className="text-xs text-neutral-500">{[n.relationship, n.phone, n.address].filter(Boolean).join(" · ") || "—"}</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && <button className={LINKBTN} onClick={() => run(() => deleteNextOfKin(n.id))}>Remove</button>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<input className={INPUT} placeholder="Name" value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} required />
|
||||||
|
<input className={INPUT} placeholder="Relationship" value={f.relationship} onChange={(e) => setF({ ...f, relationship: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Phone" value={f.phone} onChange={(e) => setF({ ...f, phone: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Address" value={f.address} onChange={(e) => setF({ ...f, address: e.target.value })} />
|
||||||
|
<label className="col-span-2 flex items-center gap-2 text-sm text-neutral-600"><input type="checkbox" checked={f.isEmergency} onChange={(e) => setF({ ...f, isEmergency: e.target.checked })} /> Emergency contact</label>
|
||||||
|
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending || !f.name}>{pending ? "Adding…" : "Add"}</button></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PpeTab({ crewId, rows, canIssue, onDone }: { crewId: string; rows: Ppe[]; canIssue: boolean; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [f, setF] = useState({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" });
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crewId);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
run(() => issuePpe(fd), () => setF({ item: "BOILER_SUIT", size: "", quantity: "1", comment: "" }));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{rows.length === 0 ? <p className="text-sm text-neutral-400">No PPE issued.</p> : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="text-left text-xs text-neutral-500 border-b border-neutral-100"><th className="py-2">Item</th><th>Size</th><th>Qty</th><th>Issued</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-50 last:border-0">
|
||||||
|
<td className="py-2 text-neutral-800">{label(r.item)}</td>
|
||||||
|
<td className="text-neutral-600">{r.size ?? "—"}</td>
|
||||||
|
<td className="text-neutral-600">{r.quantity}</td>
|
||||||
|
<td className="text-neutral-600">{fmtDate(r.issuedDate)}</td>
|
||||||
|
<td>{r.returnedDate ? <Badge variant="secondary">Returned</Badge> : <Badge variant="success">Issued</Badge>}</td>
|
||||||
|
<td className="text-right">{canIssue && !r.returnedDate && <button className="text-xs text-primary-600 hover:underline" onClick={() => run(() => returnPpe(r.id))}>Mark returned</button>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{canIssue && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<select className={INPUT} value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })}>{PPE_ITEMS.map((i) => <option key={i} value={i}>{label(i)}</option>)}</select>
|
||||||
|
<input className={INPUT} placeholder="Size" value={f.size} onChange={(e) => setF({ ...f, size: e.target.value })} />
|
||||||
|
<input className={INPUT} type="number" min={1} placeholder="Qty" value={f.quantity} onChange={(e) => setF({ ...f, quantity: e.target.value })} />
|
||||||
|
<input className={INPUT} placeholder="Comment" value={f.comment} onChange={(e) => setF({ ...f, comment: e.target.value })} />
|
||||||
|
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Issuing…" : "Issue PPE"}</button></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExperienceTab({ crewId, rows, ranks, canEdit, onDone }: { crewId: string; rows: Exp[]; ranks: { id: string; name: string }[]; canEdit: boolean; onDone: () => void }) {
|
||||||
|
const { pending, error, run } = useRun(onDone);
|
||||||
|
const [f, setF] = useState({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" });
|
||||||
|
function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("crewMemberId", crewId);
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
run(() => addExperience(fd), () => setF({ vesselType: "", rankId: "", fromDate: "", toDate: "", durationMonths: "" }));
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{rows.length === 0 ? <p className="text-sm text-neutral-400">No experience records.</p> : rows.map((r) => (
|
||||||
|
<div key={r.id} className="border-b border-neutral-50 last:border-0 py-2">
|
||||||
|
<p className="text-sm text-neutral-900">{r.rank ?? "—"}{r.vesselType ? ` · ${r.vesselType}` : ""}</p>
|
||||||
|
<p className="text-xs text-neutral-500">{fmtDate(r.fromDate)} – {fmtDate(r.toDate)}{r.durationMonths ? ` · ${r.durationMonths} mo` : ""} · {r.source}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<form onSubmit={submit} className="border-t border-neutral-100 pt-3 grid grid-cols-2 gap-2">
|
||||||
|
<select className={INPUT} value={f.rankId} onChange={(e) => setF({ ...f, rankId: e.target.value })}><option value="">Rank…</option>{ranks.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}</select>
|
||||||
|
<input className={INPUT} placeholder="Vessel type" value={f.vesselType} onChange={(e) => setF({ ...f, vesselType: e.target.value })} />
|
||||||
|
<label className="text-xs text-neutral-500">From<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} /></label>
|
||||||
|
<label className="text-xs text-neutral-500">To<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} /></label>
|
||||||
|
<input className={INPUT} type="number" min={0} placeholder="Duration (months)" value={f.durationMonths} onChange={(e) => setF({ ...f, durationMonths: e.target.value })} />
|
||||||
|
<div className="col-span-2"><Err msg={error} /><button className={BTN} disabled={pending}>{pending ? "Adding…" : "Add experience"}</button></div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayStatus({ paystatus }: { paystatus: Props["paystatus"] }) {
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
{!paystatus.showSalary ? (
|
||||||
|
<p className="text-sm text-neutral-500">Net pay is visible to office roles only. Site staff see pay <em>status</em> once monthly wage reports are generated.</p>
|
||||||
|
) : paystatus.salary ? (
|
||||||
|
<>
|
||||||
|
<Row k="Basic" v={`${paystatus.salary.currency} ${paystatus.salary.basic.toLocaleString("en-IN")} / ${paystatus.salary.rateBasis.toLowerCase()}`} />
|
||||||
|
<Row k="Victualing / day" v={`${paystatus.salary.currency} ${paystatus.salary.victualingPerDay.toLocaleString("en-IN")}`} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-400">No salary structure on file.</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-400 border-t border-neutral-100 pt-3">Monthly pay rows (paid / processing) arrive with payroll wage reports in a later phase.</p>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignOffButton({ assignmentId, crewName }: { assignmentId: string; crewName: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
const [remarks, setRemarks] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await signOffCrew(assignmentId, date, remarks);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error);
|
||||||
|
else { setOpen(false); router.push("/crewing/crew"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50">Sign off</button>
|
||||||
|
<AdminDialog title={`Sign off ${crewName}`} open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">Ends this tour: the assignment closes, a tour record is added to Experience, and the crew member returns to the Candidates pool as an ex-hand. A backfill requisition is auto-raised.</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Sign-off date *</label>
|
||||||
|
<input type="date" className={INPUT} value={date} onChange={(e) => setDate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Remarks</label>
|
||||||
|
<input className={INPUT} value={remarks} onChange={(e) => setRemarks(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !date} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">{pending ? "Signing off…" : "Sign off"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal file
113
App/app/(portal)/crewing/crew/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CrewProfile } from "./crew-profile";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Crew profile" };
|
||||||
|
|
||||||
|
export default async function CrewProfilePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
if (!hasPermission(role, "view_crew_records")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const c = await db.crewMember.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
currentRank: { select: { name: true } },
|
||||||
|
documents: { orderBy: { createdAt: "desc" } },
|
||||||
|
bankDetail: true,
|
||||||
|
epfDetail: true,
|
||||||
|
nextOfKin: { orderBy: { createdAt: "asc" } },
|
||||||
|
ppeIssues: { orderBy: { issuedDate: "desc" } },
|
||||||
|
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
|
||||||
|
assignments: {
|
||||||
|
where: { status: { not: "SIGNED_OFF" } },
|
||||||
|
orderBy: { signOnDate: "desc" },
|
||||||
|
take: 1,
|
||||||
|
include: {
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
salaryStructures: { orderBy: { effectiveFrom: "desc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!c) notFound();
|
||||||
|
if (c.status !== "EMPLOYEE") notFound(); // the Candidates page handles non-crew
|
||||||
|
|
||||||
|
const assignment = c.assignments[0] ?? null;
|
||||||
|
const showSalary = canViewSalary(role);
|
||||||
|
const currentSalary = assignment?.salaryStructures.find((s) => s.approvedById) ?? assignment?.salaryStructures[0] ?? null;
|
||||||
|
|
||||||
|
const ranks = await db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
||||||
|
|
||||||
|
const appraisals = await db.appraisal.findMany({
|
||||||
|
where: { assignment: { crewMemberId: c.id } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, period: true, status: true, comments: true, ratings: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CrewProfile
|
||||||
|
crew={{
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
employeeId: c.employeeId ?? "—",
|
||||||
|
rank: c.currentRank?.name ?? "—",
|
||||||
|
location: assignment?.vessel?.name ?? assignment?.site?.name ?? "—",
|
||||||
|
status: assignment?.status ?? null,
|
||||||
|
}}
|
||||||
|
documents={c.documents.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
docType: d.docType,
|
||||||
|
number: documentNumberValue(d.number, d.docType, role),
|
||||||
|
issueDate: d.issueDate?.toISOString() ?? null,
|
||||||
|
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||||
|
verificationStatus: d.verificationStatus,
|
||||||
|
hasFile: Boolean(d.fileKey),
|
||||||
|
}))}
|
||||||
|
bank={{
|
||||||
|
accountName: c.bankDetail?.accountName ?? null,
|
||||||
|
accountNumber: bankEpfValue(c.bankDetail?.accountNumber, role),
|
||||||
|
ifsc: c.bankDetail?.ifsc ?? null,
|
||||||
|
bankName: c.bankDetail?.bankName ?? null,
|
||||||
|
}}
|
||||||
|
epf={{
|
||||||
|
uan: c.epfDetail?.uan ?? null,
|
||||||
|
aadhaar: bankEpfValue(c.epfDetail?.aadhaarLast4, role),
|
||||||
|
pfNumber: c.epfDetail?.pfNumber ?? null,
|
||||||
|
}}
|
||||||
|
nextOfKin={c.nextOfKin.map((n) => ({ id: n.id, name: n.name, relationship: n.relationship, phone: n.phone, address: n.address, isEmergency: n.isEmergency }))}
|
||||||
|
ppe={c.ppeIssues.map((p) => ({ id: p.id, item: p.item, size: p.size, quantity: p.quantity, issuedDate: p.issuedDate.toISOString(), returnedDate: p.returnedDate?.toISOString() ?? null }))}
|
||||||
|
experience={c.experienceRecords.map((e) => ({ id: e.id, vesselType: e.vesselType, rank: e.rank?.name ?? null, fromDate: e.fromDate?.toISOString() ?? null, toDate: e.toDate?.toISOString() ?? null, durationMonths: e.durationMonths, source: e.source }))}
|
||||||
|
paystatus={{
|
||||||
|
showSalary,
|
||||||
|
salary: showSalary && currentSalary
|
||||||
|
? { basic: Number(currentSalary.basic), rateBasis: currentSalary.rateBasis, victualingPerDay: Number(currentSalary.victualingPerDay), currency: currentSalary.currency }
|
||||||
|
: null,
|
||||||
|
}}
|
||||||
|
ranks={ranks}
|
||||||
|
perms={{
|
||||||
|
editRecords: hasPermission(role, "upload_crew_records"),
|
||||||
|
issuePpe: hasPermission(role, "issue_ppe"),
|
||||||
|
}}
|
||||||
|
signOff={{ assignmentId: assignment?.id ?? null, canSignOff: hasPermission(role, "sign_off_crew") && Boolean(assignment) }}
|
||||||
|
appraisals={appraisals.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
period: a.period,
|
||||||
|
status: a.status,
|
||||||
|
comments: a.comments,
|
||||||
|
ratings: (a.ratings ?? null) as { competence: number | null; conduct: number | null; safety: number | null } | null,
|
||||||
|
}))}
|
||||||
|
appraisalCtx={{ assignmentId: assignment?.id ?? null, canRaise: hasPermission(role, "raise_appraisal") && Boolean(assignment) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
329
App/app/(portal)/crewing/crew/actions.ts
Normal file
329
App/app/(portal)/crewing/crew/actions.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
||||||
|
import { autoRaiseRequisition, notifyAutoRaised } from "@/lib/requisition-service";
|
||||||
|
import { SeafarerDocType, PpeItem } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
// Whole months between two dates (floored), min 0 — for the experience record.
|
||||||
|
function monthsBetween(from: Date, to: Date): number {
|
||||||
|
const months = (to.getFullYear() - from.getFullYear()) * 12 + (to.getMonth() - from.getMonth()) - (to.getDate() < from.getDate() ? 1 : 0);
|
||||||
|
return Math.max(0, months);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const crewPath = (id: string) => `/crewing/crew/${id}`;
|
||||||
|
|
||||||
|
async function guard(permission: Permission): Promise<{ error: string } | { userId: string }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireCrew(id: string) {
|
||||||
|
return db.crewMember.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Documents ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const docSchema = z.object({
|
||||||
|
crewMemberId: z.string().min(1),
|
||||||
|
docType: z.nativeEnum(SeafarerDocType),
|
||||||
|
number: z.string().optional(),
|
||||||
|
issueDate: z.string().optional(),
|
||||||
|
expiryDate: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function uploadDocument(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = docSchema.safeParse({
|
||||||
|
crewMemberId: formData.get("crewMemberId"),
|
||||||
|
docType: formData.get("docType"),
|
||||||
|
number: (formData.get("number") as string) || undefined,
|
||||||
|
issueDate: (formData.get("issueDate") as string) || undefined,
|
||||||
|
expiryDate: (formData.get("expiryDate") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
let fileKey: string | null = null;
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (file instanceof File && file.size > 0) {
|
||||||
|
fileKey = buildStorageKey("crew-document", d.crewMemberId, file.name);
|
||||||
|
await uploadBuffer(fileKey, Buffer.from(await file.arrayBuffer()), file.type || "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.seafarerDocument.create({
|
||||||
|
data: {
|
||||||
|
crewMemberId: d.crewMemberId,
|
||||||
|
docType: d.docType,
|
||||||
|
number: d.number ?? null,
|
||||||
|
fileKey,
|
||||||
|
issueDate: d.issueDate ? new Date(d.issueDate) : null,
|
||||||
|
expiryDate: d.expiryDate ? new Date(d.expiryDate) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "DOCUMENT_UPLOADED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { docType: d.docType } } });
|
||||||
|
|
||||||
|
revalidatePath(crewPath(d.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocument(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, docType: true } });
|
||||||
|
if (!doc) return { error: "Document not found" };
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
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));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bank & EPF ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const bankEpfSchema = z.object({
|
||||||
|
crewMemberId: z.string().min(1),
|
||||||
|
accountName: z.string().optional(),
|
||||||
|
accountNumber: z.string().optional(),
|
||||||
|
ifsc: z.string().optional(),
|
||||||
|
bankName: z.string().optional(),
|
||||||
|
uan: z.string().optional(),
|
||||||
|
aadhaarLast4: z.string().optional(),
|
||||||
|
pfNumber: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function saveBankEpf(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = bankEpfSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
await tx.bankDetail.upsert({
|
||||||
|
where: { crewMemberId: d.crewMemberId },
|
||||||
|
update: { accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||||
|
create: { crewMemberId: d.crewMemberId, accountName: d.accountName, accountNumber: d.accountNumber, ifsc: d.ifsc, bankName: d.bankName },
|
||||||
|
});
|
||||||
|
await tx.epfDetail.upsert({
|
||||||
|
where: { crewMemberId: d.crewMemberId },
|
||||||
|
update: { uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||||
|
create: { crewMemberId: d.crewMemberId, uan: d.uan, aadhaarLast4: d.aadhaarLast4, pfNumber: d.pfNumber },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "bank_epf" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(crewPath(d.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Next of kin / emergency ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const nokSchema = z.object({
|
||||||
|
crewMemberId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1, "Name is required"),
|
||||||
|
relationship: z.string().optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
isEmergency: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addNextOfKin(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = nokSchema.safeParse({
|
||||||
|
crewMemberId: formData.get("crewMemberId"),
|
||||||
|
name: formData.get("name"),
|
||||||
|
relationship: (formData.get("relationship") as string) || undefined,
|
||||||
|
phone: (formData.get("phone") as string) || undefined,
|
||||||
|
address: (formData.get("address") as string) || undefined,
|
||||||
|
isEmergency: formData.get("isEmergency") === "on" || formData.get("isEmergency") === "true",
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
await db.nextOfKin.create({
|
||||||
|
data: {
|
||||||
|
crewMemberId: d.crewMemberId,
|
||||||
|
name: d.name,
|
||||||
|
relationship: d.relationship ?? null,
|
||||||
|
phone: d.phone ?? null,
|
||||||
|
address: d.address ?? null,
|
||||||
|
isEmergency: d.isEmergency ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "RECORD_UPDATED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { record: "next_of_kin" } } });
|
||||||
|
|
||||||
|
revalidatePath(crewPath(d.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNextOfKin(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const nok = await db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true } });
|
||||||
|
if (!nok) return { error: "Record not found" };
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
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));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PPE ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ppeSchema = z.object({
|
||||||
|
crewMemberId: z.string().min(1),
|
||||||
|
item: z.nativeEnum(PpeItem),
|
||||||
|
size: z.string().optional(),
|
||||||
|
quantity: z.coerce.number().int().min(1).default(1),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function issuePpe(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("issue_ppe");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = ppeSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
await db.ppeIssue.create({
|
||||||
|
data: { crewMemberId: d.crewMemberId, item: d.item, size: d.size ?? null, quantity: d.quantity, comment: d.comment ?? null, issuedById: g.userId },
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "PPE_ISSUED", actorId: g.userId, crewMemberId: d.crewMemberId, metadata: { item: d.item } } });
|
||||||
|
|
||||||
|
revalidatePath(crewPath(d.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function returnPpe(id: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("issue_ppe");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
const ppe = await db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, returnedDate: true } });
|
||||||
|
if (!ppe) return { error: "PPE record not found" };
|
||||||
|
if (ppe.returnedDate) return { error: "Already returned" };
|
||||||
|
await db.ppeIssue.update({ where: { id }, data: { returnedDate: new Date() } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "PPE_RETURNED", actorId: g.userId, crewMemberId: ppe.crewMemberId } });
|
||||||
|
revalidatePath(crewPath(ppe.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Experience ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const expSchema = z.object({
|
||||||
|
crewMemberId: z.string().min(1),
|
||||||
|
vesselType: z.string().optional(),
|
||||||
|
rankId: z.string().optional(),
|
||||||
|
fromDate: z.string().optional(),
|
||||||
|
toDate: z.string().optional(),
|
||||||
|
durationMonths: z.coerce.number().int().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function addExperience(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("upload_crew_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = expSchema.safeParse(Object.fromEntries(formData));
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
if (!(await requireCrew(d.crewMemberId))) return { error: "Crew member not found" };
|
||||||
|
|
||||||
|
await db.experienceRecord.create({
|
||||||
|
data: {
|
||||||
|
crewMemberId: d.crewMemberId,
|
||||||
|
vesselType: d.vesselType ?? null,
|
||||||
|
rankId: d.rankId || null,
|
||||||
|
fromDate: d.fromDate ? new Date(d.fromDate) : null,
|
||||||
|
toDate: d.toDate ? new Date(d.toDate) : null,
|
||||||
|
durationMonths: d.durationMonths ?? null,
|
||||||
|
source: "declared",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "EXPERIENCE_ADDED", actorId: g.userId, crewMemberId: d.crewMemberId } });
|
||||||
|
|
||||||
|
revalidatePath(crewPath(d.crewMemberId));
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sign off (Phase 4c, Epic K) ────────────────────────────────────────────────
|
||||||
|
// Ends a tour of duty: assignment → SIGNED_OFF, append an internal EXPERIENCE_RECORD,
|
||||||
|
// flip the crew member back to EX_HAND (so they return to the Candidates pool), and
|
||||||
|
// auto-raise a SIGN_OFF backfill requisition (reuses the Phase-2 helper).
|
||||||
|
|
||||||
|
export async function signOffCrew(assignmentId: string, signOffDate: string, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("sign_off_crew");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!signOffDate) return { error: "A sign-off date is required" };
|
||||||
|
|
||||||
|
const assignment = await db.crewAssignment.findUnique({
|
||||||
|
where: { id: assignmentId },
|
||||||
|
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!assignment) return { error: "Assignment not found" };
|
||||||
|
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has already signed off" };
|
||||||
|
|
||||||
|
const off = new Date(signOffDate);
|
||||||
|
|
||||||
|
// Sign-off + the backfill requisition commit atomically (spec §5.3/§11): the
|
||||||
|
// 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.experienceRecord.create({
|
||||||
|
data: {
|
||||||
|
crewMemberId: assignment.crewMemberId,
|
||||||
|
rankId: assignment.rankId,
|
||||||
|
vesselType: assignment.vessel?.name ?? assignment.site?.name ?? null,
|
||||||
|
fromDate: assignment.signOnDate,
|
||||||
|
toDate: off,
|
||||||
|
durationMonths: monthsBetween(assignment.signOnDate, off),
|
||||||
|
source: "internal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Same entity: flip EMPLOYEE → EX_HAND; they reappear in Candidates as a
|
||||||
|
// returning hand. The ex-hand flag lives on type/status — their original
|
||||||
|
// source (how they were first recruited) is preserved. currentRank (rank
|
||||||
|
// held) is refreshed to the tour they just signed off from.
|
||||||
|
await tx.crewMember.update({
|
||||||
|
where: { id: assignment.crewMemberId },
|
||||||
|
data: { status: "EX_HAND", type: "EX_HAND", currentRankId: assignment.rankId },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({
|
||||||
|
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);
|
||||||
|
|
||||||
|
revalidatePath(crewPath(assignment.crewMemberId));
|
||||||
|
revalidatePath("/crewing/crew");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal file
93
App/app/(portal)/crewing/crew/crew-directory.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { AssignmentStatus } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type CrewRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
employeeId: string;
|
||||||
|
rank: string;
|
||||||
|
location: string;
|
||||||
|
status: AssignmentStatus | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"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";
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: AssignmentStatus | null }) {
|
||||||
|
if (status === "ACTIVE") return <Badge variant="success">Active</Badge>;
|
||||||
|
if (status === "ON_LEAVE") return <Badge variant="warning">On leave</Badge>;
|
||||||
|
return <Badge variant="secondary">—</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrewDirectory({ crew }: { crew: CrewRow[] }) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [location, setLocation] = useState("ALL");
|
||||||
|
|
||||||
|
const locations = useMemo(
|
||||||
|
() => Array.from(new Set(crew.map((c) => c.location).filter((l) => l !== "—"))).sort(),
|
||||||
|
[crew]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return crew.filter((c) => {
|
||||||
|
if (location !== "ALL" && c.location !== location) return false;
|
||||||
|
if (q && !`${c.name} ${c.employeeId} ${c.rank}`.toLowerCase().includes(q)) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [crew, search, location]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Crew</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">{crew.length} active crew member{crew.length === 1 ? "" : "s"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<input className={`${INPUT} flex-1 min-w-[200px]`} placeholder="Search name, employee no or rank…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
|
||||||
|
<option value="ALL">All vessels / sites</option>
|
||||||
|
{locations.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3">Employee</th>
|
||||||
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
<th className="px-4 py-3">Vessel / site</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-neutral-400">
|
||||||
|
{crew.length === 0 ? "No crew onboarded yet." : "No crew match these filters."}
|
||||||
|
</td></tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/crewing/crew/${c.id}`} className="font-medium text-neutral-900 hover:text-primary-700">{c.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{c.employeeId}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{c.rank}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{c.location}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={c.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
App/app/(portal)/crewing/crew/page.tsx
Normal file
55
App/app/(portal)/crewing/crew/page.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { CrewDirectory } from "./crew-directory";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Crew" };
|
||||||
|
|
||||||
|
export default async function CrewPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "view_crew_records")) redirect("/dashboard");
|
||||||
|
|
||||||
|
// Own-site scoping (§8.7): a site-staff user with a home site sees only crew whose
|
||||||
|
// active assignment is at that site. Without a home site they remain unscoped.
|
||||||
|
let siteScopeId: string | null = null;
|
||||||
|
if (session.user.role === "SITE_STAFF") {
|
||||||
|
siteScopeId = (await db.user.findUnique({ where: { id: session.user.id }, select: { siteId: true } }))?.siteId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crew = await db.crewMember.findMany({
|
||||||
|
where: {
|
||||||
|
status: "EMPLOYEE",
|
||||||
|
...(siteScopeId ? { assignments: { some: { status: { not: "SIGNED_OFF" }, siteId: siteScopeId } } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
include: {
|
||||||
|
currentRank: { select: { name: true } },
|
||||||
|
assignments: {
|
||||||
|
where: { status: { not: "SIGNED_OFF" } },
|
||||||
|
orderBy: { signOnDate: "desc" },
|
||||||
|
take: 1,
|
||||||
|
include: { vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = crew.map((c) => {
|
||||||
|
const a = c.assignments[0];
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
employeeId: c.employeeId ?? "—",
|
||||||
|
rank: c.currentRank?.name ?? "—",
|
||||||
|
location: a?.vessel?.name ?? a?.site?.name ?? "—",
|
||||||
|
status: a?.status ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return <CrewDirectory crew={rows} />;
|
||||||
|
}
|
||||||
138
App/app/(portal)/crewing/leave/actions.ts
Normal file
138
App/app/(portal)/crewing/leave/actions.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { leaveCausesClash } from "@/lib/leave-clash";
|
||||||
|
import { autoRaiseRequisition, notifyAutoRaised, getManagerRecipients } from "@/lib/requisition-service";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import { LeaveType } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const LEAVE_PATH = "/crewing/leave";
|
||||||
|
|
||||||
|
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
function revalidate() {
|
||||||
|
revalidatePath(LEAVE_PATH);
|
||||||
|
revalidatePath("/approvals");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply for leave (Site staff, on behalf of a crew member) ───────────────────
|
||||||
|
|
||||||
|
const applySchema = z
|
||||||
|
.object({
|
||||||
|
assignmentId: z.string().min(1, "Crew member is required"),
|
||||||
|
type: z.nativeEnum(LeaveType).default("ANNUAL"),
|
||||||
|
fromDate: z.string().min(1, "From date is required"),
|
||||||
|
toDate: z.string().min(1, "To date is required"),
|
||||||
|
reason: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => new Date(d.toDate) >= new Date(d.fromDate), { message: "To date must be on or after the from date" });
|
||||||
|
|
||||||
|
export async function applyLeave(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("apply_leave");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = applySchema.safeParse({
|
||||||
|
assignmentId: formData.get("assignmentId"),
|
||||||
|
type: (formData.get("type") as string) || undefined,
|
||||||
|
fromDate: formData.get("fromDate"),
|
||||||
|
toDate: formData.get("toDate"),
|
||||||
|
reason: (formData.get("reason") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const assignment = await db.crewAssignment.findUnique({
|
||||||
|
where: { id: d.assignmentId },
|
||||||
|
include: { crewMember: { select: { id: true, name: true } }, rank: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!assignment) return { error: "Crew assignment not found" };
|
||||||
|
if (assignment.status === "SIGNED_OFF") return { error: "This crew member has signed off" };
|
||||||
|
|
||||||
|
const leave = await db.leaveRequest.create({
|
||||||
|
data: {
|
||||||
|
assignmentId: d.assignmentId,
|
||||||
|
type: d.type,
|
||||||
|
fromDate: new Date(d.fromDate),
|
||||||
|
toDate: new Date(d.toDate),
|
||||||
|
reason: d.reason ?? null,
|
||||||
|
appliedById: g.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db.crewAction.create({ data: { actionType: "LEAVE_APPLIED", actorId: g.userId, crewMemberId: assignment.crewMember.id } });
|
||||||
|
|
||||||
|
const managers = await getManagerRecipients();
|
||||||
|
await notifyCrew({
|
||||||
|
event: "LEAVE_FOR_APPROVAL",
|
||||||
|
recipients: managers,
|
||||||
|
subject: `Leave for approval — ${assignment.crewMember.name}`,
|
||||||
|
body: `${assignment.crewMember.name} (${assignment.rank.name}) has a leave request from ${d.fromDate} to ${d.toDate} awaiting your decision.`,
|
||||||
|
link: LEAVE_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
return { ok: true, id: leave.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decide leave (Manager) ─────────────────────────────────────────────────────
|
||||||
|
// On approval the assignment goes ON_LEAVE and a clash check runs; if it would
|
||||||
|
// leave the vessel with no same-rank cover, a LEAVE requisition is auto-raised.
|
||||||
|
|
||||||
|
export async function decideLeave(id: string, approve: boolean, note?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("decide_leave");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const leave = await db.leaveRequest.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { assignment: { select: { id: true, crewMemberId: true, rankId: true, vesselId: true, siteId: true } } },
|
||||||
|
});
|
||||||
|
if (!leave) return { error: "Leave request not found" };
|
||||||
|
if (leave.status !== "APPLIED") return { error: `This leave request is already ${leave.status}` };
|
||||||
|
if (!approve && !note?.trim()) return { error: "A reason is required to decline" };
|
||||||
|
|
||||||
|
if (!approve) {
|
||||||
|
await db.leaveRequest.update({ where: { id }, data: { status: "REJECTED", decidedById: g.userId, decidedAt: new Date(), reason: note?.trim() || leave.reason } });
|
||||||
|
await db.crewAction.create({ data: { actionType: "LEAVE_DECIDED", actorId: g.userId, crewMemberId: leave.assignment.crewMemberId, note: note?.trim() || null, metadata: { decision: "REJECTED" } } });
|
||||||
|
revalidate();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave approval + the clash check + any backfill requisition commit atomically
|
||||||
|
// (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.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" } } });
|
||||||
|
const clash = await leaveCausesClash(tx, {
|
||||||
|
assignmentId: leave.assignment.id,
|
||||||
|
rankId: leave.assignment.rankId,
|
||||||
|
vesselId: leave.assignment.vesselId,
|
||||||
|
fromDate: leave.fromDate,
|
||||||
|
toDate: leave.toDate,
|
||||||
|
});
|
||||||
|
if (!clash) return null;
|
||||||
|
return autoRaiseRequisition(
|
||||||
|
{ rankId: leave.assignment.rankId, vesselId: leave.assignment.vesselId, siteId: leave.assignment.siteId, reason: "LEAVE" },
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the office after the transaction commits.
|
||||||
|
if (backfill) await notifyAutoRaised(backfill);
|
||||||
|
|
||||||
|
revalidate();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal file
163
App/app/(portal)/crewing/leave/leave-manager.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { LeaveStatus, LeaveType } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { applyLeave, decideLeave } 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";
|
||||||
|
const LEAVE_TYPES: LeaveType[] = ["ANNUAL", "MEDICAL", "EMERGENCY", "UNPAID", "OTHER"];
|
||||||
|
const fmt = (iso: string) => new Date(iso).toLocaleDateString();
|
||||||
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
|
||||||
|
type Assignment = { id: string; crewName: string; rank: string; location: string };
|
||||||
|
type Request = { id: string; crewName: string; rank: string; location: string; type: LeaveType; status: LeaveStatus; fromDate: string; toDate: string; reason: string | null };
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<LeaveStatus, "warning" | "success" | "danger" | "secondary"> = {
|
||||||
|
APPLIED: "warning", APPROVED: "success", REJECTED: "danger", CANCELLED: "secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LeaveManager({ assignments, requests, canApply, canDecide }: { assignments: Assignment[]; requests: Request[]; canApply: boolean; canDecide: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [f, setF] = useState({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" });
|
||||||
|
|
||||||
|
const duration = f.fromDate && f.toDate ? Math.max(0, Math.round((new Date(f.toDate).getTime() - new Date(f.fromDate).getTime()) / 86400000) + 1) : 0;
|
||||||
|
|
||||||
|
async function submitApply(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
Object.entries(f).forEach(([k, v]) => v && fd.set(k, v));
|
||||||
|
const res = await applyLeave(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error);
|
||||||
|
else { setOpen(false); setF({ assignmentId: "", type: "ANNUAL", fromDate: "", toDate: "", reason: "" }); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Leave</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Site staff apply on behalf of crew · the Manager approves.</p>
|
||||||
|
</div>
|
||||||
|
{canApply && <button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">Apply for leave</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th>
|
||||||
|
<th className="px-4 py-3">Rank / location</th>
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Dates</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{requests.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-neutral-400">No leave requests.</td></tr>
|
||||||
|
) : requests.map((r) => (
|
||||||
|
<DecisionRow key={r.id} r={r} canDecide={canDecide} onDone={() => router.refresh()} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminDialog title="Apply for leave" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={submitApply} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Crew member *</label>
|
||||||
|
<select className={INPUT} value={f.assignmentId} onChange={(e) => setF({ ...f, assignmentId: e.target.value })} required>
|
||||||
|
<option value="">— Select crew —</option>
|
||||||
|
{assignments.map((a) => <option key={a.id} value={a.id}>{a.crewName} · {a.rank} · {a.location}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Type</label>
|
||||||
|
<select className={INPUT} value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })}>
|
||||||
|
{LEAVE_TYPES.map((t) => <option key={t} value={t}>{label(t)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">From *</label>
|
||||||
|
<input type="date" className={INPUT} value={f.fromDate} onChange={(e) => setF({ ...f, fromDate: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">To *</label>
|
||||||
|
<input type="date" className={INPUT} value={f.toDate} onChange={(e) => setF({ ...f, toDate: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{duration > 0 && <p className="text-xs text-neutral-500 bg-neutral-50 rounded-md px-3 py-2">{duration} day{duration === 1 ? "" : "s"} of leave.</p>}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||||
|
<input className={INPUT} value={f.reason} onChange={(e) => setF({ ...f, reason: e.target.value })} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !f.assignmentId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Applying…" : "Apply"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecisionRow({ r, canDecide, onDone }: { r: Request; canDecide: boolean; onDone: () => void }) {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [declineOpen, setDeclineOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
async function approve() {
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await decideLeave(r.id, true);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else onDone();
|
||||||
|
}
|
||||||
|
async function decline(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await decideLeave(r.id, false, reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setDeclineOpen(false); onDone(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{r.rank} · {r.location}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{label(r.type)}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{fmt(r.fromDate)} – {fmt(r.toDate)}</td>
|
||||||
|
<td className="px-4 py-3"><Badge variant={STATUS_VARIANT[r.status]}>{label(r.status)}</Badge></td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{r.status === "APPLIED" && (canDecide ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={approve} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Approve</button>
|
||||||
|
<button onClick={() => setDeclineOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Decline</button>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-neutral-400">Awaiting manager</span>)}
|
||||||
|
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||||
|
<AdminDialog title="Decline leave" open={declineOpen} onClose={() => setDeclineOpen(false)}>
|
||||||
|
<form onSubmit={decline} className="space-y-4 text-left">
|
||||||
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason" />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setDeclineOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Decline</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
App/app/(portal)/crewing/leave/page.tsx
Normal file
52
App/app/(portal)/crewing/leave/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { LeaveManager } from "./leave-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Leave" };
|
||||||
|
|
||||||
|
export default async function LeavePage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
const canApply = hasPermission(role, "apply_leave");
|
||||||
|
const canDecide = hasPermission(role, "decide_leave");
|
||||||
|
if (!canApply && !canDecide) redirect("/dashboard"); // MPO has no leave screen (R1)
|
||||||
|
|
||||||
|
const [assignments, requests] = await Promise.all([
|
||||||
|
db.crewAssignment.findMany({
|
||||||
|
where: { status: { not: "SIGNED_OFF" } },
|
||||||
|
orderBy: { crewMember: { name: "asc" } },
|
||||||
|
include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.leaveRequest.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LeaveManager
|
||||||
|
assignments={assignments.map((a) => ({ id: a.id, crewName: a.crewMember.name, rank: a.rank.name, location: a.vessel?.name ?? a.site?.name ?? "—" }))}
|
||||||
|
requests={requests.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
crewName: r.assignment.crewMember.name,
|
||||||
|
rank: r.assignment.rank.name,
|
||||||
|
location: r.assignment.vessel?.name ?? r.assignment.site?.name ?? "—",
|
||||||
|
type: r.type,
|
||||||
|
status: r.status,
|
||||||
|
fromDate: r.fromDate.toISOString(),
|
||||||
|
toDate: r.toDate.toISOString(),
|
||||||
|
reason: r.reason,
|
||||||
|
}))}
|
||||||
|
canApply={canApply}
|
||||||
|
canDecide={canDecide}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
138
App/app/(portal)/crewing/requisitions/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { canCancel } from "@/lib/requisition-state-machine";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { WithdrawRequisitionButton } from "./withdraw-button";
|
||||||
|
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "../requisition-ui";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Requisition" };
|
||||||
|
|
||||||
|
export default async function RequisitionDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const req = await db.requisition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true, code: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
raisedBy: { select: { name: true } },
|
||||||
|
sourceReliefRequest: { select: { id: true, requestedBy: { select: { name: true } } } },
|
||||||
|
_count: { select: { applications: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!req) notFound();
|
||||||
|
|
||||||
|
const location = req.vessel?.name ?? req.site?.name ?? "—";
|
||||||
|
const canWithdraw = hasPermission(session.user.role, "cancel_requisition") && canCancel(req.status, session.user.role);
|
||||||
|
|
||||||
|
const details: [string, string][] = [
|
||||||
|
["Requisition", req.code],
|
||||||
|
["Rank", `${req.rank.name} (${req.rank.code})`],
|
||||||
|
["Vessel / site", location],
|
||||||
|
["Reason", REASON_LABEL[req.reason]],
|
||||||
|
["Raised by", req.autoRaised ? "System (auto-raised)" : req.raisedBy?.name ?? "—"],
|
||||||
|
["Raised", `${ageLabel(req.createdAt.toISOString())} ago`],
|
||||||
|
["Needed by", req.neededBy ? req.neededBy.toLocaleDateString() : "—"],
|
||||||
|
];
|
||||||
|
if (req.status === "CANCELLED" && req.cancellationReason) {
|
||||||
|
details.push(["Withdrawn", req.cancellationReason]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<Link href="/crewing/requisitions" className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Requisitions
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{req.rank.name} — {location}</h1>
|
||||||
|
<Badge variant={STATUS_VARIANT[req.status]}>{STATUS_LABEL[req.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 mt-1">
|
||||||
|
<span className="font-mono">{req.code}</span> · {REASON_LABEL[req.reason]} · {ageLabel(req.createdAt.toISOString())} ago
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/crewing/requisitions/${req.id}/pipeline`}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Open pipeline
|
||||||
|
</Link>
|
||||||
|
{canWithdraw && <WithdrawRequisitionButton id={req.id} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{req.autoRaised && (
|
||||||
|
<div className="mb-6 rounded-lg border border-warning-200 bg-warning-50 px-4 py-3 text-sm text-warning-800">
|
||||||
|
This requisition was <strong>auto-raised by the system</strong> ({REASON_LABEL[req.reason]}). No manual action
|
||||||
|
was needed to open it.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{req.sourceReliefRequest && (
|
||||||
|
<div className="mb-6 rounded-lg border border-primary-200 bg-primary-50 px-4 py-3 text-sm text-primary-800">
|
||||||
|
Converted from a relief request raised by{" "}
|
||||||
|
<strong>{req.sourceReliefRequest.requestedBy.name}</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Vacancy details */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Vacancy details</h2>
|
||||||
|
</div>
|
||||||
|
<dl className="divide-y divide-neutral-100">
|
||||||
|
{details.map(([k, v]) => (
|
||||||
|
<div key={k} className="flex justify-between gap-4 px-4 py-2.5">
|
||||||
|
<dt className="text-sm text-neutral-500">{k}</dt>
|
||||||
|
<dd className="text-sm text-neutral-900 text-right">{v}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{req.notes && (
|
||||||
|
<div className="px-4 py-3 border-t border-neutral-100">
|
||||||
|
<p className="text-xs font-medium text-neutral-500 mb-1">Notes</p>
|
||||||
|
<p className="text-sm text-neutral-700">{req.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Candidates — the recruitment pipeline (Phase 3b) */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Candidates</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-8 text-center">
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{req._count.applications}</p>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5 mb-4">
|
||||||
|
candidate{req._count.applications === 1 ? "" : "s"} in the pipeline
|
||||||
|
</p>
|
||||||
|
<Link href={`/crewing/requisitions/${req.id}/pipeline`} className="text-sm font-medium text-primary-600 hover:underline">
|
||||||
|
Open recruitment pipeline →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal file
63
App/app/(portal)/crewing/requisitions/[id]/pipeline/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { PipelineBoard } from "./pipeline-board";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Recruitment pipeline" };
|
||||||
|
|
||||||
|
export default async function PipelinePage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
if (!hasPermission(role, "view_requisitions")) redirect("/dashboard");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const requisition = await db.requisition.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { rank: { select: { name: true } }, vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!requisition) notFound();
|
||||||
|
|
||||||
|
const applications = await db.application.findMany({
|
||||||
|
where: { requisitionId: id },
|
||||||
|
include: { crewMember: { select: { id: true, name: true, type: true, experienceMonths: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canManage = hasPermission(role, "manage_candidates");
|
||||||
|
// Candidates available to add: in the pool (not employees) and not already applied here.
|
||||||
|
const appliedIds = new Set(applications.map((a) => a.crewMemberId));
|
||||||
|
const pool = canManage
|
||||||
|
? (await db.crewMember.findMany({
|
||||||
|
where: { status: { not: "EMPLOYEE" } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, type: true },
|
||||||
|
})).filter((c) => !appliedIds.has(c.id))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PipelineBoard
|
||||||
|
requisition={{
|
||||||
|
id: requisition.id,
|
||||||
|
code: requisition.code,
|
||||||
|
rank: requisition.rank.name,
|
||||||
|
location: requisition.vessel?.name ?? requisition.site?.name ?? "—",
|
||||||
|
status: requisition.status,
|
||||||
|
}}
|
||||||
|
applications={applications.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
stage: a.stage,
|
||||||
|
crewName: a.crewMember.name,
|
||||||
|
isExHand: a.crewMember.type === "EX_HAND",
|
||||||
|
experienceMonths: a.crewMember.experienceMonths,
|
||||||
|
}))}
|
||||||
|
pool={pool}
|
||||||
|
canManage={canManage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import type { ApplicationStage, RequisitionStatus } from "@prisma/client";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { STAGE_ORDER, STAGE_LABEL } from "../../../applications/application-ui";
|
||||||
|
import { addApplication } from "../../../applications/actions";
|
||||||
|
|
||||||
|
type AppCard = { id: string; stage: ApplicationStage; crewName: string; isExHand: boolean; experienceMonths: number };
|
||||||
|
type PoolItem = { id: string; name: string; type: string };
|
||||||
|
|
||||||
|
export function PipelineBoard({
|
||||||
|
requisition,
|
||||||
|
applications,
|
||||||
|
pool,
|
||||||
|
canManage,
|
||||||
|
}: {
|
||||||
|
requisition: { id: string; code: string; rank: string; location: string; status: RequisitionStatus };
|
||||||
|
applications: AppCard[];
|
||||||
|
pool: PoolItem[];
|
||||||
|
canManage: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [crewMemberId, setCrewMemberId] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function add(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("requisitionId", requisition.id);
|
||||||
|
fd.set("crewMemberId", crewMemberId);
|
||||||
|
const res = await addApplication(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error);
|
||||||
|
else { setOpen(false); setCrewMemberId(""); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const byStage = (s: ApplicationStage) => applications.filter((a) => a.stage === s);
|
||||||
|
const rejected = applications.filter((a) => a.stage === "REJECTED");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link href={`/crewing/requisitions/${requisition.id}`} className="inline-flex items-center gap-1.5 text-sm text-neutral-500 hover:text-neutral-800 mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4" /> Requisition
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">{requisition.rank} — {requisition.location}</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Recruitment pipeline · <span className="font-mono">{requisition.code}</span> · {applications.length} candidate{applications.length === 1 ? "" : "s"}</p>
|
||||||
|
</div>
|
||||||
|
{canManage && (
|
||||||
|
<button onClick={() => setOpen(true)} className="rounded-lg border border-neutral-300 px-4 py-2.5 text-sm font-semibold text-neutral-700 hover:bg-neutral-50">
|
||||||
|
+ Add candidate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||||
|
{STAGE_ORDER.map((s) => {
|
||||||
|
const cards = byStage(s);
|
||||||
|
return (
|
||||||
|
<div key={s} className="w-56 shrink-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between px-1">
|
||||||
|
<span className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">{STAGE_LABEL[s]}</span>
|
||||||
|
<span className="text-xs text-neutral-400">{cards.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 min-h-[60px] rounded-lg bg-neutral-50 p-2">
|
||||||
|
{cards.map((a) => (
|
||||||
|
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="block rounded-md border border-neutral-200 bg-white p-3 hover:border-primary-300 hover:shadow-sm transition">
|
||||||
|
<p className="text-sm font-medium text-neutral-900">{a.crewName}</p>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5">
|
||||||
|
{Math.floor(a.experienceMonths / 12)} yrs
|
||||||
|
{a.isExHand && <span className="ml-1 text-purple-600">· ex-hand</span>}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{cards.length === 0 && <p className="text-center text-xs text-neutral-300 py-2">—</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rejected.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<p className="text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-2">Rejected ({rejected.length})</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{rejected.map((a) => (
|
||||||
|
<Link key={a.id} href={`/crewing/applications/${a.id}`} className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-500 hover:bg-neutral-50">
|
||||||
|
{a.crewName}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminDialog title="Add candidate to pipeline" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={add} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Candidate</label>
|
||||||
|
<select className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" value={crewMemberId} onChange={(e) => setCrewMemberId(e.target.value)} required>
|
||||||
|
<option value="">— Select from the pool —</option>
|
||||||
|
{pool.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}{c.type === "EX_HAND" ? " (ex-hand)" : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{pool.length === 0 && <p className="mt-1 text-xs text-neutral-400">No available candidates. Add candidates from the Candidates page first.</p>}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending || !crewMemberId} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">{pending ? "Adding…" : "Add to pipeline"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { cancelRequisition } 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 function WithdrawRequisitionButton({ id }: { id: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const result = await cancelRequisition(id, reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg border border-danger-300 px-4 py-2 text-sm font-medium text-danger-700 hover:bg-danger-50"
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Withdraw requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Withdrawing closes this requisition. A reason is required and is recorded on the audit trail.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason *</label>
|
||||||
|
<textarea className={INPUT} rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60">
|
||||||
|
{pending ? "Withdrawing…" : "Withdraw requisition"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal file
303
App/app/(portal)/crewing/requisitions/actions.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import {
|
||||||
|
canCancel,
|
||||||
|
canPerformAction,
|
||||||
|
getTransition,
|
||||||
|
type RequisitionAction,
|
||||||
|
} from "@/lib/requisition-state-machine";
|
||||||
|
import {
|
||||||
|
createRequisitionTx,
|
||||||
|
getMpoRecipients,
|
||||||
|
getOfficeRecipients,
|
||||||
|
requisitionLocationLabel,
|
||||||
|
} from "@/lib/requisition-service";
|
||||||
|
import { notifyCrew } from "@/lib/notifier";
|
||||||
|
import { RequisitionReason } from "@prisma/client";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true; id?: string } | { error: string };
|
||||||
|
|
||||||
|
const LIST_PATH = "/crewing/requisitions";
|
||||||
|
|
||||||
|
// Crewing flag + permission guard. Returns the actor on success.
|
||||||
|
async function guard(
|
||||||
|
permission: Permission
|
||||||
|
): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raise a requisition (MPO / Manager) ───────────────────────────────────────
|
||||||
|
|
||||||
|
const raiseSchema = z
|
||||||
|
.object({
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
vesselId: z.string().optional(),
|
||||||
|
siteId: z.string().optional(),
|
||||||
|
reason: z.nativeEnum(RequisitionReason).default("NEW_VACANCY"),
|
||||||
|
neededBy: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
|
||||||
|
message: "A vessel or site is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function raiseRequisition(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("raise_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = raiseSchema.safeParse({
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
|
reason: (formData.get("reason") as string) || undefined,
|
||||||
|
neededBy: (formData.get("neededBy") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const requisition = await db.$transaction((tx) =>
|
||||||
|
createRequisitionTx(tx, {
|
||||||
|
rankId: d.rankId,
|
||||||
|
vesselId: d.vesselId || null,
|
||||||
|
siteId: d.siteId || null,
|
||||||
|
reason: d.reason,
|
||||||
|
neededBy: d.neededBy ? new Date(d.neededBy) : null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
raisedById: g.userId,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify the MPO pool so it can start sourcing (spec §11). Don't self-notify.
|
||||||
|
const recipients = (await getMpoRecipients()).filter((u) => u.id !== g.userId);
|
||||||
|
if (recipients.length) {
|
||||||
|
const loc = requisitionLocationLabel(requisition);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "REQUISITION_RAISED",
|
||||||
|
recipients,
|
||||||
|
subject: `Requisition ${requisition.code} raised`,
|
||||||
|
body: `A ${requisition.rank.name} vacancy on ${loc} has been raised (${requisition.code}).`,
|
||||||
|
link: `${LIST_PATH}/${requisition.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: requisition.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Withdraw / cancel a requisition (Manager, from OPEN/SHORTLISTING) ──────────
|
||||||
|
|
||||||
|
export async function cancelRequisition(id: string, reason: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("cancel_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const trimmed = reason?.trim();
|
||||||
|
if (!trimmed) return { error: "A reason is required to withdraw a requisition" };
|
||||||
|
|
||||||
|
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!req) return { error: "Requisition not found" };
|
||||||
|
if (!canCancel(req.status, g.role)) {
|
||||||
|
return { error: `A requisition cannot be withdrawn once it is ${req.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.requisition.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
cancellationReason: trimmed,
|
||||||
|
actions: {
|
||||||
|
create: { actionType: "REQUISITION_CANCELLED", actorId: g.userId, note: trimmed },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advance a requisition through the pipeline stages ──────────────────────────
|
||||||
|
// Phase 2 exposes the transitions; the recruitment pipeline (Phase 3) drives
|
||||||
|
// them as candidates progress. Role gating comes from the state machine.
|
||||||
|
|
||||||
|
export async function transitionRequisition(
|
||||||
|
id: string,
|
||||||
|
action: RequisitionAction
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
const req = await db.requisition.findUnique({ where: { id }, select: { status: true } });
|
||||||
|
if (!req) return { error: "Requisition not found" };
|
||||||
|
|
||||||
|
const transition = getTransition(req.status, action);
|
||||||
|
if (!transition) return { error: `Cannot ${action} from ${req.status}` };
|
||||||
|
if (!canPerformAction(req.status, action, session.user.role)) return { error: "Unauthorized" };
|
||||||
|
|
||||||
|
await db.requisition.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: transition.to,
|
||||||
|
filledAt: transition.to === "FILLED" ? new Date() : undefined,
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: transition.to === "FILLED" ? "REQUISITION_FILLED" : "REQUISITION_ADVANCED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
metadata: { from: req.status, to: transition.to },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
revalidatePath(`${LIST_PATH}/${id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Relief cover request (site staff) ──────────────────────────────────────────
|
||||||
|
// Site staff flag a foreseen gap; the office converts it into a requisition. The
|
||||||
|
// site-staff origination UI lands with the Leave/clash screen (Phase 4); the
|
||||||
|
// action exists now so the office-side convert flow and auto-raise share a path.
|
||||||
|
|
||||||
|
const reliefSchema = z
|
||||||
|
.object({
|
||||||
|
rankId: z.string().min(1, "Rank is required"),
|
||||||
|
vesselId: z.string().optional(),
|
||||||
|
siteId: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => Boolean(d.vesselId) || Boolean(d.siteId), {
|
||||||
|
message: "A vessel or site is required",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function requestReliefCover(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("request_relief_cover");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = reliefSchema.safeParse({
|
||||||
|
rankId: formData.get("rankId"),
|
||||||
|
vesselId: (formData.get("vesselId") as string) || undefined,
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
|
note: (formData.get("note") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const relief = await db.$transaction(async (tx) => {
|
||||||
|
const created = await tx.reliefRequest.create({
|
||||||
|
data: {
|
||||||
|
rankId: d.rankId,
|
||||||
|
vesselId: d.vesselId || null,
|
||||||
|
siteId: d.siteId || null,
|
||||||
|
note: d.note || null,
|
||||||
|
requestedById: g.userId,
|
||||||
|
},
|
||||||
|
include: { rank: true, vessel: true, site: true },
|
||||||
|
});
|
||||||
|
// CrewAction has no relief relation; record the id in metadata.
|
||||||
|
await tx.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: "RELIEF_REQUESTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
metadata: { reliefRequestId: created.id, rankId: d.rankId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipients = await getOfficeRecipients();
|
||||||
|
if (recipients.length) {
|
||||||
|
const loc = requisitionLocationLabel(relief);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "RELIEF_REQUESTED",
|
||||||
|
recipients,
|
||||||
|
subject: `Relief cover requested — ${relief.rank.name} on ${loc}`,
|
||||||
|
body: `A site has requested relief cover for a ${relief.rank.name} on ${loc}. Convert it to a requisition to start sourcing.`,
|
||||||
|
link: LIST_PATH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: relief.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convert a relief request into a requisition (MPO / Manager) ────────────────
|
||||||
|
|
||||||
|
const convertSchema = z.object({
|
||||||
|
reliefRequestId: z.string().min(1, "Relief request is required"),
|
||||||
|
reason: z.nativeEnum(RequisitionReason).default("REPLACEMENT"),
|
||||||
|
neededBy: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function convertReliefToRequisition(formData: FormData): Promise<ActionResult> {
|
||||||
|
const g = await guard("convert_relief_to_requisition");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
|
||||||
|
const parsed = convertSchema.safeParse({
|
||||||
|
reliefRequestId: formData.get("reliefRequestId"),
|
||||||
|
reason: (formData.get("reason") as string) || undefined,
|
||||||
|
neededBy: (formData.get("neededBy") as string) || undefined,
|
||||||
|
notes: (formData.get("notes") as string) || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
const d = parsed.data;
|
||||||
|
|
||||||
|
const relief = await db.reliefRequest.findUnique({ where: { id: d.reliefRequestId } });
|
||||||
|
if (!relief) return { error: "Relief request not found" };
|
||||||
|
if (relief.status !== "OPEN") return { error: "This relief request has already been handled" };
|
||||||
|
|
||||||
|
const requisition = await db.$transaction(async (tx) => {
|
||||||
|
const req = await createRequisitionTx(tx, {
|
||||||
|
rankId: relief.rankId,
|
||||||
|
vesselId: relief.vesselId,
|
||||||
|
siteId: relief.siteId,
|
||||||
|
reason: d.reason,
|
||||||
|
neededBy: d.neededBy ? new Date(d.neededBy) : null,
|
||||||
|
notes: d.notes || null,
|
||||||
|
raisedById: g.userId,
|
||||||
|
});
|
||||||
|
await tx.reliefRequest.update({
|
||||||
|
where: { id: relief.id },
|
||||||
|
data: { status: "CONVERTED", convertedRequisitionId: req.id },
|
||||||
|
});
|
||||||
|
await tx.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: "RELIEF_CONVERTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
requisitionId: req.id,
|
||||||
|
metadata: { reliefRequestId: relief.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return req;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the requester know their relief request became a requisition.
|
||||||
|
const requester = await db.user.findUnique({ where: { id: relief.requestedById } });
|
||||||
|
if (requester && requester.isActive && requester.id !== g.userId) {
|
||||||
|
const loc = requisitionLocationLabel(requisition);
|
||||||
|
await notifyCrew({
|
||||||
|
event: "RELIEF_CONVERTED",
|
||||||
|
recipients: [requester],
|
||||||
|
subject: `Relief cover converted — ${requisition.code}`,
|
||||||
|
body: `Your relief request for a ${requisition.rank.name} on ${loc} is now requisition ${requisition.code}.`,
|
||||||
|
link: `${LIST_PATH}/${requisition.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(LIST_PATH);
|
||||||
|
return { ok: true, id: requisition.id };
|
||||||
|
}
|
||||||
80
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
80
App/app/(portal)/crewing/requisitions/page.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { RequisitionsManager } from "./requisitions-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Requisitions" };
|
||||||
|
|
||||||
|
export default async function RequisitionsPage() {
|
||||||
|
// Dark unless the crewing module is switched on.
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
if (!hasPermission(session.user.role, "view_requisitions")) redirect("/dashboard");
|
||||||
|
const role = session.user.role;
|
||||||
|
|
||||||
|
const [requisitions, reliefRequests, ranks, vessels, sites] = await Promise.all([
|
||||||
|
db.requisition.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
raisedBy: { select: { name: true } },
|
||||||
|
_count: { select: { applications: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.reliefRequest.findMany({
|
||||||
|
where: { status: "OPEN" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
rank: { select: { name: true } },
|
||||||
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
|
requestedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.rank.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, code: true, name: true } }),
|
||||||
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Flatten to plain props — no Date/Decimal crosses the server→client boundary.
|
||||||
|
const rows = requisitions.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
code: r.code,
|
||||||
|
status: r.status,
|
||||||
|
reason: r.reason,
|
||||||
|
autoRaised: r.autoRaised,
|
||||||
|
rankName: r.rank.name,
|
||||||
|
location: r.vessel?.name ?? r.site?.name ?? "—",
|
||||||
|
raisedBy: r.raisedBy?.name ?? "System",
|
||||||
|
candidateCount: r._count.applications,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const relief = reliefRequests.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
rankName: r.rank.name,
|
||||||
|
location: r.vessel?.name ?? r.site?.name ?? "—",
|
||||||
|
note: r.note,
|
||||||
|
requestedBy: r.requestedBy.name,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RequisitionsManager
|
||||||
|
requisitions={rows}
|
||||||
|
reliefRequests={relief}
|
||||||
|
ranks={ranks}
|
||||||
|
vessels={vessels}
|
||||||
|
sites={sites}
|
||||||
|
canRaise={hasPermission(role, "raise_requisition")}
|
||||||
|
canConvert={hasPermission(role, "convert_relief_to_requisition")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal file
242
App/app/(portal)/crewing/requisitions/requisition-form.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { raiseRequisition, convertReliefToRequisition } from "./actions";
|
||||||
|
import { REASON_OPTIONS, REASON_LABEL } from "./requisition-ui";
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
type Opt = { id: string; name: string };
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
// A single "Vessel / site" picker — values are encoded "v:<id>" / "s:<id>" so
|
||||||
|
// one control covers both cost axes (spec §9 modal). Returns "" when unset.
|
||||||
|
function LocationSelect({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
vessels,
|
||||||
|
sites,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
vessels: Opt[];
|
||||||
|
sites: Opt[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select className={INPUT} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
<option value="">— Select vessel or site —</option>
|
||||||
|
{vessels.length > 0 && (
|
||||||
|
<optgroup label="Vessels">
|
||||||
|
{vessels.map((v) => (
|
||||||
|
<option key={v.id} value={`v:${v.id}`}>{v.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={`s:${s.id}`}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLocation(fd: FormData, location: string) {
|
||||||
|
if (location.startsWith("v:")) fd.set("vesselId", location.slice(2));
|
||||||
|
else if (location.startsWith("s:")) fd.set("siteId", location.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Raise requisition (MPO / Manager) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function RaiseRequisitionButton({
|
||||||
|
ranks,
|
||||||
|
vessels,
|
||||||
|
sites,
|
||||||
|
}: {
|
||||||
|
ranks: RankOpt[];
|
||||||
|
vessels: Opt[];
|
||||||
|
sites: Opt[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [rankId, setRankId] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [reason, setReason] = useState(REASON_OPTIONS[0]);
|
||||||
|
const [neededBy, setNeededBy] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setRankId(""); setLocation(""); setReason(REASON_OPTIONS[0]); setNeededBy(""); setNotes(""); setError("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("rankId", rankId);
|
||||||
|
applyLocation(fd, location);
|
||||||
|
fd.set("reason", reason);
|
||||||
|
if (neededBy) fd.set("neededBy", neededBy);
|
||||||
|
if (notes) fd.set("notes", notes);
|
||||||
|
|
||||||
|
const result = await raiseRequisition(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
+ Raise requisition
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Raise requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Rank *</label>
|
||||||
|
<select className={INPUT} value={rankId} onChange={(e) => setRankId(e.target.value)} required>
|
||||||
|
<option value="">— Select rank —</option>
|
||||||
|
{ranks.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.code} — {r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel / site *</label>
|
||||||
|
<LocationSelect value={location} onChange={setLocation} vessels={vessels} sites={sites} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||||
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||||
|
{REASON_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
||||||
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Raising…" : "Raise requisition"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Convert a relief request into a requisition (MPO / Manager) ─────────────────
|
||||||
|
|
||||||
|
export function ConvertReliefButton({
|
||||||
|
reliefRequestId,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
reliefRequestId: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [reason, setReason] = useState<typeof REASON_OPTIONS[number]>("REPLACEMENT");
|
||||||
|
const [neededBy, setNeededBy] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("reliefRequestId", reliefRequestId);
|
||||||
|
fd.set("reason", reason);
|
||||||
|
if (neededBy) fd.set("neededBy", neededBy);
|
||||||
|
if (notes) fd.set("notes", notes);
|
||||||
|
|
||||||
|
const result = await convertReliefToRequisition(fd);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
} else {
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<AdminDialog title="Convert to requisition" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Convert the relief request <span className="font-medium text-neutral-900">{label}</span> into an open
|
||||||
|
requisition so sourcing can begin.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Reason</label>
|
||||||
|
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
|
||||||
|
{REASON_OPTIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>{REASON_LABEL[r]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Needed by</label>
|
||||||
|
<input type="date" className={INPUT} value={neededBy} onChange={(e) => setNeededBy(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Notes</label>
|
||||||
|
<input className={INPUT} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional" />
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
|
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
|
{pending ? "Converting…" : "Convert"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal file
52
App/app/(portal)/crewing/requisitions/requisition-ui.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
||||||
|
import type { BadgeProps } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Variant = NonNullable<BadgeProps["variant"]>;
|
||||||
|
|
||||||
|
// Status → badge variant (Crewing-Implementation-Spec §8.2).
|
||||||
|
export const STATUS_VARIANT: Record<RequisitionStatus, Variant> = {
|
||||||
|
OPEN: "outline",
|
||||||
|
SHORTLISTING: "default",
|
||||||
|
PROPOSING: "default",
|
||||||
|
INTERVIEWING: "warning",
|
||||||
|
SELECTED: "default",
|
||||||
|
FILLED: "success",
|
||||||
|
CANCELLED: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<RequisitionStatus, string> = {
|
||||||
|
OPEN: "Open",
|
||||||
|
SHORTLISTING: "Shortlisting",
|
||||||
|
PROPOSING: "Proposing",
|
||||||
|
INTERVIEWING: "Interviewing",
|
||||||
|
SELECTED: "Selected",
|
||||||
|
FILLED: "Filled",
|
||||||
|
CANCELLED: "Cancelled",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASON_LABEL: Record<RequisitionReason, string> = {
|
||||||
|
NEW_VACANCY: "New vacancy",
|
||||||
|
REPLACEMENT: "Replacement",
|
||||||
|
LEAVE: "Leave cover",
|
||||||
|
SIGN_OFF: "Sign-off",
|
||||||
|
END_OF_CONTRACT: "End of contract",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REASON_OPTIONS: RequisitionReason[] = [
|
||||||
|
"NEW_VACANCY",
|
||||||
|
"REPLACEMENT",
|
||||||
|
"LEAVE",
|
||||||
|
"SIGN_OFF",
|
||||||
|
"END_OF_CONTRACT",
|
||||||
|
"OTHER",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Compact "age" label (e.g. "3d", "5h", "12m") relative to now.
|
||||||
|
export function ageLabel(iso: string): string {
|
||||||
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
|
if (mins < 60) return `${Math.max(mins, 0)}m`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h`;
|
||||||
|
return `${Math.floor(hrs / 24)}d`;
|
||||||
|
}
|
||||||
231
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
231
App/app/(portal)/crewing/requisitions/requisitions-manager.tsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { RequisitionStatus, RequisitionReason } from "@prisma/client";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RaiseRequisitionButton, ConvertReliefButton } from "./requisition-form";
|
||||||
|
import { STATUS_VARIANT, STATUS_LABEL, REASON_LABEL, ageLabel } from "./requisition-ui";
|
||||||
|
|
||||||
|
type RequisitionRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
status: RequisitionStatus;
|
||||||
|
reason: RequisitionReason;
|
||||||
|
autoRaised: boolean;
|
||||||
|
rankName: string;
|
||||||
|
location: string;
|
||||||
|
raisedBy: string;
|
||||||
|
candidateCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReliefRow = {
|
||||||
|
id: string;
|
||||||
|
rankName: string;
|
||||||
|
location: string;
|
||||||
|
note: string | null;
|
||||||
|
requestedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Opt = { id: string; name: string };
|
||||||
|
type RankOpt = { id: string; code: string; name: string };
|
||||||
|
|
||||||
|
const INPUT =
|
||||||
|
"rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
|
const STATUS_FILTERS: RequisitionStatus[] = [
|
||||||
|
"OPEN", "SHORTLISTING", "PROPOSING", "INTERVIEWING", "SELECTED", "FILLED", "CANCELLED",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RequisitionsManager({
|
||||||
|
requisitions,
|
||||||
|
reliefRequests,
|
||||||
|
ranks,
|
||||||
|
vessels,
|
||||||
|
sites,
|
||||||
|
canRaise,
|
||||||
|
canConvert,
|
||||||
|
}: {
|
||||||
|
requisitions: RequisitionRow[];
|
||||||
|
reliefRequests: ReliefRow[];
|
||||||
|
ranks: RankOpt[];
|
||||||
|
vessels: Opt[];
|
||||||
|
sites: Opt[];
|
||||||
|
canRaise: boolean;
|
||||||
|
canConvert: boolean;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
|
||||||
|
const [location, setLocation] = useState("ALL");
|
||||||
|
const [rank, setRank] = useState("ALL");
|
||||||
|
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
|
||||||
|
|
||||||
|
const locations = useMemo(
|
||||||
|
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
|
||||||
|
[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 q = search.trim().toLowerCase();
|
||||||
|
return requisitions.filter((r) => {
|
||||||
|
if (status !== "ALL" && r.status !== status) 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;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [requisitions, search, status, location, rank, reason]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Requisitions</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">
|
||||||
|
{requisitions.length} requisition{requisitions.length === 1 ? "" : "s"} · vacancies being sourced and filled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRaise && <RaiseRequisitionButton ranks={ranks} vessels={vessels} sites={sites} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
className={`${INPUT} flex-1 min-w-[200px]`}
|
||||||
|
placeholder="Search code, rank or location…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select className={INPUT} value={status} onChange={(e) => setStatus(e.target.value as typeof status)}>
|
||||||
|
<option value="ALL">All statuses</option>
|
||||||
|
{STATUS_FILTERS.map((s) => (
|
||||||
|
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select className={INPUT} value={location} onChange={(e) => setLocation(e.target.value)}>
|
||||||
|
<option value="ALL">All vessels / sites</option>
|
||||||
|
{locations.map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Requisitions table */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Requisition</th>
|
||||||
|
<th className="px-4 py-3">Vessel / site</th>
|
||||||
|
<th className="px-4 py-3">Rank</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">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400">
|
||||||
|
No requisitions match these filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/crewing/requisitions/${r.id}`} className="block">
|
||||||
|
<span className="font-mono text-xs text-neutral-900">{r.code}</span>
|
||||||
|
<span className="ml-2 text-xs text-neutral-400">{ageLabel(r.createdAt)} ago</span>
|
||||||
|
{r.autoRaised && (
|
||||||
|
<span className="ml-2 rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-[10px] font-medium">
|
||||||
|
Auto
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</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-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">
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relief requests from sites (spec §8.2 / R3 / R6) */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">Relief requests from sites</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5 mb-3">
|
||||||
|
Foreseen gaps flagged by site staff. Convert one into a requisition to start sourcing.
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Vessel / site</th>
|
||||||
|
<th className="px-4 py-3">Rank</th>
|
||||||
|
<th className="px-4 py-3">Note</th>
|
||||||
|
<th className="px-4 py-3">Requested by</th>
|
||||||
|
<th className="px-4 py-3 w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{reliefRequests.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
||||||
|
No open relief requests.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
reliefRequests.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<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-500">{r.note ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-500">{r.requestedBy}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
{canConvert && (
|
||||||
|
<ConvertReliefButton reliefRequestId={r.id} label={`${r.rankName} on ${r.location}`} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
App/app/(portal)/crewing/verification/actions.ts
Normal file
165
App/app/(portal)/crewing/verification/actions.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission, type Permission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
type ActionResult = { ok: true } | { error: string };
|
||||||
|
const PATH = "/crewing/verification";
|
||||||
|
|
||||||
|
async function guard(permission: Permission): Promise<{ error: string } | { userId: string; role: Role }> {
|
||||||
|
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, permission)) return { error: "Unauthorized" };
|
||||||
|
return { userId: session.user.id, role: session.user.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document verification (MPO / Manager) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export async function verifyDocument(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("verify_site_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||||
|
|
||||||
|
const doc = await db.seafarerDocument.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } });
|
||||||
|
if (!doc) return { error: "Document not found" };
|
||||||
|
if (doc.verificationStatus !== "PENDING") return { error: `This document is already ${doc.verificationStatus.toLowerCase()}` };
|
||||||
|
|
||||||
|
await db.seafarerDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: { verificationStatus: approve ? "VERIFIED" : "REJECTED", verifiedById: g.userId },
|
||||||
|
});
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
crewMemberId: doc.crewMemberId,
|
||||||
|
note: remarks?.trim() || null,
|
||||||
|
metadata: { record: "document" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(PATH);
|
||||||
|
revalidatePath(`/crewing/crew/${doc.crewMemberId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bank / EPF verification (Accounts) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function verifyBankEpf(crewMemberId: string, kind: "bank" | "epf", approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("verify_bank_epf");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||||
|
|
||||||
|
const status = approve ? "VERIFIED" : "REJECTED";
|
||||||
|
if (kind === "bank") {
|
||||||
|
const rec = await db.bankDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||||
|
if (!rec) return { error: "Bank details not found" };
|
||||||
|
if (rec.verificationStatus !== "PENDING") return { error: `Bank details already ${rec.verificationStatus.toLowerCase()}` };
|
||||||
|
await db.bankDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||||
|
} else {
|
||||||
|
const rec = await db.epfDetail.findUnique({ where: { crewMemberId }, select: { id: true, verificationStatus: true } });
|
||||||
|
if (!rec) return { error: "EPF details not found" };
|
||||||
|
if (rec.verificationStatus !== "PENDING") return { error: `EPF details already ${rec.verificationStatus.toLowerCase()}` };
|
||||||
|
await db.epfDetail.update({ where: { crewMemberId }, data: { verificationStatus: status, verifiedById: g.userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: {
|
||||||
|
actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED",
|
||||||
|
actorId: g.userId,
|
||||||
|
crewMemberId,
|
||||||
|
note: remarks?.trim() || null,
|
||||||
|
metadata: { record: kind },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(PATH);
|
||||||
|
revalidatePath(`/crewing/crew/${crewMemberId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PPE / next-of-kin verification (MPO) ───────────────────────────────────────
|
||||||
|
|
||||||
|
async function verifyRecord(
|
||||||
|
load: () => Promise<{ crewMemberId: string; verificationStatus: "PENDING" | "VERIFIED" | "REJECTED" } | null>,
|
||||||
|
set: (status: "VERIFIED" | "REJECTED", userId: string) => Promise<unknown>,
|
||||||
|
recordLabel: string,
|
||||||
|
approve: boolean,
|
||||||
|
remarks: string | undefined,
|
||||||
|
userId: string
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
if (!approve && !remarks?.trim()) return { error: "A reason is required to reject" };
|
||||||
|
const rec = await load();
|
||||||
|
if (!rec) return { error: "Record not found" };
|
||||||
|
if (rec.verificationStatus !== "PENDING") return { error: `This record is already ${rec.verificationStatus.toLowerCase()}` };
|
||||||
|
|
||||||
|
await set(approve ? "VERIFIED" : "REJECTED", userId);
|
||||||
|
await db.crewAction.create({
|
||||||
|
data: { actionType: approve ? "RECORD_VERIFIED" : "RECORD_REJECTED", actorId: userId, crewMemberId: rec.crewMemberId, note: remarks?.trim() || null, metadata: { record: recordLabel } },
|
||||||
|
});
|
||||||
|
revalidatePath(PATH);
|
||||||
|
revalidatePath(`/crewing/crew/${rec.crewMemberId}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPpe(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("verify_site_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
return verifyRecord(
|
||||||
|
() => db.ppeIssue.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
|
||||||
|
(status, userId) => db.ppeIssue.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
|
||||||
|
"ppe",
|
||||||
|
approve,
|
||||||
|
remarks,
|
||||||
|
g.userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyNextOfKin(id: string, approve: boolean, remarks?: string): Promise<ActionResult> {
|
||||||
|
const g = await guard("verify_site_records");
|
||||||
|
if ("error" in g) return g;
|
||||||
|
return verifyRecord(
|
||||||
|
() => db.nextOfKin.findUnique({ where: { id }, select: { crewMemberId: true, verificationStatus: true } }),
|
||||||
|
(status, userId) => db.nextOfKin.update({ where: { id }, data: { verificationStatus: status, verifiedById: userId } }),
|
||||||
|
"next_of_kin",
|
||||||
|
approve,
|
||||||
|
remarks,
|
||||||
|
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 };
|
||||||
|
}
|
||||||
82
App/app/(portal)/crewing/verification/page.tsx
Normal file
82
App/app/(portal)/crewing/verification/page.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
|
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { VerificationManager } from "./verification-manager";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Verification" };
|
||||||
|
|
||||||
|
export default async function VerificationPage() {
|
||||||
|
if (!CREWING_ENABLED) notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
const role = session.user.role;
|
||||||
|
const canDocs = hasPermission(role, "verify_site_records");
|
||||||
|
const canBankEpf = hasPermission(role, "verify_bank_epf");
|
||||||
|
const canAppraisals = hasPermission(role, "verify_appraisal");
|
||||||
|
if (!canDocs && !canBankEpf && !canAppraisals) redirect("/dashboard");
|
||||||
|
|
||||||
|
const [docs, bank, epf, appraisals, ppe, nok] = await Promise.all([
|
||||||
|
canDocs
|
||||||
|
? db.seafarerDocument.findMany({
|
||||||
|
where: { verificationStatus: "PENDING" },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
crewMember: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
assignments: { where: { status: { not: "SIGNED_OFF" } }, take: 1, include: { vessel: { select: { name: true } }, site: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
canBankEpf
|
||||||
|
? db.bankDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||||
|
: [],
|
||||||
|
canBankEpf
|
||||||
|
? db.epfDetail.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||||
|
: [],
|
||||||
|
canAppraisals
|
||||||
|
? db.appraisal.findMany({
|
||||||
|
where: { status: "SUBMITTED" },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: { assignment: { include: { crewMember: { select: { name: true } }, rank: { select: { name: true } } } } },
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
canDocs
|
||||||
|
? db.ppeIssue.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { issuedDate: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||||
|
: [],
|
||||||
|
canDocs
|
||||||
|
? db.nextOfKin.findMany({ where: { verificationStatus: "PENDING" }, orderBy: { createdAt: "asc" }, include: { crewMember: { select: { name: true } } } })
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerificationManager
|
||||||
|
docs={docs.map((d) => {
|
||||||
|
const a = d.crewMember.assignments[0];
|
||||||
|
return {
|
||||||
|
id: d.id,
|
||||||
|
crewName: d.crewMember.name,
|
||||||
|
location: a?.vessel?.name ?? a?.site?.name ?? "—",
|
||||||
|
docType: d.docType,
|
||||||
|
number: d.number,
|
||||||
|
expiryDate: d.expiryDate?.toISOString() ?? null,
|
||||||
|
submitted: d.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
bank={bank.map((b) => ({ crewMemberId: b.crewMemberId, crewName: b.crewMember.name, accountName: b.accountName, accountNumber: b.accountNumber, ifsc: b.ifsc, bankName: b.bankName }))}
|
||||||
|
epf={epf.map((e) => ({ crewMemberId: e.crewMemberId, crewName: e.crewMember.name, uan: e.uan, aadhaarLast4: e.aadhaarLast4, pfNumber: e.pfNumber }))}
|
||||||
|
appraisals={appraisals.map((a) => ({ id: a.id, crewName: a.assignment.crewMember.name, rank: a.assignment.rank.name, period: a.period, comments: a.comments }))}
|
||||||
|
ppe={ppe.map((p) => ({ id: p.id, crewName: p.crewMember.name, item: p.item, size: p.size }))}
|
||||||
|
nok={nok.map((n) => ({ id: n.id, crewName: n.crewMember.name, name: n.name, relationship: n.relationship }))}
|
||||||
|
canDocs={canDocs}
|
||||||
|
canBankEpf={canBankEpf}
|
||||||
|
canAppraisals={canAppraisals}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
283
App/app/(portal)/crewing/verification/verification-manager.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { SeafarerDocType } from "@prisma/client";
|
||||||
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
|
import { verifyDocument, verifyBankEpf, verifyPpe, verifyNextOfKin, recordEpfoCheck } from "./actions";
|
||||||
|
import { verifyAppraisal } from "../appraisals/actions";
|
||||||
|
import type { PpeItem } from "@prisma/client";
|
||||||
|
|
||||||
|
const label = (s: string) => s.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (m) => m.toUpperCase());
|
||||||
|
const fmt = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString() : "—");
|
||||||
|
const isExpired = (iso: string | null) => Boolean(iso && new Date(iso) < new Date());
|
||||||
|
|
||||||
|
type Doc = { id: string; crewName: string; location: string; docType: SeafarerDocType; number: string | null; expiryDate: string | null; submitted: string };
|
||||||
|
type Bank = { crewMemberId: string; crewName: string; accountName: string | null; accountNumber: string | null; ifsc: string | null; bankName: string | null };
|
||||||
|
type Epf = { crewMemberId: string; crewName: string; uan: string | null; aadhaarLast4: string | null; pfNumber: string | null };
|
||||||
|
type Appr = { id: string; crewName: string; rank: string; period: string; comments: string | null };
|
||||||
|
type Ppe = { id: string; crewName: string; item: PpeItem; size: string | null };
|
||||||
|
type Nok = { id: string; crewName: string; name: string; relationship: string | null };
|
||||||
|
|
||||||
|
function Actions({ onVerify, onReject }: { onVerify: () => Promise<{ ok: true } | { error: string }>; onReject: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await onVerify();
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else router.refresh();
|
||||||
|
}
|
||||||
|
async function reject(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true); setError("");
|
||||||
|
const res = await onReject(reason);
|
||||||
|
setPending(false);
|
||||||
|
if ("error" in res) setError(res.error); else { setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button onClick={verify} disabled={pending} className="rounded-md bg-primary-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Verify</button>
|
||||||
|
<button onClick={() => setOpen(true)} disabled={pending} className="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-medium text-neutral-700 hover:bg-neutral-50">Reject</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-danger-700 mt-1">{error}</p>}
|
||||||
|
<AdminDialog title="Reject record" open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={reject} className="space-y-4 text-left">
|
||||||
|
<textarea className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm" rows={3} value={reason} onChange={(e) => setReason(e.target.value)} required placeholder="Reason for rejection" />
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button type="button" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">Reject</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">{title}</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-0.5 mb-3">{sub}</p>
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
{empty ? <p className="px-4 py-10 text-center text-sm text-neutral-400">Nothing awaiting verification.</p> : (
|
||||||
|
<table className="w-full text-sm">{children}</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerificationManager({ docs, bank, epf, appraisals, ppe, nok, canDocs, canBankEpf, canAppraisals }: { docs: Doc[]; bank: Bank[]; epf: Epf[]; appraisals: Appr[]; ppe: Ppe[]; nok: Nok[]; canDocs: boolean; canBankEpf: boolean; canAppraisals: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-semibold text-neutral-900">Verification</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">Site-entered records awaiting office verification.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canDocs && (
|
||||||
|
<Card title="Documents" sub="Verify or reject crew documents (MPO)." empty={docs.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Vessel / site</th><th className="px-4 py-3">Document</th><th className="px-4 py-3">Expiry</th><th className="px-4 py-3">Submitted</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{docs.map((d) => (
|
||||||
|
<tr key={d.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{d.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{d.location}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{label(d.docType)}{d.number ? ` · ${d.number}` : ""}</td>
|
||||||
|
<td className="px-4 py-3">{d.expiryDate ? <span className={isExpired(d.expiryDate) ? "text-danger-700 font-medium" : "text-neutral-600"}>{fmt(d.expiryDate)}{isExpired(d.expiryDate) ? " · expired" : ""}</span> : "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-500">{fmt(d.submitted)}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyDocument(d.id, true)} onReject={(r) => verifyDocument(d.id, false, r)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDocs && (
|
||||||
|
<Card title="PPE" sub="Verify or reject issued PPE (MPO)." empty={ppe.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Item</th><th className="px-4 py-3">Size</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ppe.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{label(r.item)}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{r.size ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyPpe(r.id, true)} onReject={(x) => verifyPpe(r.id, false, x)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDocs && (
|
||||||
|
<Card title="Next of kin" sub="Verify or reject next-of-kin records (MPO)." empty={nok.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Contact</th><th className="px-4 py-3">Relationship</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{nok.map((r) => (
|
||||||
|
<tr key={r.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{r.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{r.name}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{r.relationship ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyNextOfKin(r.id, true)} onReject={(x) => verifyNextOfKin(r.id, false, x)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canBankEpf && (
|
||||||
|
<Card title="Bank details" sub="Verify or reject crew bank details (Accounts)." empty={bank.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Account</th><th className="px-4 py-3">IFSC</th><th className="px-4 py-3">Bank</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{bank.map((b) => (
|
||||||
|
<tr key={b.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{b.crewName}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-neutral-700">{b.accountNumber ?? "—"}{b.accountName ? ` (${b.accountName})` : ""}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{b.ifsc ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{b.bankName ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyBankEpf(b.crewMemberId, "bank", true)} onReject={(r) => verifyBankEpf(b.crewMemberId, "bank", false, r)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canBankEpf && (
|
||||||
|
<Card title="EPF details" sub="Verify or reject crew EPF / identity details (Accounts)." empty={epf.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">UAN</th><th className="px-4 py-3">Aadhaar</th><th className="px-4 py-3">PF no.</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{epf.map((e) => (
|
||||||
|
<tr key={e.crewMemberId} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{e.crewName}</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 text-neutral-600">{e.pfNumber ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAppraisals && (
|
||||||
|
<Card title="Appraisals" sub="Verify or reject submitted appraisals (MPO)." empty={appraisals.length === 0}>
|
||||||
|
<thead><tr className="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wide">
|
||||||
|
<th className="px-4 py-3">Crew</th><th className="px-4 py-3">Rank</th><th className="px-4 py-3">Period</th><th className="px-4 py-3">Comments</th><th className="px-4 py-3 w-32"></th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{appraisals.map((a) => (
|
||||||
|
<tr key={a.id} className="border-b border-neutral-100 last:border-0 hover:bg-neutral-50">
|
||||||
|
<td className="px-4 py-3 font-medium text-neutral-900">{a.crewName}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-600">{a.rank}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-700">{a.period}</td>
|
||||||
|
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{a.comments ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3"><Actions onVerify={() => verifyAppraisal(a.id, true)} onReject={(r) => verifyAppraisal(a.id, false, r)} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
||||||
import { StatCard } from "@/components/dashboard/stat-card";
|
import { StatCard } from "@/components/dashboard/stat-card";
|
||||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||||
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -182,7 +182,7 @@ async function ManagerDashboard() {
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent approved POs */}
|
{/* Recent approved POs */}
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,23 @@ const STATUSES = [
|
||||||
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
||||||
{ value: "CLOSED", label: "Closed" },
|
{ value: "CLOSED", label: "Closed" },
|
||||||
{ value: "REJECTED", label: "Rejected" },
|
{ value: "REJECTED", label: "Rejected" },
|
||||||
|
{ value: "CANCELLED", label: "Cancelled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
vessels: { id: string; name: string }[];
|
||||||
|
perPageOptions: number[];
|
||||||
|
defaultPerPage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels }: Props) {
|
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) {
|
||||||
const router = useRouter();
|
const 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") ?? "");
|
||||||
|
|
@ -49,7 +56,8 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply() {
|
// Changing any filter resets to page 1; perPage is preserved across applies.
|
||||||
|
function buildParams(nextPerPage: number) {
|
||||||
const params = new URLSearchParams();
|
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);
|
||||||
|
|
@ -57,12 +65,24 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
if (approvedTo) params.set("approvedTo", approvedTo);
|
if (approvedTo) params.set("approvedTo", approvedTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (vesselId) params.set("vesselId", vesselId);
|
||||||
for (const s of statuses) params.append("status", s);
|
for (const s of statuses) params.append("status", s);
|
||||||
router.push(`/history?${params.toString()}`);
|
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
router.push(`/history?${buildParams(perPage).toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePerPage(next: number) {
|
||||||
|
router.push(`/history?${buildParams(next).toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
|
||||||
router.push("/history");
|
const params = new URLSearchParams();
|
||||||
|
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
|
||||||
|
const qs = params.toString();
|
||||||
|
router.push(qs ? `/history?${qs}` : "/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
|
||||||
|
|
@ -138,6 +158,13 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
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,17 +1,21 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission, submitterCanViewAll } 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 { 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";
|
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;
|
||||||
|
|
@ -20,6 +24,8 @@ interface Props {
|
||||||
approvedTo?: string;
|
approvedTo?: string;
|
||||||
vesselId?: string;
|
vesselId?: string;
|
||||||
status?: string | string[];
|
status?: string | string[];
|
||||||
|
page?: string;
|
||||||
|
perPage?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,9 +33,17 @@ 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");
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
// Report-export holders see History; submitters get read+export access when the
|
||||||
|
// submitter-view-all feature flag is on.
|
||||||
|
if (
|
||||||
|
!hasPermission(session.user.role, "export_reports") &&
|
||||||
|
!submitterCanViewAll(session.user.role)
|
||||||
|
) {
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } =
|
||||||
|
await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -56,16 +70,45 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
const statuses = (Array.isArray(status) ? status : status ? [status] : []).filter(Boolean);
|
||||||
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
if (statuses.length > 0) where.status = { in: statuses as POStatus[] };
|
||||||
|
|
||||||
|
const total = await db.purchaseOrder.count({ where });
|
||||||
|
const { perPage, page, totalPages, skip, take } = resolvePagination({
|
||||||
|
perPageParam,
|
||||||
|
pageParam,
|
||||||
|
total,
|
||||||
|
options: PER_PAGE_OPTIONS,
|
||||||
|
defaultPerPage: DEFAULT_PER_PAGE,
|
||||||
|
});
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels] = 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" },
|
||||||
take: 200,
|
skip,
|
||||||
|
take,
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Shared filter params for the pagination footer links (everything except `page`).
|
||||||
|
const pageParams = new URLSearchParams();
|
||||||
|
if (dateFrom) pageParams.set("dateFrom", dateFrom);
|
||||||
|
if (dateTo) pageParams.set("dateTo", dateTo);
|
||||||
|
if (approvedFrom) pageParams.set("approvedFrom", approvedFrom);
|
||||||
|
if (approvedTo) pageParams.set("approvedTo", approvedTo);
|
||||||
|
if (vesselId) pageParams.set("vesselId", vesselId);
|
||||||
|
for (const s of statuses) pageParams.append("status", s);
|
||||||
|
pageParams.set("perPage", String(perPage));
|
||||||
|
|
||||||
|
const pageHref = (p: number) => {
|
||||||
|
const params = new URLSearchParams(pageParams);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/history?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstRow = total === 0 ? 0 : skip + 1;
|
||||||
|
const lastRow = skip + orders.length;
|
||||||
|
|
||||||
const exportParams = new URLSearchParams({ format: "csv" });
|
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);
|
||||||
|
|
@ -97,7 +140,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<HistoryFilters vessels={vessels} />
|
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} />
|
||||||
</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">
|
||||||
|
|
@ -115,7 +158,10 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-100">
|
<tbody className="divide-y divide-neutral-100">
|
||||||
{orders.map((po) => (
|
{orders.map((po) => (
|
||||||
<tr key={po.id} className="hover:bg-neutral-50">
|
<tr
|
||||||
|
key={po.id}
|
||||||
|
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
|
||||||
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
||||||
{po.poNumber}
|
{po.poNumber}
|
||||||
|
|
@ -139,8 +185,41 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{orders.length === 200 && (
|
{total > 0 && (
|
||||||
<p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results — refine filters to narrow results.</p>
|
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600">
|
||||||
|
<span>
|
||||||
|
Showing {firstRow}–{lastRow} of {total}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link
|
||||||
|
href={pageHref(page - 1)}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||||
|
Previous
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-500">
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<Link
|
||||||
|
href={pageHref(page + 1)}
|
||||||
|
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
|
||||||
|
Next
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="/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/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
|
||||||
<Link href="/inventory/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
|
<Link href="/catalogue/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="/inventory/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
<Link href="/catalogue/items" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
|
||||||
+ Add more items
|
+ 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">
|
||||||
|
|
|
||||||
|
|
@ -4,107 +4,12 @@ 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,12 +98,25 @@ 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,6 +10,9 @@ 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)
|
||||||
|
|
@ -19,15 +22,33 @@ 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({ poId, poStatus, totalAmount = 0, paidAmount = 0 }: Props) {
|
export function PaymentActions({
|
||||||
|
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>("");
|
const [amount, setAmount] = useState<string>(advancePrefill);
|
||||||
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() {
|
||||||
|
|
@ -120,6 +141,11 @@ export function PaymentActions({ poId, poStatus, totalAmount = 0, paidAmount = 0
|
||||||
className="w-full sm:w-36 rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
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 && (
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
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, canCancel } from "@/lib/po-state-machine";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { notify } from "@/lib/notifier";
|
import { notify } from "@/lib/notifier";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
|
@ -113,3 +114,118 @@ export async function discardDraftPo(
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Cancel a PO ───────────────────────────────────────────────────────────────
|
||||||
|
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
|
||||||
|
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
|
||||||
|
// explicit whitelists, none of which include CANCELLED).
|
||||||
|
|
||||||
|
export async function cancelPo({
|
||||||
|
poId,
|
||||||
|
reason,
|
||||||
|
}: {
|
||||||
|
poId: string;
|
||||||
|
reason: string;
|
||||||
|
}): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||||
|
return { error: "You do not have permission to cancel purchase orders." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = (reason ?? "").trim();
|
||||||
|
if (!trimmed) return { error: "A cancellation reason is required." };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
include: { submitter: true },
|
||||||
|
});
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (!canCancel(po.status, session.user.role)) {
|
||||||
|
return {
|
||||||
|
error: po.status === "CANCELLED"
|
||||||
|
? "This purchase order is already cancelled."
|
||||||
|
: "You cannot cancel this purchase order.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
status: "CANCELLED",
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
cancellationReason: trimmed,
|
||||||
|
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify the submitter and Accounts (they track spend).
|
||||||
|
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||||
|
const recipients = [po.submitter, ...accounts].filter(
|
||||||
|
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
|
||||||
|
);
|
||||||
|
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
revalidatePath("/history");
|
||||||
|
revalidatePath("/my-orders");
|
||||||
|
revalidatePath("/payments");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
|
||||||
|
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
|
||||||
|
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
|
||||||
|
// surfaced on the replacement via the schema self-relation.
|
||||||
|
|
||||||
|
export async function supersedePo({
|
||||||
|
poId,
|
||||||
|
replacementPoNumber,
|
||||||
|
}: {
|
||||||
|
poId: string;
|
||||||
|
replacementPoNumber: string;
|
||||||
|
}): Promise<{ ok: true } | { error: string }> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) return { error: "Unauthorized" };
|
||||||
|
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||||
|
return { error: "You do not have permission to link a superseding purchase order." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (replacementPoNumber ?? "").trim();
|
||||||
|
if (!num) return { error: "Enter the PO number that supersedes this one." };
|
||||||
|
|
||||||
|
const po = await db.purchaseOrder.findUnique({
|
||||||
|
where: { id: poId },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
});
|
||||||
|
if (!po) return { error: "PO not found" };
|
||||||
|
if (po.status !== "CANCELLED") {
|
||||||
|
return { error: "Only a cancelled purchase order can be superseded." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const replacement = await db.purchaseOrder.findUnique({
|
||||||
|
where: { poNumber: num },
|
||||||
|
select: { id: true, poNumber: true },
|
||||||
|
});
|
||||||
|
if (!replacement) return { error: `No purchase order found with number "${num}".` };
|
||||||
|
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
|
||||||
|
|
||||||
|
await db.purchaseOrder.update({
|
||||||
|
where: { id: poId },
|
||||||
|
data: {
|
||||||
|
supersededById: replacement.id,
|
||||||
|
actions: {
|
||||||
|
create: {
|
||||||
|
actionType: "SUPERSEDED",
|
||||||
|
actorId: session.user.id,
|
||||||
|
note: `Superseded by ${replacement.poNumber}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/po/${poId}`);
|
||||||
|
revalidatePath(`/po/${replacement.id}`);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -71,6 +72,11 @@ 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
|
||||||
|
|
@ -156,6 +162,7 @@ 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,8 +7,12 @@ 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 { 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";
|
||||||
|
|
@ -40,10 +44,13 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
|
termsCatalogue: CatalogueCategory[];
|
||||||
|
initialTerms: PoTerm[];
|
||||||
managerNoteAuthor?: string | null;
|
managerNoteAuthor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, managerNoteAuthor }: Props) {
|
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, initialTerms, managerNoteAuthor }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
po.lineItems.map((li) => ({
|
||||||
|
|
@ -62,6 +69,9 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
const 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";
|
||||||
|
|
@ -72,6 +82,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
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 ?? "");
|
||||||
|
|
@ -88,6 +99,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,7 +120,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
const extPo = po;
|
const extPo = po;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()}>
|
<form id="edit-po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||||
{canResubmit && (
|
{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">
|
||||||
|
|
@ -172,7 +184,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -229,7 +241,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<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>
|
||||||
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
|
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT_CLS} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -238,7 +250,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -248,54 +260,14 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
{/* 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>
|
||||||
<select name="vendorId" defaultValue={po.vendorId ?? ""} className={INPUT_CLS}>
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={po.vendorId ?? ""} />
|
||||||
<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-4">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||||
<div className="space-y-3">
|
<p className="text-xs text-neutral-500 mb-4">Add a category and pick (or type) a clause.</p>
|
||||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
<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 && (
|
||||||
|
|
@ -324,6 +296,12 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UnsavedChangesGuard
|
||||||
|
enabled={dirty && !submitting}
|
||||||
|
onSaveDraft={() => handleSubmit("save")}
|
||||||
|
saving={submitting === "save"}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ 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";
|
||||||
|
|
||||||
|
|
@ -29,7 +32,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, noteAction] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.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: {} } },
|
||||||
|
|
@ -38,6 +41,7 @@ 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 } } } }),
|
||||||
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 } },
|
||||||
|
|
@ -48,6 +52,10 @@ 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 termsCatalogue = await getTermsCatalogue();
|
||||||
|
const savedTerms = parsePoTerms(po.terms);
|
||||||
|
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -73,6 +81,9 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
|
termsCatalogue={termsCatalogue}
|
||||||
|
initialTerms={initialTerms}
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
83
App/app/(portal)/po/[id]/email-actions.ts
Normal file
83
App/app/(portal)/po/[id]/email-actions.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { buildStorageKey, uploadBuffer, generateDownloadUrl } 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.
|
||||||
|
let link: string;
|
||||||
|
try {
|
||||||
|
const pdf = await renderPoPdf(poId);
|
||||||
|
const slug = po.poNumber.replace(/\//g, "-");
|
||||||
|
const key = buildStorageKey("po-pdf", poId, `${slug}.pdf`);
|
||||||
|
await uploadBuffer(key, pdf, "application/pdf");
|
||||||
|
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,6 +2,7 @@ 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";
|
||||||
|
|
||||||
|
|
@ -27,21 +28,23 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
||||||
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" } },
|
||||||
receipt: true,
|
receipt: true,
|
||||||
|
supersededBy: { select: { id: true, poNumber: true } },
|
||||||
|
supersedes: { select: { id: true, poNumber: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
|
|
||||||
// Submitters can only view their own POs (unless they have view_all_pos)
|
// Submitters can only view their own POs — unless they hold view_all_pos, or the
|
||||||
const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(
|
// submitter-view-all feature flag grants them read access to every PO.
|
||||||
session.user.role
|
if (!canViewAllPos(session.user.role) && po.submitterId !== session.user.id) {
|
||||||
);
|
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" &&
|
||||||
|
|
@ -54,9 +57,11 @@ 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} />
|
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} vendorEmail={vendorEmail} />
|
||||||
{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("/inventory/vendors");
|
revalidatePath("/catalogue/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("/inventory/vendors");
|
revalidatePath("/catalogue/vendors");
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath("/history");
|
revalidatePath("/history");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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";
|
||||||
|
|
@ -77,6 +78,11 @@ 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),
|
||||||
|
|
@ -108,6 +114,7 @@ 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,9 +7,13 @@ 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 { 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 }[] };
|
||||||
|
|
@ -25,23 +29,28 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: 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, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, termsCatalogue, defaultTerms, 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);
|
||||||
|
|
@ -49,6 +58,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
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 ?? "");
|
||||||
|
|
@ -75,11 +85,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
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()}>
|
<form id="po-form" className="space-y-6" onSubmit={(e) => e.preventDefault()} onInput={markDirty} onChange={markDirty}>
|
||||||
{/* 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>
|
||||||
|
|
@ -136,7 +147,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
name="accountId"
|
name="accountId"
|
||||||
value={defaultAccountId}
|
value={defaultAccountId}
|
||||||
onChange={setDefaultAccountId}
|
onChange={(v) => { setDefaultAccountId(v); markDirty(); }}
|
||||||
groups={accounts}
|
groups={accounts}
|
||||||
placeholder="Search accounting code…"
|
placeholder="Search accounting code…"
|
||||||
required
|
required
|
||||||
|
|
@ -194,12 +205,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
<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>
|
||||||
<textarea
|
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
||||||
name="placeOfDelivery"
|
{deliveryOptions.length === 0 && (
|
||||||
rows={2}
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
className={INPUT_CLS}
|
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
||||||
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
</p>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -208,7 +219,7 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||||
<LineItemsEditor
|
<LineItemsEditor
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
onChange={setLineItems}
|
onChange={(v) => { setLineItems(v); markDirty(); }}
|
||||||
multiAccount={multiAccount}
|
multiAccount={multiAccount}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
defaultAccountId={defaultAccountId || undefined}
|
defaultAccountId={defaultAccountId || undefined}
|
||||||
|
|
@ -222,57 +233,21 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<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>
|
||||||
<select
|
<VendorSelect name="vendorId" vendors={vendors} initialValue={initialVendorId ?? ""} />
|
||||||
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-4">Terms & Conditions</h2>
|
<h2 className="text-base font-semibold text-neutral-900 mb-1">Terms & Conditions</h2>
|
||||||
<div className="space-y-3">
|
<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="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2.5 text-sm text-neutral-500 select-none">
|
<PoTermsEditor value={terms} onChange={(v) => { setTerms(v); markDirty(); }} catalogue={termsCatalogue} />
|
||||||
<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={setFiles} disabled={!!submitting} />
|
<FileUploader files={files} onChange={(v) => { setFiles(v); markDirty(); }} disabled={!!submitting} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -297,6 +272,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
||||||
{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,6 +4,8 @@ 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";
|
||||||
|
|
@ -46,7 +48,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
|
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
db.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: {} } },
|
||||||
|
|
@ -55,9 +57,12 @@ 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 } } } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
const [termsCatalogue, defaultTerms] = await Promise.all([getTermsCatalogue(), getDefaultPoTerms()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
|
|
@ -72,6 +77,9 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
|
termsCatalogue={termsCatalogue}
|
||||||
|
defaultTerms={defaultTerms}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
initialVesselId={initialVesselId}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const ROLE_LABELS: Record<string, string> = {
|
||||||
SUPERUSER: "SuperUser",
|
SUPERUSER: "SuperUser",
|
||||||
AUDITOR: "Auditor",
|
AUDITOR: "Auditor",
|
||||||
ADMIN: "Admin",
|
ADMIN: "Admin",
|
||||||
|
SITE_STAFF: "Site Staff",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue