Compare commits

..

4 commits

Author SHA1 Message Date
55d69a291a Merge pull request 'feat(crewing): Phase 3b — recruitment pipeline (flagged)' (#67) from feat/crewing-pipeline into feat/crewing-candidates
Reviewed-on: #67
2026-06-22 18:49:29 +00:00
79eba5505c Merge branch 'feat/crewing-candidates' into feat/crewing-pipeline
All checks were successful
PR checks / checks (pull_request) Successful in 39s
PR checks / integration (pull_request) Successful in 28s
2026-06-22 18:49:11 +00:00
136cb798f5 Merge branch 'feat/crewing-requisitions' into feat/crewing-candidates
All checks were successful
PR checks / checks (pull_request) Successful in 37s
PR checks / integration (pull_request) Successful in 27s
2026-06-22 18:48:12 +00:00
feac86e3a3 Merge branch 'feat/crewing-foundations' into feat/crewing-requisitions
All checks were successful
PR checks / checks (pull_request) Successful in 36s
PR checks / integration (pull_request) Successful in 29s
2026-06-22 18:46:58 +00:00
214 changed files with 540 additions and 15324 deletions

View file

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

4
.gitignore vendored
View file

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

View file

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

1
App/.gitignore vendored
View file

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

View file

@ -98,29 +98,6 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked). `Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
### Delivery Locations (issue #19)
`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete).
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)``"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
### 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 (AprMar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import. Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (AprMar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
@ -129,45 +106,18 @@ Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25
When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation). When Accounts records a payment, a **compulsory payment date** is captured (`PurchaseOrder.paymentDate`) — the input defaults to today and rejects future dates (validated in `processPaymentSchema` and `markPaid`). There is also an editable **`poDate`** field; the exported PO "Date" shows `poDate ?? approvedAt ?? createdAt` (i.e. the approval date once approved, not creation).
**Advance payment (issue #92):** the approving Manager sets how much of the PO is paid first via a 0100% slider on the approval card (`approval-actions.tsx`, default 100%). The slider is convenience only — the resolved **absolute amount** is stored on `PurchaseOrder.suggestedAdvancePayment` (`Decimal(12,2)`, nullable; null = no explicit advance ⇒ full payment). `approvePo()` clamps it to `[0, totalAmount]` and records it on the `APPROVED` audit row; it is **set once at approval and never edited after**. Accounts sees it on the payment queue + PO detail, and it **prefills the first payment's amount** (`payment-actions.tsx`, only when nothing is paid yet and the advance is a true partial); the balance then runs through the normal `PARTIALLY_PAID` loop. It does **not** appear on the exported PO/invoice.
### Vendors ### Vendors
`Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code). `Vendor` carries `isVerified`, `gstin`, `pincode` + `latitude`/`longitude` (geocoded for vendor-distance sorting from a Site), and a `VendorContact[]` list. **Submitters can create vendors** (permission `create_vendor`) but they are created **unverified**; a vendor becomes verified when a PO is closed/paid with it, on import, or when a Manager/Accounts/Admin runs `verifyVendor`. Only `manage_vendors` holders may assign a `vendorId` (the formal verified code).
### Email PO to vendor (issue #14)
An **Email to vendor** button on the PO detail (`po-detail.tsx`, available once the PO is approved — `MGR_APPROVED` through `CLOSED`, and again after payment — when the vendor has a primary-contact email) opens an **Outlook draft** addressed to that contact with a **time-limited PDF download link** in the body. The user reviews and sends it.
The pipeline (no mailto attachment — `mailto:` can't carry files): `prepareVendorEmail(poId)` (`po/[id]/email-actions.ts`) → `renderPoPdf` (`lib/pdf-service.ts`) → **PdfService** (a standalone Express + Playwright microservice, the GstService/EpfoService pattern) renders the existing `/api/po/[id]/export?format=pdf&pdf=1` page to a real PDF via headless Chromium → `uploadBuffer` to R2 (`po-pdf/…`) → `generateDownloadUrl` (presigned, **7-day** TTL) → returns a `mailto:` with the link. The export route accepts a server-only `svc` token (`PDF_SERVICE_TOKEN`) so PdfService can fetch the page without a user session, and `pdf=1` drops the on-screen print button + `window.print()` auto-trigger. Gated by `PDF_SERVICE_URL`/`PDF_SERVICE_TOKEN` — if unset the action returns a friendly "not configured" error. **No new DB model/migration.**
**Caching:** the PDF is stored at a **deterministic per-PO key** (`buildPoPdfKey``po-pdf/<poId>/<slug>.pdf`, no timestamp). On each send, `statObject(key)` checks for an existing copy: if one exists and its `lastModified >= po.updatedAt`, it's **reused** (no re-render, no re-upload) and only a **fresh presigned URL is minted** (refreshing the 7-day timer). It re-renders only when there's no copy yet or the PO changed since the cached one.
### Inventory (feature-flagged) ### Inventory (feature-flagged)
Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless. Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at PO approval** — not on close — for the ordered quantities, when the PO has a `siteId`. The whole inventory surface (site stock, consumption) is gated by `NEXT_PUBLIC_INVENTORY_ENABLED` (see `lib/feature-flags.ts`); the vendor/product catalogue used for PO creation stays available regardless.
### Product catalogue sync (`lib/product-catalog.ts`)
`syncProductCatalog(poId, lineItems, vendorId, actorId)` registers a PO's line items as reusable **`Product`s** (the `/catalogue/items` catalogue): a line item with no `productId` is matched to an existing product by name (case-insensitive) or a new product is created, then the line item is linked back; `lastPrice`/`lastVendorId` and the per-vendor `ProductVendorPrice` are upserted. It runs **at approval** (`approvePo`) so an approved PO's items are immediately reusable in further POs, **and again at full payment** (`markPaid`) to refresh prices on the final figures. Idempotent — re-running matches the same product. (Import takes its own auto-create path.)
### Import → Closed ### Import → Closed
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices. `/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
### Reports — Purchasing spend analytics (issue #18 wiki "Reports Mockup")
Spend analytics under a **Reports** sidebar section (with a **"Purchasing"** subheading, so other domains can add report groups later). Gated by **`view_analytics`** (Manager / SuperUser / Auditor / Admin); CSV export by the same. Two report families, each an **index → drill/detail** pair:
- **Cost Centres** (`/reports/cost-centres`) — spend compared across **vessels** (the PO cost centre). Row → **`/reports/cost-centres/[id]`** detail: trend + a **Top accounting codes** breakdown re-pivotable by tier (Heading / Sub-heading / Leaf) and Top-N.
- **Accounting Codes** (`/reports/accounting-codes`) — drills the `Account` tree (headings → sub-headings → leaves) via a `?parent=` query; leaf rows open **`/reports/accounting-codes/[id]`**: trend + breakdown **by cost centre** (or, for a non-leaf, by sub-account).
**Spend definition** (`lib/reports.ts`, the pure/unit-tested core): a PO counts once it reaches `POST_APPROVAL_STATUSES`, dated by `approvedAt`, valued at the full `totalAmount` — the same basis as the dashboard tiles. FY is the Indian **AprMar** year. `getReportDataset()` does one query pass; everything else is pure functions over it. **`allocatePoSpend()`** splits each PO across the accounting codes its **line items** carry (line `accountId`, falling back to the PO-level account), **proportionally** so the per-PO rows always sum back to `totalAmount` — so multi-account POs are attributed correctly in the accounting-code report. `poCount` is **distinct POs** (a multi-account PO yields several rows). Account spend rolls leaf descendants up via `buildAccountIndex().leavesUnder`.
**Filters** live in the **URL query** so the server component re-renders — no client fetching: `gran` (**weekly** / monthly / yearly), `fy`, `month` (weekly), `scope` (Top/Bottom-N), `parent` (accounting drill), `tier` / `break` / `topn` (detail breakdowns), and `sel` + `cmp` (the **custom "Add to graph"** multi-select — tick rows via the `<SelectCheckbox>` links, then `cmp=1` compares just the selected set). Weekly focuses one FY month and buckets by week-of-month (W1W5). The shared `<ReportsToolbar>` (client) writes the params; charts are **recharts** (`components/reports/charts.tsx`) — the comparison chart plots **one colour-coded series per item** (cost centre / accounting code) in every granularity, including the yearly grouped-bars view (x-axis = FYs, a coloured bar per item — not one colour per year); KPIs/tables/breadcrumbs are server-rendered. Export → `/api/reports/spend?dim=…` (CSV mirroring the on-screen view, incl. the custom selection).
Sites are **not** cost centres (only vessels are).
### Crewing (feature-flagged) ### Crewing (feature-flagged)
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far: A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12). **Foundations** and **Requisitions** ship so far:
@ -201,62 +151,6 @@ A crew-management module built incrementally per the **wiki `Crewing-Implementat
- **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. - **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. - **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.78.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.98.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`.
@ -280,12 +174,7 @@ RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME
FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003) GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
EPFO_SERVICE_URL # EpfoService microservice for UAN lookup (defaults to localhost:3004)
PDF_SERVICE_URL # PdfService microservice for PO→PDF render (defaults to localhost:3005)
PDF_SERVICE_TOKEN # Shared secret for PdfService ↔ export-route auth ("Email to vendor")
APP_INTERNAL_URL # Base URL PdfService reaches the app at (falls back to NEXTAUTH_URL)
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED # Opt-in ("true"): submitters (TECHNICAL/MANNING) read & export every PO + History (read-only)
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default) NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod. NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
``` ```

View file

@ -1,55 +0,0 @@
"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 };
}

View file

@ -1,82 +0,0 @@
"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>
);
}

View file

@ -1,34 +0,0 @@
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}
/>
);
}

View file

@ -1,167 +0,0 @@
"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 };
}

View file

@ -1,203 +0,0 @@
"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>
);
}

View file

@ -1,56 +0,0 @@
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}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,35 +0,0 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation";
import { TermsTable } from "./terms-table";
import type { Metadata } from "next";
export const metadata: Metadata = { title: "Terms & Conditions" };
export default async function TermsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard");
const [terms, categories] = await Promise.all([
db.termsCondition.findMany({
orderBy: [{ category: { sortOrder: "asc" } }, { isActive: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
include: { category: { select: { name: true } } },
}),
db.termsCategory.findMany({ orderBy: [{ sortOrder: "asc" }, { name: "asc" }], select: { name: true } }),
]);
return (
<TermsTable
categoryNames={categories.map((c) => c.name)}
terms={terms.map((t) => ({
id: t.id,
categoryName: t.category.name,
text: t.text,
isDefault: t.isDefault,
isActive: t.isActive,
}))}
/>
);
}

View file

@ -1,122 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AdminDialog } from "@/components/ui/admin-dialog";
import { createTerm, updateTerm } from "./actions";
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
export type TermRow = {
id: string;
categoryName: string;
text: string;
isDefault: boolean;
isActive: boolean;
};
function Fields({ term, categoryNames }: { term?: TermRow; categoryNames: string[] }) {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Category *</label>
<input
name="categoryName"
list="tc-category-list"
defaultValue={term?.categoryName ?? ""}
required
autoComplete="off"
className={INPUT}
placeholder="Pick a category or type a new one…"
/>
<datalist id="tc-category-list">
{categoryNames.map((c) => (
<option key={c} value={c} />
))}
</datalist>
<p className="mt-1 text-xs text-neutral-400">Type a new name to create a category.</p>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Clause text *</label>
<textarea name="text" defaultValue={term?.text ?? ""} rows={3} required className={INPUT} placeholder="e.g. Within 4 to 5 days" />
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input type="checkbox" name="isDefault" defaultChecked={term?.isDefault ?? false} className="h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500/30" />
Pre-add to new POs by default
</label>
</div>
);
}
export function AddTermButton({ categoryNames }: { categoryNames: string[] }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createTerm(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Clause
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditTermButton({
term,
categoryNames,
open: controlledOpen,
onOpenChange,
}: {
term: TermRow;
categoryNames: string[];
open?: boolean;
onOpenChange?: (v: boolean) => void;
}) {
const router = useRouter();
const [internalOpen, setInternalOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await updateTerm(term.id, new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setPending(false); setOpen(false); router.refresh(); }
}
return (
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit T&C Clause">
<form onSubmit={handleSubmit} className="space-y-4">
<Fields term={term} categoryNames={categoryNames} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</form>
</AdminDialog>
);
}

View file

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

View file

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

View file

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

View file

@ -3,8 +3,6 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { canPerformAction } from "@/lib/po-state-machine"; import { canPerformAction } from "@/lib/po-state-machine";
import { approvePoSchema } from "@/lib/validations/po";
import { syncProductCatalog } from "@/lib/product-catalog";
import { notify } from "@/lib/notifier"; import { notify } from "@/lib/notifier";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
@ -14,21 +12,14 @@ export async function approvePo({
poId, poId,
note, note,
withNote = false, withNote = false,
suggestedAdvancePayment,
}: { }: {
poId: string; poId: string;
note?: string; note?: string;
withNote?: boolean; withNote?: boolean;
// Absolute advance the Manager wants paid first (issue #92). Whole amount,
// resolved from the approval slider client-side. Omitted ⇒ full payment.
suggestedAdvancePayment?: number;
}): Promise<ActionResult> { }): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user) return { error: "Unauthorized" }; if (!session?.user) return { error: "Unauthorized" };
const parsed = approvePoSchema.safeParse({ note, suggestedAdvancePayment });
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
const po = await db.purchaseOrder.findUnique({ const po = await db.purchaseOrder.findUnique({
where: { id: poId }, where: { id: poId },
include: { submitter: true, lineItems: true }, include: { submitter: true, lineItems: true },
@ -44,28 +35,17 @@ export async function approvePo({
return { error: "A vendor must be assigned before approving this PO." }; return { error: "A vendor must be assigned before approving this PO." };
} }
// Resolve the advance: clamp to [0, total]. Undefined ⇒ no explicit advance
// (full payment, current default behaviour). The slider always sends a value,
// but a malformed/over-total amount is clamped rather than rejected.
const total = Number(po.totalAmount);
const advance =
parsed.data.suggestedAdvancePayment === undefined
? null
: Math.min(Math.max(parsed.data.suggestedAdvancePayment, 0), total);
await db.purchaseOrder.update({ await db.purchaseOrder.update({
where: { id: poId }, where: { id: poId },
data: { data: {
status: "MGR_APPROVED", status: "MGR_APPROVED",
approvedAt: new Date(), approvedAt: new Date(),
managerNote: note ?? null, managerNote: note ?? null,
suggestedAdvancePayment: advance,
actions: { actions: {
create: { create: {
actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED", actionType: withNote ? "APPROVED_WITH_NOTE" : "APPROVED",
note: note ?? null, note: note ?? null,
actorId: session.user.id, actorId: session.user.id,
metadata: advance !== null ? { suggestedAdvancePayment: advance } : undefined,
}, },
}, },
}, },
@ -85,12 +65,6 @@ export async function approvePo({
revalidatePath(`/admin/sites/${siteId}`); revalidatePath(`/admin/sites/${siteId}`);
} }
// Register the line items in the product catalogue (/catalogue/items) on
// approval, so an approved PO's items are immediately reusable in further POs.
// Idempotent; payment re-syncs to refresh prices on the final figures.
await syncProductCatalog(poId, po.lineItems, po.vendorId, session.user.id);
revalidatePath("/catalogue/items");
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }); const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
await notify({ await notify({
event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED", event: withNote ? "PO_APPROVED_WITH_NOTE" : "PO_APPROVED",

View file

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

View file

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

View file

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

View file

@ -6,9 +6,6 @@ import { ApprovalActions } from "./approval-actions";
import { PoDetail } from "@/components/po/po-detail"; import { PoDetail } from "@/components/po/po-detail";
import { ManagerEditPoForm } from "./manager-edit-po-form"; import { ManagerEditPoForm } from "./manager-edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -32,7 +29,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
}); });
const hasSignature = !!(currentUser?.signatureKey); const hasSignature = !!(currentUser?.signatureKey);
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([ const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
db.purchaseOrder.findUnique({ db.purchaseOrder.findUnique({
where: { id }, where: { id },
include: { include: {
@ -55,17 +52,12 @@ export default async function ApprovalDetailPage({ params }: Props) {
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
]); ]);
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,
@ -106,20 +98,12 @@ 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 <ApprovalActions poId={po.id} poStatus={po.status} />
poId={po.id}
poStatus={po.status}
totalAmount={Number(po.totalAmount)}
currency={po.currency}
/>
) : ( ) : (
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3"> <div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
<span className="text-warning-500 text-xl leading-none mt-0.5"></span> <span className="text-warning-500 text-xl leading-none mt-0.5"></span>

View file

@ -13,37 +13,30 @@ import {
approveInterviewWaiver, approveInterviewWaiver,
declineInterviewWaiver, declineInterviewWaiver,
} from "../crewing/applications/actions"; } 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 CrewApprovalKind = "SALARY" | "SELECTION" | "WAIVER";
export type CrewApprovalItem = { export type CrewApprovalItem = {
id: string; // applicationId, or leaveRequestId for LEAVE applicationId: string;
kind: CrewApprovalKind; kind: CrewApprovalKind;
candidateName: string; candidateName: string;
rank: string; rank: string;
requisitionCode: string; requisitionCode: string;
detail: string; detail: string; // amount for salary, etc.
link: string;
}; };
const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver", LEAVE: "Leave", APPRAISAL: "Appraisal" }; const KIND_LABEL: Record<CrewApprovalKind, string> = { SALARY: "Salary", SELECTION: "Selection", WAIVER: "Waiver" };
const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary", LEAVE: "warning", APPRAISAL: "default" } as const; const KIND_VARIANT = { SALARY: "warning", SELECTION: "default", WAIVER: "secondary" } as const;
const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = { const approveFn: Record<CrewApprovalKind, (id: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: approveSalary, SALARY: approveSalary,
SELECTION: selectCandidate, SELECTION: selectCandidate,
WAIVER: approveInterviewWaiver, 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 }>> = { const returnFn: Record<CrewApprovalKind, (id: string, reason: string) => Promise<{ ok: true } | { error: string }>> = {
SALARY: returnSalary, SALARY: returnSalary,
SELECTION: returnSelection, SELECTION: returnSelection,
WAIVER: declineInterviewWaiver, WAIVER: declineInterviewWaiver,
LEAVE: (id, reason) => decideLeave(id, false, reason),
APPRAISAL: (id, reason) => approveAppraisal(id, false, reason),
}; };
function Row({ item }: { item: CrewApprovalItem }) { function Row({ item }: { item: CrewApprovalItem }) {
@ -55,14 +48,14 @@ function Row({ item }: { item: CrewApprovalItem }) {
async function approve() { async function approve() {
setPending(true); setError(""); setPending(true); setError("");
const res = await approveFn[item.kind](item.id); const res = await approveFn[item.kind](item.applicationId);
setPending(false); setPending(false);
if ("error" in res) setError(res.error); else router.refresh(); if ("error" in res) setError(res.error); else router.refresh();
} }
async function doReturn(e: React.FormEvent) { async function doReturn(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setPending(true); setError(""); setPending(true); setError("");
const res = await returnFn[item.kind](item.id, reason); const res = await returnFn[item.kind](item.applicationId, reason);
setPending(false); setPending(false);
if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); } if ("error" in res) setError(res.error); else { setReturnOpen(false); router.refresh(); }
} }
@ -71,7 +64,7 @@ function Row({ item }: { item: CrewApprovalItem }) {
<tr className="hover:bg-neutral-50"> <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"><Badge variant={KIND_VARIANT[item.kind]}>{KIND_LABEL[item.kind]}</Badge></td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Link href={item.link} className="font-medium text-neutral-900 hover:text-primary-700">{item.candidateName}</Link> <Link href={`/crewing/applications/${item.applicationId}`} 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> <span className="block text-xs text-neutral-500">{item.rank} · <span className="font-mono">{item.requisitionCode}</span></span>
</td> </td>
<td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td> <td className="px-4 py-3 text-sm text-neutral-600">{item.detail}</td>
@ -111,7 +104,7 @@ export function CrewingApprovals({ items }: { items: CrewApprovalItem[] }) {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-neutral-100"> <tbody className="divide-y divide-neutral-100">
{items.map((item) => <Row key={`${item.kind}-${item.id}`} item={item} />)} {items.map((item) => <Row key={`${item.kind}-${item.applicationId}`} item={item} />)}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -58,9 +58,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
CREWING_ENABLED && CREWING_ENABLED &&
(hasPermission(role, "approve_salary_structure") || (hasPermission(role, "approve_salary_structure") ||
hasPermission(role, "select_candidate") || hasPermission(role, "select_candidate") ||
hasPermission(role, "approve_interview_waiver") || hasPermission(role, "approve_interview_waiver"));
hasPermission(role, "decide_leave") ||
hasPermission(role, "approve_appraisal"));
const crewGates = showCrewing const crewGates = showCrewing
? await db.applicationGate.findMany({ ? await db.applicationGate.findMany({
@ -87,52 +85,15 @@ export default async function ApprovalsPage({ searchParams }: Props) {
? "Returning crew — interview waiver" ? "Returning crew — interview waiver"
: "Interview cleared"; : "Interview cleared";
return { return {
id: g.applicationId, applicationId: g.applicationId,
kind: g.gate as CrewApprovalKind, kind: g.gate as CrewApprovalKind,
candidateName: g.application.crewMember.name, candidateName: g.application.crewMember.name,
rank: g.application.requisition.rank.name, rank: g.application.requisition.rank.name,
requisitionCode: g.application.requisition.code, requisitionCode: g.application.requisition.code,
detail, 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">
@ -222,7 +183,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</> </>
)} )}
{showCrewing && allCrewItems.length > 0 && <CrewingApprovals items={allCrewItems} />} {showCrewing && crewItems.length > 0 && <CrewingApprovals items={crewItems} />}
</div> </div>
); );
} }

View file

@ -89,7 +89,6 @@ export default async function ApplicationDetailPage({ params }: { params: Promis
salaryPending={salaryPending} salaryPending={salaryPending}
waiverPending={waiverPending} waiverPending={waiverPending}
selectionPending={selectionPending} selectionPending={selectionPending}
employeeNo={app.crewMember.employeeId}
salary={proposed ? { salary={proposed ? {
rateBasis: proposed.rateBasis, rateBasis: proposed.rateBasis,
basic: Number(proposed.basic), basic: Number(proposed.basic),
@ -105,7 +104,6 @@ export default async function ApplicationDetailPage({ params }: { params: Promis
approveSalary: hasPermission(role, "approve_salary_structure"), approveSalary: hasPermission(role, "approve_salary_structure"),
approveWaiver: hasPermission(role, "approve_interview_waiver"), approveWaiver: hasPermission(role, "approve_interview_waiver"),
select: hasPermission(role, "select_candidate"), select: hasPermission(role, "select_candidate"),
onboard: hasPermission(role, "onboard_crew"),
}} }}
/> />

View file

@ -10,10 +10,7 @@ import {
getTransition, getTransition,
type ApplicationAction, type ApplicationAction,
} from "@/lib/application-pipeline"; } from "@/lib/application-pipeline";
import { getManagerRecipients } from "@/lib/requisition-service"; import { getManagerRecipients, getMpoRecipients } 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 { notifyCrew } from "@/lib/notifier";
import { SalaryRateBasis } from "@prisma/client"; import { SalaryRateBasis } from "@prisma/client";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -115,16 +112,6 @@ export async function advanceStage(id: string, action: ApplicationAction): Promi
if (!transition) return { error: `Cannot ${action} from ${app.stage}` }; if (!transition) return { error: `Cannot ${action} from ${app.stage}` };
if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" }; if (!canPerformAction(app.stage, action, g.role)) return { error: "Unauthorized" };
// C5 (spec §5.1 / Epic C5 AC1): at least one reference must be recorded before
// leaving the COMPETENCY_AND_REFERENCES stage. The merged competency+references
// gate is completed by `verify_competency`.
if (action === "verify_competency") {
const references = await db.referenceCheck.count({ where: { applicationId: id } });
if (references === 0) {
return { error: "Record at least one reference check before completing competency & references" };
}
}
await db.application.update({ await db.application.update({
where: { id }, where: { id },
data: { data: {
@ -217,33 +204,6 @@ export async function verifyDocuments(formData: FormData): Promise<ActionResult>
const d = parsed.data; const d = parsed.data;
const crewMemberId = app.crewMember.id; const crewMemberId = app.crewMember.id;
// C3 (spec §5.1 / Epic C3 AC1): block advancement when a mandatory document for
// the seat's rank is EXPIRED.
// Scope note (documented limitation): seafarer documents are collected on the
// crew profile *after* onboarding (Phase 4a) — during the pipeline a candidate
// usually has none on file, so a hard "missing document" block would stall the
// whole funnel. We therefore gate on what is available (expiry of documents the
// candidate already holds); the "all required documents present" check is
// enforced post-onboarding in the verification queue (§8.11). Once careers
// intake (A2) uploads documents pre-onboarding, tighten this to also require
// presence of every mandatory docType.
const reqRank = await db.requisition.findUnique({ where: { id: app.requisition.id }, select: { rankId: true } });
if (reqRank) {
const [required, candidateDocs] = await Promise.all([
db.rankDocRequirement.findMany({ where: { rankId: reqRank.rankId, isMandatory: true }, select: { docType: true } }),
db.seafarerDocument.findMany({ where: { crewMemberId }, select: { docType: true, expiryDate: true } }),
]);
const requiredTypes = new Set(required.map((r) => r.docType));
const now = new Date();
const expired = candidateDocs.filter((doc) => requiredTypes.has(doc.docType) && doc.expiryDate && doc.expiryDate < now);
if (expired.length > 0) {
return { error: `Cannot verify documents — a required document is expired: ${expired.map((doc) => doc.docType).join(", ")}` };
}
}
// C4 (experience check) is deferred: the Requisition has no min-experience
// criteria field yet (see Epic A2 AC1 / wiki Tech-Debt). Once that lands, compare
// the candidate's ExperienceRecord total against it here and flag a shortfall.
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
// Capture bank / EPF (PII — encryption deferred to Phase 4). // Capture bank / EPF (PII — encryption deferred to Phase 4).
await tx.bankDetail.upsert({ await tx.bankDetail.upsert({
@ -369,7 +329,7 @@ export async function returnSalary(id: string, reason: string): Promise<ActionRe
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
}); });
await db.crewAction.create({ await db.crewAction.create({
data: { actionType: "SALARY_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` }, data: { actionType: "SALARY_AGREED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Returned: ${reason.trim()}` },
}); });
revalidateApp(id, app.requisition.id); revalidateApp(id, app.requisition.id);
return { ok: true }; return { ok: true };
@ -486,7 +446,7 @@ export async function declineInterviewWaiver(id: string, reason: string): Promis
data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() },
}); });
await db.crewAction.create({ await db.crewAction.create({
data: { actionType: "WAIVER_DECLINED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` }, data: { actionType: "WAIVER_REQUESTED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Declined: ${reason.trim()}` },
}); });
revalidateApp(id, app.requisition.id); revalidateApp(id, app.requisition.id);
return { ok: true }; return { ok: true };
@ -536,7 +496,7 @@ export async function returnSelection(id: string, reason: string): Promise<Actio
await db.$transaction(async (tx) => { await db.$transaction(async (tx) => {
await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } }); await tx.applicationGate.updateMany({ where: { applicationId: id, gate: "SELECTION" }, data: { result: "REJECTED", decidedById: g.userId, note: reason.trim() } });
await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } }); await tx.application.update({ where: { id }, data: { interviewResult: "PENDING" } });
await tx.crewAction.create({ data: { actionType: "SELECTION_RETURNED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } }); await tx.crewAction.create({ data: { actionType: "INTERVIEW_RECORDED", actorId: g.userId, applicationId: id, crewMemberId: app.crewMember.id, note: `Selection returned: ${reason.trim()}` } });
}); });
revalidateApp(id, app.requisition.id); revalidateApp(id, app.requisition.id);
return { ok: true }; return { ok: true };
@ -575,106 +535,3 @@ export async function rejectApplication(id: string, reason: string): Promise<Act
return rejectApplicationInternal(id, app.crewMember.id, app.requisition.id, g.userId, reason.trim()); 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 };
}

View file

@ -17,7 +17,6 @@ import {
selectCandidate, selectCandidate,
returnSelection, returnSelection,
rejectApplication, rejectApplication,
onboardCandidate,
} from "./actions"; } from "./actions";
const INPUT = const INPUT =
@ -36,7 +35,6 @@ export type ActionCardProps = {
salaryPending: boolean; salaryPending: boolean;
waiverPending: boolean; waiverPending: boolean;
selectionPending: boolean; selectionPending: boolean;
employeeNo: string | null;
salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null; salary: { rateBasis: SalaryRateBasis; basic: number; victualingPerDay: number; currency: string; approved: boolean } | null;
perms: { perms: {
manage: boolean; manage: boolean;
@ -46,7 +44,6 @@ export type ActionCardProps = {
approveSalary: boolean; approveSalary: boolean;
approveWaiver: boolean; approveWaiver: boolean;
select: boolean; select: boolean;
onboard: boolean;
}; };
}; };
@ -299,7 +296,7 @@ export function ApplicationActionCard(p: ActionCardProps) {
return ( return (
<Card title="Selected" sub="Ready to onboard."> <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 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} />} <button className={PRIMARY} disabled title="Onboarding arrives in the next phase (3c)">Onboard to crew (next phase)</button>
</Card> </Card>
); );
@ -313,62 +310,12 @@ export function ApplicationActionCard(p: ActionCardProps) {
default: default:
return ( return (
<Card title="Onboarded"> <Card title="Onboarded">
<p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2"> <p className="text-sm text-success-700 bg-success-50 rounded-lg px-3 py-2">This candidate has been onboarded.</p>
Onboarded to crew{p.employeeNo ? <> · <span className="font-mono">{p.employeeNo}</span></> : null}.
</p>
</Card> </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 &amp; 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 }> }) { function ReturnButton({ label, onReturn }: { label: string; onReturn: (reason: string) => Promise<{ ok: true } | { error: string }> }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);

View file

@ -1,146 +0,0 @@
"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 };
}

View file

@ -1,46 +0,0 @@
"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 };
}

View file

@ -1,169 +0,0 @@
"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>
);
}

View file

@ -1,46 +0,0 @@
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")}
/>
);
}

View file

@ -21,13 +21,7 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
const { id } = await params; const { id } = await params;
const c = await db.crewMember.findUnique({ const c = await db.crewMember.findUnique({
where: { id }, where: { id },
include: { include: { appliedRank: { select: { name: true } }, currentRank: { select: { name: true } } },
appliedRank: { select: { name: true } },
currentRank: { select: { name: true } },
// B3 AC3 — pull the returning hand's history so the callout shows real records.
experienceRecords: { orderBy: { fromDate: "desc" }, include: { rank: { select: { name: true } } } },
documents: { orderBy: { createdAt: "desc" }, select: { id: true, docType: true, expiryDate: true } },
},
}); });
if (!c) notFound(); if (!c) notFound();
@ -51,50 +45,16 @@ export default async function CandidateDetailPage({ params }: { params: Promise<
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1> <h1 className="text-2xl font-semibold text-neutral-900">{c.name}</h1>
<Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge> <Badge variant={STATUS_VARIANT[c.status]}>{STATUS_LABEL[c.status]}</Badge>
{c.type === "EX_HAND" && ( {c.source === "EX_HAND" && (
<span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span> <span className="rounded-full bg-purple-100 text-purple-700 px-2.5 py-0.5 text-xs font-medium">Returning crew</span>
)} )}
</div> </div>
</div> </div>
{c.type === "EX_HAND" && ( {c.source === "EX_HAND" && (
<div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800"> <div className="mb-6 rounded-lg border border-purple-200 bg-purple-50 px-4 py-3 text-sm text-purple-800">
<strong>Returning crew.</strong> The interview may be waived with Manager approval.{" "} <strong>Returning crew.</strong> Prior documents, bank details and tour history are on file from earlier
{c.experienceRecords.length === 0 && c.documents.length === 0 ? ( assignments; the interview may be waived with Manager approval (recruitment pipeline next phase).
<span>No prior records are on file yet.</span>
) : (
<span>Prior records on file from earlier assignments:</span>
)}
{c.experienceRecords.length > 0 && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Tour history</p>
<ul className="space-y-1">
{c.experienceRecords.map((e) => (
<li key={e.id} className="text-sm text-purple-900">
{e.rank?.name ?? "—"}
{e.vesselType ? ` · ${e.vesselType}` : ""}
{e.durationMonths != null ? ` · ${experienceLabel(e.durationMonths)}` : ""}
{e.fromDate ? ` (${e.fromDate.getFullYear()}${e.toDate ? `${e.toDate.getFullYear()}` : ""})` : ""}
</li>
))}
</ul>
</div>
)}
{c.documents.length > 0 && (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-purple-600 mb-1">Documents on file</p>
<div className="flex flex-wrap gap-1.5">
{c.documents.map((doc) => (
<span key={doc.id} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-800">
{doc.docType}
{doc.expiryDate ? ` · exp ${doc.expiryDate.getFullYear()}` : ""}
</span>
))}
</div>
</div>
)}
</div> </div>
)} )}

View file

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

View file

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

View file

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

View file

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

View file

@ -33,7 +33,6 @@ export default async function CandidatesPage() {
id: c.id, id: c.id,
name: c.name, name: c.name,
source: c.source, source: c.source,
type: c.type,
status: c.status, status: c.status,
appliedRankId: c.appliedRankId, appliedRankId: c.appliedRankId,
appliedRank: c.appliedRank?.name ?? null, appliedRank: c.appliedRank?.name ?? null,
@ -47,9 +46,5 @@ export default async function CandidatesPage() {
hasCv: Boolean(c.cvKey), hasCv: Boolean(c.cvKey),
})); }));
// B3 AC2 — ex-hands (proven crew) surface above new candidates by default.
// Stable sort preserves the createdAt-desc order within each group.
rows.sort((a, b) => Number(b.status === "EX_HAND") - Number(a.status === "EX_HAND"));
return <CandidatesManager candidates={rows} ranks={ranks} />; return <CandidatesManager candidates={rows} ranks={ranks} />;
} }

View file

@ -1,423 +0,0 @@
"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>
</>
);
}

View file

@ -1,113 +0,0 @@
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) }}
/>
);
}

View file

@ -1,329 +0,0 @@
"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 };
}

View file

@ -1,93 +0,0 @@
"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>
);
}

View file

@ -1,55 +0,0 @@
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} />;
}

View file

@ -1,138 +0,0 @@
"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 };
}

View file

@ -1,163 +0,0 @@
"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>
);
}

View file

@ -1,52 +0,0 @@
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}
/>
);
}

View file

@ -25,7 +25,6 @@ export default async function RequisitionsPage() {
vessel: { select: { name: true } }, vessel: { select: { name: true } },
site: { select: { name: true } }, site: { select: { name: true } },
raisedBy: { select: { name: true } }, raisedBy: { select: { name: true } },
_count: { select: { applications: true } },
}, },
}), }),
db.reliefRequest.findMany({ db.reliefRequest.findMany({
@ -53,7 +52,6 @@ export default async function RequisitionsPage() {
rankName: r.rank.name, rankName: r.rank.name,
location: r.vessel?.name ?? r.site?.name ?? "—", location: r.vessel?.name ?? r.site?.name ?? "—",
raisedBy: r.raisedBy?.name ?? "System", raisedBy: r.raisedBy?.name ?? "System",
candidateCount: r._count.applications,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
})); }));

View file

@ -16,7 +16,6 @@ type RequisitionRow = {
rankName: string; rankName: string;
location: string; location: string;
raisedBy: string; raisedBy: string;
candidateCount: number;
createdAt: string; createdAt: string;
}; };
@ -59,33 +58,21 @@ export function RequisitionsManager({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL"); const [status, setStatus] = useState<"ALL" | RequisitionStatus>("ALL");
const [location, setLocation] = useState("ALL"); const [location, setLocation] = useState("ALL");
const [rank, setRank] = useState("ALL");
const [reason, setReason] = useState<"ALL" | RequisitionReason>("ALL");
const locations = useMemo( const locations = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(), () => Array.from(new Set(requisitions.map((r) => r.location).filter((l) => l !== "—"))).sort(),
[requisitions] [requisitions]
); );
const rankNames = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.rankName))).sort(),
[requisitions]
);
const reasons = useMemo(
() => Array.from(new Set(requisitions.map((r) => r.reason))),
[requisitions]
);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
return requisitions.filter((r) => { return requisitions.filter((r) => {
if (status !== "ALL" && r.status !== status) return false; if (status !== "ALL" && r.status !== status) return false;
if (location !== "ALL" && r.location !== location) return false; if (location !== "ALL" && r.location !== location) return false;
if (rank !== "ALL" && r.rankName !== rank) return false;
if (reason !== "ALL" && r.reason !== reason) return false;
if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false; if (q && !`${r.code} ${r.rankName} ${r.location}`.toLowerCase().includes(q)) return false;
return true; return true;
}); });
}, [requisitions, search, status, location, rank, reason]); }, [requisitions, search, status, location]);
return ( return (
<div> <div>
@ -119,18 +106,6 @@ export function RequisitionsManager({
<option key={l} value={l}>{l}</option> <option key={l} value={l}>{l}</option>
))} ))}
</select> </select>
<select className={INPUT} value={rank} onChange={(e) => setRank(e.target.value)}>
<option value="ALL">All ranks</option>
{rankNames.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select className={INPUT} value={reason} onChange={(e) => setReason(e.target.value as typeof reason)}>
<option value="ALL">All reasons</option>
{reasons.map((r) => (
<option key={r} value={r}>{REASON_LABEL[r]}</option>
))}
</select>
</div> </div>
{/* Requisitions table */} {/* Requisitions table */}
@ -142,7 +117,6 @@ export function RequisitionsManager({
<th className="px-4 py-3">Vessel / site</th> <th className="px-4 py-3">Vessel / site</th>
<th className="px-4 py-3">Rank</th> <th className="px-4 py-3">Rank</th>
<th className="px-4 py-3">Reason</th> <th className="px-4 py-3">Reason</th>
<th className="px-4 py-3">Candidates</th>
<th className="px-4 py-3">Raised by</th> <th className="px-4 py-3">Raised by</th>
<th className="px-4 py-3">Status</th> <th className="px-4 py-3">Status</th>
</tr> </tr>
@ -150,7 +124,7 @@ export function RequisitionsManager({
<tbody> <tbody>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-4 py-12 text-center text-neutral-400"> <td colSpan={6} className="px-4 py-12 text-center text-neutral-400">
No requisitions match these filters. No requisitions match these filters.
</td> </td>
</tr> </tr>
@ -171,7 +145,6 @@ export function RequisitionsManager({
<td className="px-4 py-3 text-neutral-700">{r.location}</td> <td className="px-4 py-3 text-neutral-700">{r.location}</td>
<td className="px-4 py-3 text-neutral-700">{r.rankName}</td> <td className="px-4 py-3 text-neutral-700">{r.rankName}</td>
<td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td> <td className="px-4 py-3 text-neutral-500">{REASON_LABEL[r.reason]}</td>
<td className="px-4 py-3 text-neutral-700 tabular-nums">{r.candidateCount}</td>
<td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td> <td className="px-4 py-3 text-neutral-500">{r.raisedBy}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge> <Badge variant={STATUS_VARIANT[r.status]}>{STATUS_LABEL[r.status]}</Badge>

View file

@ -1,165 +0,0 @@
"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 };
}

View file

@ -1,82 +0,0 @@
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}
/>
);
}

View file

@ -1,283 +0,0 @@
"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>
);
}

View file

@ -19,18 +19,12 @@ const STATUSES = [
interface Props { interface Props {
vessels: { id: string; name: string }[]; vessels: { id: string; name: string }[];
perPageOptions: number[];
defaultPerPage: number;
} }
export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Props) { export function HistoryFilters({ vessels }: Props) {
const router = useRouter(); const router = useRouter();
const sp = useSearchParams(); const sp = useSearchParams();
const perPage = perPageOptions.includes(Number(sp.get("perPage")))
? Number(sp.get("perPage"))
: defaultPerPage;
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? ""); const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? ""); const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? ""); const [approvedFrom, setApprovedFrom] = useState(sp.get("approvedFrom") ?? "");
@ -56,8 +50,7 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
); );
} }
// Changing any filter resets to page 1; perPage is preserved across applies. function apply() {
function buildParams(nextPerPage: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (dateFrom) params.set("dateFrom", dateFrom); if (dateFrom) params.set("dateFrom", dateFrom);
if (dateTo) params.set("dateTo", dateTo); if (dateTo) params.set("dateTo", dateTo);
@ -65,24 +58,12 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
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);
if (nextPerPage !== defaultPerPage) params.set("perPage", String(nextPerPage)); router.push(`/history?${params.toString()}`);
return params;
}
function apply() {
router.push(`/history?${buildParams(perPage).toString()}`);
}
function changePerPage(next: number) {
router.push(`/history?${buildParams(next).toString()}`);
} }
function clear() { function clear() {
setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]); setDateFrom(""); setDateTo(""); setApprovedFrom(""); setApprovedTo(""); setVesselId(""); setStatuses([]);
const params = new URLSearchParams(); router.push("/history");
if (perPage !== defaultPerPage) params.set("perPage", String(perPage));
const qs = params.toString();
router.push(qs ? `/history?${qs}` : "/history");
} }
const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0; const hasFilters = dateFrom || dateTo || approvedFrom || approvedTo || vesselId || statuses.length > 0;
@ -158,13 +139,6 @@ export function HistoryFilters({ vessels, perPageOptions, defaultPerPage }: Prop
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>
); );

View file

@ -1,21 +1,17 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { hasPermission, submitterCanViewAll } from "@/lib/permissions"; import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { formatCurrency, formatDate } from "@/lib/utils"; import { formatCurrency, formatDate } from "@/lib/utils";
import { PoStatusBadge } from "@/components/po/po-status-badge"; import { PoStatusBadge } from "@/components/po/po-status-badge";
import { HistoryFilters } from "./history-filters"; import { HistoryFilters } from "./history-filters";
import { 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;
@ -24,8 +20,6 @@ interface Props {
approvedTo?: string; approvedTo?: string;
vesselId?: string; vesselId?: string;
status?: string | string[]; status?: string | string[];
page?: string;
perPage?: string;
}>; }>;
} }
@ -33,17 +27,9 @@ export default async function HistoryPage({ searchParams }: Props) {
const session = await auth(); const session = await auth();
if (!session?.user) redirect("/login"); if (!session?.user) redirect("/login");
// Report-export holders see History; submitters get read+export access when the if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
// submitter-view-all feature flag is on.
if (
!hasPermission(session.user.role, "export_reports") &&
!submitterCanViewAll(session.user.role)
) {
redirect("/dashboard");
}
const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status, page: pageParam, perPage: perPageParam } = const { dateFrom, dateTo, approvedFrom, approvedTo, vesselId, status } = await searchParams;
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) {
@ -70,45 +56,16 @@ 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" },
skip, take: 200,
take,
}), }),
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
]); ]);
// 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);
@ -140,7 +97,7 @@ export default async function HistoryPage({ searchParams }: Props) {
</div> </div>
<Suspense> <Suspense>
<HistoryFilters vessels={vessels} perPageOptions={PER_PAGE_OPTIONS} defaultPerPage={DEFAULT_PER_PAGE} /> <HistoryFilters vessels={vessels} />
</Suspense> </Suspense>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
@ -185,41 +142,8 @@ export default async function HistoryPage({ searchParams }: Props) {
<div className="p-12 text-center text-neutral-500">No purchase orders found.</div> <div className="p-12 text-center text-neutral-500">No purchase orders found.</div>
)} )}
</div> </div>
{total > 0 && ( {orders.length === 200 && (
<div className="mt-3 flex items-center justify-between text-sm text-neutral-600"> <p className="mt-2 text-xs text-neutral-400 text-right">Showing first 200 results refine filters to narrow results.</p>
<span>
Showing {firstRow}{lastRow} of {total}
</span>
<div className="flex items-center gap-2">
{page > 1 ? (
<Link
href={pageHref(page - 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Previous
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Previous
</span>
)}
<span className="text-neutral-500">
Page {page} of {totalPages}
</span>
{page < totalPages ? (
<Link
href={pageHref(page + 1)}
className="rounded-lg border border-neutral-300 bg-white px-3 py-1.5 font-medium text-neutral-700 hover:bg-neutral-50 transition-colors"
>
Next
</Link>
) : (
<span className="rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 font-medium text-neutral-300">
Next
</span>
)}
</div>
</div>
)} )}
</div> </div>
); );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,9 +3,6 @@ import { db } from "@/lib/db";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { EditPoForm } from "./edit-po-form"; import { EditPoForm } from "./edit-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getTermsCatalogue } from "@/lib/terms-data";
import { parsePoTerms, legacyPoTerms } from "@/lib/terms";
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form"; import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
import type { Metadata } from "next"; import type { Metadata } from "next";
@ -32,7 +29,7 @@ export default async function EditPoPage({ params }: Props) {
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER"; const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
if (!canEdit) redirect(`/po/${id}`); if (!canEdit) redirect(`/po/${id}`);
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([ const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
@ -41,7 +38,6 @@ export default async function EditPoPage({ params }: Props) {
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
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 } },
@ -52,10 +48,6 @@ export default async function EditPoPage({ params }: Props) {
]); ]);
const accounts = buildAccountGroups(leafAccounts); const accounts = buildAccountGroups(leafAccounts);
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
const termsCatalogue = await getTermsCatalogue();
const savedTerms = parsePoTerms(po.terms);
const initialTerms = savedTerms.length > 0 ? savedTerms : legacyPoTerms(po);
const serializedPo = { const serializedPo = {
...po, ...po,
@ -81,9 +73,6 @@ 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>

View file

@ -1,90 +0,0 @@
"use server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { buildPoPdfKey, uploadBuffer, generateDownloadUrl, statObject } from "@/lib/storage";
import { renderPoPdf, isPdfServiceConfigured, PdfServiceError } from "@/lib/pdf-service";
type Result = { ok: true; mailto: string; to: string } | { error: string };
// PO must be approved (a valid document) before it can be emailed to a vendor;
// available through every later state, incl. once payment is recorded (issue #14).
const EMAILABLE = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
const VIEW_ALL_ROLES = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const LINK_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
/**
* Build an "email this PO to the vendor" Outlook draft: render the PO to a PDF,
* store it (R2), and return a mailto: addressed to the vendor's primary contact
* with a time-limited download link in the body. The user reviews & sends it.
*/
export async function prepareVendorEmail(poId: string): Promise<Result> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: {
company: { select: { name: true } },
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
},
});
if (!po) return { error: "PO not found" };
const canView = VIEW_ALL_ROLES.includes(session.user.role) || po.submitterId === session.user.id;
if (!canView) return { error: "You cannot access this purchase order." };
if (!EMAILABLE.includes(po.status)) {
return { error: "The PO must be approved before it can be emailed to the vendor." };
}
const to = po.vendor?.contacts?.[0]?.email?.trim();
if (!to) {
return { error: "The vendor has no primary contact email. Add one on the vendor before emailing." };
}
if (!isPdfServiceConfigured()) {
return { error: "PDF emailing is not configured on this environment." };
}
// Render → store → presigned link. The PDF is cached at a deterministic
// per-PO key: if a copy already exists and is at least as new as the PO's last
// change, reuse it and only mint a fresh presigned URL (refreshing the 7-day
// timer). Re-render only when there's no copy yet or the PO changed since.
let link: string;
try {
const slug = po.poNumber.replace(/\//g, "-");
const key = buildPoPdfKey(poId, `${slug}.pdf`);
const cached = await statObject(key);
const isFresh = cached !== null && cached.lastModified >= po.updatedAt;
if (!isFresh) {
const pdf = await renderPoPdf(poId);
await uploadBuffer(key, pdf, "application/pdf");
}
link = await generateDownloadUrl(key, LINK_TTL_SECONDS);
} catch (e) {
if (e instanceof PdfServiceError) return { error: `Could not generate the PO PDF: ${e.message}` };
return { error: "Could not generate the PO PDF." };
}
const company = po.company?.name ?? "Pelagia Marine Services Pvt. Ltd.";
const vendorName = po.vendor?.contacts?.[0]?.name || po.vendor?.name || "Sir/Madam";
const sender = session.user.name ?? "";
const subject = `Purchase Order ${po.poNumber}`;
const body = [
`Dear ${vendorName},`,
"",
`Please find our Purchase Order ${po.poNumber} at the link below:`,
link,
"",
"(The link is valid for 7 days.)",
"",
"Regards,",
sender,
company,
].join("\n");
const mailto = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
return { ok: true, mailto, to };
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,8 +4,6 @@ import { hasPermission } from "@/lib/permissions";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { NewPoForm } from "./new-po-form"; import { NewPoForm } from "./new-po-form";
import { buildAccountGroups } from "@/lib/cost-centre-groups"; import { buildAccountGroups } from "@/lib/cost-centre-groups";
import { formatDeliveryLocation } from "@/lib/delivery-location";
import { getTermsCatalogue, getDefaultPoTerms } from "@/lib/terms-data";
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { CartItem } from "@/lib/cart"; import type { CartItem } from "@/lib/cart";
@ -48,7 +46,7 @@ export default async function NewPoPage({ searchParams }: Props) {
} }
} }
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([ const [vessels, leafAccounts, vendors, companies] = await Promise.all([
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.account.findMany({ db.account.findMany({
where: { isActive: true, children: { none: {} } }, where: { isActive: true, children: { none: {} } },
@ -57,12 +55,9 @@ export default async function NewPoPage({ searchParams }: Props) {
}), }),
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }), db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }), db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
]); ]);
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">
@ -77,9 +72,6 @@ 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}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo/otp { uan } → { sessionId, mobileHint } — request an EPFO OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.uan) return NextResponse.json({ error: "uan is required" }, { status: 400 });
try {
const res = await fetch(`${EPFO_SERVICE}/otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uan: body.uan }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

View file

@ -1,32 +0,0 @@
import { auth } from "@/auth";
import { hasPermission } from "@/lib/permissions";
import { NextRequest, NextResponse } from "next/server";
const EPFO_SERVICE = process.env.EPFO_SERVICE_URL ?? "http://localhost:3004";
/** POST /api/epfo { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!hasPermission(session.user.role, "verify_bank_epf")) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
if (!body.sessionId || !body.uan || !body.otp) {
return NextResponse.json({ error: "sessionId, uan and otp are required" }, { status: 400 });
}
try {
const res = await fetch(`${EPFO_SERVICE}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: body.sessionId, uan: body.uan, otp: body.otp }),
cache: "no-store",
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return NextResponse.json({ error: `EPFO service unavailable: ${String(e)}` }, { status: 502 });
}
}

View file

@ -3,12 +3,10 @@ import { db } from "@/lib/db";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po"; import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import { downloadBuffer } from "@/lib/storage"; import { downloadBuffer } from "@/lib/storage";
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark"; import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
import { getImageSize, scaleToBox } from "@/lib/image-size"; import { getImageSize, scaleToBox } from "@/lib/image-size";
import { signatoryLayout } from "@/lib/po-export-layout"; import { signatoryLayout } from "@/lib/po-export-layout";
import { canViewAllPos } from "@/lib/permissions";
// ── Company fallback constants (used when no company is linked to a PO) ────── // ── Company fallback constants (used when no company is linked to a PO) ──────
@ -52,14 +50,8 @@ async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage
interface Props { params: Promise<{ id: string }> } interface Props { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, { params }: Props) { export async function GET(request: NextRequest, { params }: Props) {
// PdfService renders this page to a real PDF (issue #14). It authenticates with
// a short, server-only token instead of a user session — read-only, PDF only.
const svcToken = request.nextUrl.searchParams.get("svc");
const isService =
!!svcToken && !!process.env.PDF_SERVICE_TOKEN && svcToken === process.env.PDF_SERVICE_TOKEN;
const session = await auth(); const session = await auth();
if (!session?.user && !isService) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params; const { id } = await params;
const po = await db.purchaseOrder.findUnique({ const po = await db.purchaseOrder.findUnique({
@ -74,13 +66,10 @@ export async function GET(request: NextRequest, { params }: Props) {
}); });
if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!po) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!isService) { const canViewAll = ["ACCOUNTS", "MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"].includes(session.user.role);
// view_all_pos holders, or submitters when the view-all feature flag is on, may export if (!canViewAll && po.submitterId !== session.user.id) {
// any PO; everyone else only their own. (PdfService bypasses this — read-only, PDF only.)
if (!canViewAllPos(session!.user.role) && po.submitterId !== session!.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
}
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO // Exports are available for approved POs (manager approval is a prerequisite for a valid PO
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark. // document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
@ -95,9 +84,6 @@ export async function GET(request: NextRequest, { params }: Props) {
} }
const format = request.nextUrl.searchParams.get("format") ?? "pdf"; const format = request.nextUrl.searchParams.get("format") ?? "pdf";
// pdf=1 → render a clean page for PdfService: no on-screen print button and no
// window.print() auto-trigger (Chromium's page.pdf() captures it directly).
const cleanPdf = request.nextUrl.searchParams.get("pdf") === "1";
// ── Company data (from linked company, or fallback to constants) ────────── // ── Company data (from linked company, or fallback to constants) ──────────
const co = po.company; const co = po.company;
@ -183,13 +169,7 @@ export async function GET(request: NextRequest, { params }: Props) {
const reqDate = fmtDate(ext.requisitionDate); const reqDate = fmtDate(ext.requisitionDate);
const delivery = ext.placeOfDelivery ?? ""; const delivery = ext.placeOfDelivery ?? "";
// T&C (issue #11): prefer the dynamic snapshot (po.terms) when present; older const tcLines: [number, string, string][] = [
// POs fall back to the legacy tc* columns + the fixed boilerplate lines.
const dynamicTerms = parsePoTerms((po as { terms?: unknown }).terms);
const tcLines: [number, string, string][] =
dynamicTerms.length > 0
? dynamicTerms.map((t, i) => [i + 1, (t.category || "").toUpperCase(), t.text] as [number, string, string])
: [
[1, "", TC_FIXED_LINE], [1, "", TC_FIXED_LINE],
[2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery], [2, "DELIVERY", ext.tcDelivery ?? TC_DEFAULTS.tcDelivery],
[3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch], [3, "DISPATCH INSTRUCTIONS", ext.tcDispatch ?? TC_DEFAULTS.tcDispatch],
@ -755,11 +735,11 @@ export async function GET(request: NextRequest, { params }: Props) {
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""} ${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px"> <div class="no-print" style="margin-bottom:8px">
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px"> <button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
🖨 Print / Save as PDF 🖨 Print / Save as PDF
</button> </button>
</div>`} </div>
<!-- ── Header ─────────────────────────────────────────────────── --> <!-- ── Header ─────────────────────────────────────────────────── -->
<div class="header-band"> <div class="header-band">
@ -908,7 +888,7 @@ ${cleanPdf ? "" : `<div class="no-print" style="margin-bottom:8px">
<!-- ── Brand bar ─────────────────────────────────────────────── --> <!-- ── Brand bar ─────────────────────────────────────────────── -->
<div class="brand-bar"></div> <div class="brand-bar"></div>
${cleanPdf ? "" : `<script>window.onload = function() { window.print(); };</script>`} <script>window.onload = function() { window.print(); };</script>
</body> </body>
</html>`; </html>`;

View file

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

View file

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

View file

@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { INVENTORY_ENABLED, SUBMITTER_VIEW_ALL_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags"; import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LayoutDashboard, LayoutDashboard,
@ -28,15 +27,6 @@ import {
Network, Network,
ClipboardList, ClipboardList,
UserSearch, UserSearch,
Contact,
CalendarDays,
CalendarCheck,
UserCog,
Gauge,
BadgeCheck,
Truck,
ScrollText,
ChevronRight,
} from "lucide-react"; } from "lucide-react";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -47,59 +37,38 @@ interface NavItem {
roles?: Role[]; roles?: Role[];
} }
// History is open to all-PO viewers; when the submitter-view-all flag is on, submitters
// (TECHNICAL / MANNING) get read+export access to it too.
const HISTORY_ROLES: Role[] = [
"MANAGER", "SUPERUSER", "AUDITOR", "ADMIN",
...(SUBMITTER_VIEW_ALL_ENABLED ? (["TECHNICAL", "MANNING"] as Role[]) : []),
];
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/po/new", label: "New PO", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import PO", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] }, { href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] }, { href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] }, { href: "/payments/history", label: "Payment History", icon: Receipt, roles: ["ACCOUNTS", "SUPERUSER"] },
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"] },
{ href: "/profile", label: "My Profile", icon: UserCircle }, { href: "/profile", label: "My Profile", icon: UserCircle },
]; ];
// ── Purchasing section ──────────────────────────────────────────────────────── // ── Purchasing section ────────────────────────────────────────────────────────
// Purchase Order actions (create / browse / import / history)
const PURCHASING_PO: NavItem[] = [
{ href: "/po/new", label: "New Purchase Order", icon: Plus, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/my-orders", label: "Closed Purchase Orders", icon: FileText, roles: ["TECHNICAL", "MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/po/import", label: "Import Purchase Order", icon: Upload, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/history", label: "Purchase Order History", icon: History, roles: HISTORY_ROLES },
];
// Staff browsing items (product catalogue + cart for PO creation) // Staff browsing items (product catalogue + cart for PO creation)
const PURCHASING_STAFF: NavItem[] = [ const PURCHASING_STAFF: NavItem[] = [
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, { href: "/inventory/items", label: "Items", icon: Package, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] }, { href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["TECHNICAL", "MANNING", "SUPERUSER"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] }, { href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
]; ];
// Manager catalogue management — Sites conditionally shown // Manager catalogue management — Sites conditionally shown
// Admin does not use Purchasing; their links live under Administration // Admin does not use Purchasing; their links live under Administration
const PURCHASING_MGMT: NavItem[] = [ const PURCHASING_MGMT: NavItem[] = [
{ href: "/catalogue/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] }, { href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
{ href: "/catalogue/items", label: "Items", icon: Package, roles: ["MANAGER"] }, { href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] }, { href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
...(INVENTORY_ENABLED ...(INVENTORY_ENABLED
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }] ? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
: []), : []),
]; ];
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_PO, ...PURCHASING_STAFF, ...PURCHASING_MGMT]; const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
// ── Reports section ───────────────────────────────────────────────────────────
// Spend analytics, gated by `view_analytics` (Manager / SuperUser / Auditor /
// Admin). Links are grouped under a "Purchasing" subheading so other domains
// (e.g. Crewing) can hang their own report groups here later.
const REPORTS_ROLES: Role[] = ["MANAGER", "SUPERUSER", "AUDITOR", "ADMIN"];
const REPORTS_PURCHASING: NavItem[] = [
{ href: "/reports/cost-centres", label: "Cost Centres", icon: Ship, roles: REPORTS_ROLES },
{ href: "/reports/accounting-codes", label: "Accounting Codes", icon: Building2, roles: REPORTS_ROLES },
];
// ── Crewing section (feature-flagged) ───────────────────────────────────────── // ── Crewing section (feature-flagged) ─────────────────────────────────────────
// Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per // Gated by CREWING_ENABLED. Phase 2 adds Requisitions (Manager + MPO, per
@ -110,10 +79,6 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
? [ ? [
{ href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] }, { href: "/crewing/requisitions", label: "Requisitions", icon: ClipboardList, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] }, { href: "/crewing/candidates", label: "Candidates", icon: UserSearch, roles: ["MANNING", "MANAGER", "SUPERUSER"] },
{ href: "/crewing/crew", label: "Crew", icon: Contact, roles: ["MANNING", "MANAGER", "SUPERUSER", "SITE_STAFF", "ACCOUNTS"] },
{ href: "/crewing/leave", label: "Leave", icon: CalendarDays, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/attendance", label: "Attendance", icon: CalendarCheck, roles: ["MANAGER", "SUPERUSER", "SITE_STAFF"] },
{ href: "/crewing/verification", label: "Verification", icon: BadgeCheck, roles: ["MANNING", "SUPERUSER", "ACCOUNTS"] },
] ]
: []; : [];
@ -122,15 +87,9 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
const MANAGER_ADMIN_ITEMS: NavItem[] = [ const MANAGER_ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] }, { href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] }, { href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
{ href: "/admin/terms", label: "Terms & Conditions", icon: ScrollText, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN). // Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
...(CREWING_ENABLED ...(CREWING_ENABLED
? [ ? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }]
{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] },
{ href: "/admin/crew", label: "Crew management", icon: UserCog, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
{ href: "/admin/crew-strength", label: "Crew strength", icon: Gauge, roles: ["MANAGER", "SUPERUSER", "ADMIN"] as Role[] },
]
: []), : []),
]; ];
@ -144,60 +103,14 @@ const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/companies", label: "Companies", icon: Briefcase }, { href: "/admin/companies", label: "Companies", icon: Briefcase },
]; ];
interface NavGroup {
label?: string; // optional subheading shown above the group's links
items: NavItem[];
}
interface Section {
id: string;
label: string;
groups: NavGroup[];
}
function isItemActive(href: string, pathname: string) {
return pathname === href || pathname.startsWith(href + "/");
}
export function Sidebar({ userRole }: { userRole: Role }) { export function Sidebar({ userRole }: { userRole: Role }) {
const pathname = usePathname(); const pathname = usePathname();
const isAdmin = userRole === "ADMIN"; const isAdmin = userRole === "ADMIN";
const visible = (i: NavItem) => !i.roles || i.roles.includes(userRole); const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleMain = NAV_ITEMS.filter(visible); const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visiblePurchasing = PURCHASING_ITEMS.filter(visible); const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleReports = REPORTS_PURCHASING.filter(visible); const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
const visibleCrewing = CREWING_ITEMS.filter(visible);
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter(visible);
const adminItems = isAdmin ? [...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS] : visibleMgrAdmin;
// Headed, collapsible sections (the main links above sit outside any section).
// A section holds one or more groups; a group can carry an optional subheading.
const sections: Section[] = [
{ id: "purchasing", label: "Purchasing", groups: [{ items: visiblePurchasing }] },
{ id: "reports", label: "Reports", groups: [{ label: "Purchasing", items: visibleReports }] },
{ id: "crewing", label: "Crewing", groups: [{ items: visibleCrewing }] },
{ id: "administration", label: "Administration", groups: [{ items: adminItems }] },
]
.map((s) => ({ ...s, groups: s.groups.filter((g) => g.items.length > 0) }))
.filter((s) => s.groups.length > 0);
const sectionItems = (s: Section) => s.groups.flatMap((g) => g.items);
// The section (if any) that holds the currently active route.
const activeSectionId =
sections.find((s) => sectionItems(s).some((i) => isItemActive(i.href, pathname)))?.id ?? null;
// Single-open accordion, collapsed by default. Auto-expand the section that
// contains the active route so the user is never stranded on a hidden link.
const [openSection, setOpenSection] = useState<string | null>(activeSectionId);
// On navigation, open the section holding the new active route (which, being a
// single-open accordion, collapses any other open heading).
useEffect(() => {
if (activeSectionId) setOpenSection(activeSectionId);
}, [activeSectionId]);
const toggleSection = (id: string) =>
setOpenSection((current) => (current === id ? null : id));
return ( return (
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white"> <aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
@ -213,70 +126,59 @@ export function Sidebar({ userRole }: { userRole: Role }) {
<NavLink key={item.href} item={item} pathname={pathname} /> <NavLink key={item.href} item={item} pathname={pathname} />
))} ))}
{sections.map((section) => { {visiblePurchasing.length > 0 && (
const isOpen = openSection === section.id; <>
const regionId = `nav-section-${section.id}`; <SectionHeader label="Purchasing" />
return ( {visiblePurchasing.map((item) => (
<div key={section.id}>
<SectionHeader
label={section.label}
isOpen={isOpen}
regionId={regionId}
onToggle={() => toggleSection(section.id)}
/>
{isOpen && (
<div id={regionId} className="space-y-0.5">
{section.groups.map((group, gi) => (
<div key={group.label ?? gi} className="space-y-0.5">
{group.label && (
<p className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-300">
{group.label}
</p>
)}
{group.items.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} /> <NavLink key={item.href} item={item} pathname={pathname} />
))} ))}
</div> </>
))} )}
</div>
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
{visibleCrewing.length > 0 && (
<>
<SectionHeader label="Crewing" />
{visibleCrewing.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Vendors under Administration for MANAGER / ACCOUNTS */}
{!isAdmin && visibleMgrAdmin.length > 0 && (
<>
<SectionHeader label="Administration" />
{visibleMgrAdmin.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{/* Full Administration section for ADMIN */}
{isAdmin && (
<>
<SectionHeader label="Administration" />
{[...MANAGER_ADMIN_ITEMS, ...ADMIN_ITEMS].map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)} )}
</div>
);
})}
</nav> </nav>
</aside> </aside>
); );
} }
function SectionHeader({ function SectionHeader({ label }: { label: string }) {
label,
isOpen,
regionId,
onToggle,
}: {
label: string;
isOpen: boolean;
regionId: string;
onToggle: () => void;
}) {
return ( return (
<button <div className="pt-4 pb-1 px-3">
type="button" <p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">{label}</p>
onClick={onToggle} </div>
aria-expanded={isOpen}
aria-controls={regionId}
className="flex w-full items-center justify-between pt-4 pb-1 px-3 text-xs font-semibold text-neutral-400 uppercase tracking-wider hover:text-neutral-600"
>
<span>{label}</span>
<ChevronRight
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", isOpen && "rotate-90")}
/>
</button>
); );
} }
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) { function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
const isActive = isItemActive(item.href, pathname); const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon; const Icon = item.icon;
return ( return (
<Link <Link

View file

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

View file

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

View file

@ -4,12 +4,10 @@ import { LineItemsEditor } from "@/components/po/po-line-items-editor";
import { DiscardDraftButton } from "@/components/po/discard-draft-button"; import { DiscardDraftButton } from "@/components/po/discard-draft-button";
import { SubmitDraftButton } from "@/components/po/submit-draft-button"; import { SubmitDraftButton } from "@/components/po/submit-draft-button";
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls"; import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
import { EmailVendorButton } from "@/components/po/email-vendor-button";
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils"; import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
import { generateDownloadUrl } from "@/lib/storage"; import { generateDownloadUrl } from "@/lib/storage";
import { groupAttachments } from "@/lib/attachments"; import { groupAttachments } from "@/lib/attachments";
import { TC_FIXED_LINE } from "@/lib/validations/po"; import { TC_FIXED_LINE } from "@/lib/validations/po";
import { parsePoTerms } from "@/lib/terms";
import type { LineItemInput } from "@/lib/validations/po"; import type { LineItemInput } from "@/lib/validations/po";
import type { Role } from "@prisma/client"; import type { Role } from "@prisma/client";
@ -27,7 +25,6 @@ type PoWithRelations = {
paymentRef: string | null; paymentRef: string | null;
paymentDate?: Date | null; paymentDate?: Date | null;
paidAmount?: import("@prisma/client").Prisma.Decimal | null; paidAmount?: import("@prisma/client").Prisma.Decimal | null;
suggestedAdvancePayment?: import("@prisma/client").Prisma.Decimal | null;
piQuotationNo?: string | null; piQuotationNo?: string | null;
piQuotationDate?: Date | null; piQuotationDate?: Date | null;
requisitionNo?: string | null; requisitionNo?: string | null;
@ -39,7 +36,6 @@ type PoWithRelations = {
tcTransitInsurance?: string | null; tcTransitInsurance?: string | null;
tcPaymentTerms?: string | null; tcPaymentTerms?: string | null;
tcOthers?: string | null; tcOthers?: string | null;
terms?: import("@prisma/client").Prisma.JsonValue;
createdAt: Date; createdAt: Date;
submittedAt: Date | null; submittedAt: Date | null;
approvedAt: Date | null; approvedAt: Date | null;
@ -83,8 +79,6 @@ interface Props {
currentUserId: string; currentUserId: string;
currentRole: Role; currentRole: Role;
readOnly?: boolean; readOnly?: boolean;
// Vendor's primary contact email — enables the "Email to vendor" action (issue #14).
vendorEmail?: string | null;
} }
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
@ -107,7 +101,7 @@ const ACTION_LABELS: Record<string, string> = {
SUPERSEDED: "Superseded", SUPERSEDED: "Superseded",
}; };
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false, vendorEmail = null }: Props) { export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
const lineItemsForEditor = po.lineItems.map((li) => ({ const lineItemsForEditor = po.lineItems.map((li) => ({
name: li.name, name: li.name,
description: li.description ?? undefined, description: li.description ?? undefined,
@ -233,11 +227,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
Export XLSX Export XLSX
</a> </a>
</>)} </>)}
{/* Email to vendor — approved (not cancelled) + vendor has a contact email (issue #14) */}
{!readOnly && vendorEmail &&
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (
<EmailVendorButton poId={po.id} />
)}
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */} {/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
{po.status !== "CANCELLED" && {po.status !== "CANCELLED" &&
["MANAGER", "SUPERUSER"].includes(currentRole) && ["MANAGER", "SUPERUSER"].includes(currentRole) &&
@ -301,21 +290,6 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
</div> </div>
)} )}
{/* Manager's advance-payment decision (issue #92) a partial advance set
at approval. Shown to Accounts/Manager from approval through payment. */}
{po.suggestedAdvancePayment != null &&
Number(po.suggestedAdvancePayment) < Number(po.totalAmount) &&
["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID"].includes(po.status) && (
<div className="rounded-lg border border-primary-100 bg-primary-50 px-4 py-3">
<p className="text-sm font-medium text-primary-700 mb-0.5">Advance payment requested</p>
<p className="text-sm text-primary-700">
Pay {formatCurrency(Number(po.suggestedAdvancePayment), po.currency)} first (of{" "}
{formatCurrency(Number(po.totalAmount), po.currency)}). The balance follows the usual
part-payment flow.
</p>
</div>
)}
{/* Submitter changes banner — shown to managers when PO is resubmitted after edits */} {/* Submitter changes banner — shown to managers when PO is resubmitted after edits */}
{resubmitSnapshot && {resubmitSnapshot &&
po.status === "MGR_REVIEW" && po.status === "MGR_REVIEW" &&
@ -461,41 +435,31 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
/> />
</div> </div>
{/* Terms & Conditions (issue #11): dynamic snapshot when present, else legacy tc* + fixed line. */} {/* Terms & Conditions */}
{(() => { {(po.tcDelivery || po.tcDispatch || po.tcInspection || po.tcTransitInsurance || po.tcPaymentTerms || po.tcOthers) && (
const saved = parsePoTerms(po.terms);
const rows: { label: string; text: string }[] =
saved.length > 0
? saved.map((t) => ({ label: (t.category || "").toUpperCase(), text: t.text }))
: [
{ label: "", text: TC_FIXED_LINE },
...([
["DELIVERY", po.tcDelivery],
["DISPATCH INSTRUCTIONS", po.tcDispatch],
["INSPECTION", po.tcInspection],
["TRANSIT INSURANCE", po.tcTransitInsurance],
["PAYMENT TERMS", po.tcPaymentTerms],
["OTHERS", po.tcOthers],
] as const)
.filter(([, value]) => value)
.map(([label, value]) => ({ label, text: value as string })),
];
// Only the fixed line and nothing else → treat as "no T&C" (legacy empty PO).
if (saved.length === 0 && rows.length <= 1) return null;
return (
<div className="rounded-lg border border-neutral-200 bg-white p-6"> <div className="rounded-lg border border-neutral-200 bg-white p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3> <h3 className="text-sm font-semibold text-neutral-900 mb-3">Terms &amp; Conditions</h3>
<ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}> <ol className="space-y-1.5 text-sm text-neutral-700" style={{ listStyle: "none", padding: 0 }}>
{rows.map((r, i) => ( <li className="flex gap-2">
<li key={i} className="flex gap-2"> <span className="shrink-0 font-medium text-neutral-500">1.</span>
<span className="shrink-0 font-medium text-neutral-500">{i + 1}.</span> <span>{TC_FIXED_LINE}</span>
<span>{r.label ? <span className="font-medium">{r.label}: </span> : null}{r.text}</span> </li>
{([
{ n: 2, label: "DELIVERY", value: po.tcDelivery },
{ n: 3, label: "DISPATCH INSTRUCTIONS", value: po.tcDispatch },
{ n: 4, label: "INSPECTION", value: po.tcInspection },
{ n: 5, label: "TRANSIT INSURANCE", value: po.tcTransitInsurance },
{ n: 6, label: "PAYMENT TERMS", value: po.tcPaymentTerms },
{ n: 7, label: "OTHERS", value: po.tcOthers },
] as const).filter(({ value }) => value).map(({ n, label, value }) => (
<li key={n} className="flex gap-2">
<span className="shrink-0 font-medium text-neutral-500">{n}.</span>
<span><span className="font-medium">{label}:</span> {value}</span>
</li> </li>
))} ))}
</ol> </ol>
</div> </div>
); )}
})()}
{/* Documents — grouped by lifecycle stage (submission / payment / delivery) */} {/* Documents — grouped by lifecycle stage (submission / payment / delivery) */}
{attachmentGroups.length > 0 && ( {attachmentGroups.length > 0 && (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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