34 Commits

Author SHA1 Message Date
5b9560531e fix(ui): remove PN brand mark from mobile topbar; balance title with spacer
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m59s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:55:18 +02:00
f55be14813 test(berths): CM-2 — drop unused var in price-reconcile test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:53:24 +02:00
6bc81270b9 feat(interests): CM-2 Part B — deal-price override route + UI on linked berths
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:48:38 +02:00
38e392e38b feat(interests): CM-2 Part B — EOI/doc generation honours berth price override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:41:42 +02:00
039ef25fe5 feat(interests): CM-2 Part B — interest_berths price override (data + resolver)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:40:17 +02:00
b3753b96a1 feat(berths): CM-2 — bulk price-reconcile admin page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:38:08 +02:00
9147f2857e feat(berths): CM-2 — price-reconcile API (list + bulk apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:09 +02:00
47778796ad feat(berths): CM-2 — bulk price-reconcile service (parse + apply)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:33:40 +02:00
f7425d1231 fix(berths): CM-2 — robust purchase-price extraction (clean-token + magnitude floor)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:30:12 +02:00
df8c26d1b3 feat(proxies): CM-9 UI — ProxyCard on client, interest, and yacht detail pages
- shared ProxyCard (view/add/edit/remove point-of-contact) reading each entity's
  /[id]/proxy sub-resource; permission-gated on the entity's edit right
- wired into the client overview, interest overview, and yacht overview tabs

Completes CM-9. tsc clean, lint 0 errors, prod build green, 1638 vitest pass.
Comms send-side wiring (route EOIs/emails through resolveEffectiveProxy) is a
deliberate follow-up — the resolver + data are ready for it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 00:01:08 +02:00
91703bdb00 feat(proxies): CM-9 backend — polymorphic point-of-contact + resolver
- proxies table (migration 0095, port_id cascade), one per client/interest/yacht
- service: get/set(upsert)/clear + resolveEffectiveProxy (yacht → interest →
  client precedence), port-scoped with entity-in-port guard
- per-entity sub-resource routes (/clients|interests|yachts/[id]/proxy) reusing
  each entity's existing view/edit permission (no new permission resource)
- 3 integration tests (CRUD/upsert, tenant guard, resolution precedence)

Backend only — ProxyCard UI on the 3 detail pages to follow. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:54:47 +02:00
3165ec651f feat(client-groups): CM-1 API routes + UI (list, member viewer, copy-emails)
- /api/v1/client-groups (list/create), /[id] (get/patch/delete),
  /[id]/members (get/set) — route.ts + handlers.ts split, client_groups perms
- Client Groups list page (grid + create dialog) and detail page
  (member viewer, per-row copy email, "Copy all emails" → To:-bar format,
  manage-members picker over /api/v1/clients)
- Sidebar nav entry (UsersRound icon)

tsc clean, lint 0 errors, prod build green. Completes CM-1 (Mailchimp push
still deferred until client creds/account).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:49:29 +02:00
661187cc79 feat(client-groups): CM-1 data layer — groups entity, membership, service, Mailchimp scaffold
- client_groups + client_group_members tables (migration 0094, port_id cascade)
- client_groups permission resource (view/manage) in catalog + role backfill
- service: CRUD + wipe-and-rewrite membership + member email resolution
- mailchimp.service scaffold: config reader + inert one-way sync (mapping
  deferred until the client's MC account is wired, per CM-1 decision)
- 4 integration tests (CRUD, membership, email resolution, port-scope guard)

Backend only — API routes + UI to follow. tsc clean, 1635 vitest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:28:20 +02:00
4dc0bdd8c4 feat(crm): client-meeting batch — contact-pill cleanup, assignment toggle, receipt manual mode
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 9m16s
CM-4: remove Email/Call/WhatsApp deep-link pills from the client + interest
  detail headers; relocate GDPR export into the client-header action cluster
  as a compact icon. Keeps the interest "Log contact" quick action.
CM-5: gate the interest assignment feature behind a per-port `assignment_enabled`
  setting (default OFF for single-rep ports). Hides the AssignedToChip +
  residential assigned-to row and skips tier-2/3 auto-assign on create; the
  column + data are preserved and reversible. Tests cover the auto-assign guard.
CM-6: add a per-port `manualEntry` receipt mode (skip all parsing → empty form).
  Threaded through ocr-config.service, the admin OCR form, the scan-receipt
  route, and the scanner shell (skips Tesseract + the server call). Tests cover
  the save/resolve round-trip.

Verified: tsc clean, lint 0 errors, 1631 vitest pass, prod build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:42:36 +02:00
7f04c765f4 fix(crm): inquiry detail polish, EOI preview mime, EOI next-step, documenso v1 banner
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m51s
Build & Push Docker Images / build-and-push (push) Successful in 7m43s
- inquiries: format triage badges with labels (Open/Assigned/Converted/Dismissed),
  surface the lead's free-text message for every kind, and gate the raw-payload
  tab to super admins (was exposing raw JSON to all users)
- file preview: fall back to the server-resolved mime (getPreviewUrl already
  returns it) so files whose stored name lacks a .pdf extension — e.g.
  migration-backfilled signed EOIs — render instead of "preview not supported"
- interest overview: a signed EOI left at stage=eoi no longer shows as
  "NEXT STEP"; completion ordering rolls the next step to Reservation (display
  only, no pipeline_stage change)
- documenso admin: warning banner discouraging the deprecated v1 API + what
  breaks on it

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:36:35 +02:00
4d018be800 feat(inquiries): one-off NocoDB historical contact-form import (idempotent, dry-run default)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m1s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:28:01 +02:00
95d7776bb6 test(inquiries): drop unused import 2026-06-17 18:25:13 +02:00
0cc05f302f feat(inquiries): top-level Inquiries page (list + detail + convert), nav entries; retire admin inbox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:23:13 +02:00
54554a0928 feat(inquiries): list/get/triage/convert service + API routes (find-or-create client)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:09:59 +02:00
9879b82e5f feat(inquiries): website_submissions tracking + display columns; capture populates contact name/email
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 18:03:47 +02:00
08adb4aeea feat(permissions): add inquiries resource (view/manage) + idempotent role backfill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:59:32 +02:00
6c4490f653 feat(alerts): always-visible dismiss/ack actions + Dismiss all (service, endpoint, UI)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:53:12 +02:00
13efe177a5 feat(alerts): split interest.stale into worked-then-quiet + new-untouched (interest.no_activity)
- interest.stale now fires only for interests with real in-system follow-up
  (contact log / note / update audit) that went quiet 14+ days.
- new interest.no_activity rule covers never-touched, non-imported interests.
- guard interest.high_value_silent against imported-untouched hot leads.
- keys off migration_source_links ledger to identify the bulk import, so the
  imported backlog matches neither rule and the engine auto-resolves the flood.
- test teardown: delete interest_contact_log + test migration ledger rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-17 17:49:22 +02:00
7591231c47 test(e2e): add Initiative 4 end-to-end + integration specs
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m12s
Build & Push Docker Images / build-and-push (push) Successful in 8m24s
Sales-process coverage (launch-readiness Initiative 4):
- exhaustive: full 7-stage sales journey + illegal-skip rejection + deposit
  total + tenancy/berth-sold; multi-berth EOI berth-range; EOI pathway parity
  (in-app vs Documenso, shared EoiContext); mobile-viewport journey.
- realapi (Documenso-gated, opt-in): generate-and-sign + post-EOI stages.
- integration: Documenso DOCUMENT_COMPLETED webhook idempotency (3x replay ->
  single file/audit write); storage backend swap (s3 <-> filesystem) with a
  real on-disk filesystem round-trip.
- visual: Reports UI snapshot cases (baselines captured separately).

1615 unit/integration pass; tsc + lint clean. Test-only change (specs are not
bundled into the app image) - no app behavior modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:10:35 +02:00
2e8c4b43bf fix(backup): install pg_dump (postgresql16-client) in app + worker images
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 8m54s
The DR backup engine spawns `pg_dump` (backup.service.ts), but neither
runner image installed a postgres client — so producing a bundle fails
in prod with ENOENT (only worked in dev, where the host has pg_dump).
Surfaced by testing the feature on the live prod container.

Add `postgresql16-client` (pg_dump 16.x, matched to the postgres:16
server) to the runner stage of Dockerfile (crm-app: on-demand export +
"back up now") and Dockerfile.worker (scheduled backup-push cron).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:58:14 +02:00
fe863a588e feat(backup): full DR bundle export + admin-configurable offsite destinations
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m52s
Build & Push Docker Images / build-and-push (push) Successful in 11m59s
Backend-agnostic disaster-recovery backup engine that runs on the current
storage backend (no storage cutover required):

- Full-bundle export: db.dump (pg_dump custom) + every storage blob +
  manifest.json with per-object SHA-256, streamed as a tar. Entry points:
  admin UI download, GET /api/v1/admin/backup/export, scripts/create-full-backup.ts.
- Admin-configurable push destinations (backup_destinations table, migration
  0091): SFTP/SSH, S3-compatible (reuses the minio client), and mounted
  path/NAS behind one transport interface (test/push/prune). Secrets AES-GCM
  at rest; API returns only *IsSet markers.
- Opt-in per-destination AES-256 bundle encryption (scrypt KDF, streamed) +
  scripts/decrypt-backup.ts for restore.
- Wired the previously-dead database-backup cron to runScheduledBackupPush
  (push to enabled destinations, prune to retention, alert super-admins on
  failure).

Tests: 1608 unit/integration pass; tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:23:42 +02:00
05950ae0b6 feat(uat): file preview/download fix, clients-by-country page, residential column picker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m42s
Build & Push Docker Images / build-and-push (push) Successful in 7m20s
Batch #4 UAT items.

1. Documents — clicking any file dumped raw presigned-URL JSON. Was
   systemic: 6 surfaces linked a browser directly at the JSON-returning
   /files/[id]/{download,preview} routes. Those routes now 302-redirect
   when called with ?redirect=1 (default stays JSON for the dialog +
   interest-eoi-tab programmatic consumers); the six <Link> sites use it.
   The documents-hub file row now opens the inline FilePreviewDialog +
   has a per-row Download button, and the preview dialog header gained a
   persistent Download button for all file types.

2. Clients-by-country — the widget's "+N more" dead text is now a
   "Show all" link to a new /clients/by-country page rendering the full
   ranked country breakdown (each row drills into the filtered list).

3. Residential clients list — moved off its bespoke table onto the
   shared DataTable + ColumnPicker (same UX as clients/interests). Adds
   a "Date added" column, default-hides the empty "Residence" column,
   preserves the mobile card view, persists per-user column choices.

tsc clean, eslint clean, 1584/1584 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:34:47 +02:00
eff57af571 fix(storage): make S3 server-side-encryption optional (default off)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m46s
Build & Push Docker Images / build-and-push (push) Successful in 7m53s
Prod MinIO has no KMS/KES, so the unconditional
`x-amz-server-side-encryption: AES256` header on every PutObject was
rejected with `NotImplemented` ("KMS not configured") — breaking ALL
server-side uploads on prod: avatars, the signed-PDF deposit on
Documenso completion, GDPR exports, the nightly DB backup, generated
EOI/contract PDFs, report renders. Reads/presigned downloads were
unaffected, so the cutover walkthrough missed it.

The SSE header is now sent only when explicitly configured via the
per-port `storage_s3_sse` setting (or the STORAGE_S3_SSE env fallback);
the default is off so a vanilla S3-compatible backend accepts uploads.
This also resolves the put()-encrypts-but-presignUpload-doesn't
asymmetry — presigned PUTs never sent SSE, so both paths now match by
default.

Extracted buildPutObjectMetadata() as a pure, unit-tested helper.

Interim fix; the planned filesystem-storage migration removes SSE from
the prod path entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:08:41 +02:00
1750e265e7 feat(berths): inline spec-PDF preview, manual-pin badge, maintenance module toggle, under-offer popover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m45s
Build & Push Docker Images / build-and-push (push) Successful in 8m11s
Post-cutover UAT batch #3:
- #62 Spec tab renders the current berth spec PDF inline (lazy PdfViewer,
  toggleable, default-open) + explicit download. Interest Documents tab
  already previews/downloads linked deal docs inline (verified).
- #57 Surface berths.status_override_mode through the interest-berths API;
  linked-berth rows show an amber "Pin overrides pitch" badge + corrected
  consequence copy when a berth is specifically-pitched but manually pinned
  (the soft-pin wins on the public map).
- #63 New maintenance-module gate (maintenance_module_enabled, default on):
  registry + admin Settings toggle, maintenance-module.service, port-provider
  useMaintenanceModuleEnabled, layout wiring, buildBerthTabs hides the
  Maintenance tab when off, and both maintenance log routes assert the gate.
- #66 BerthOccupancyChip: >1 competing interest opens a popover listing every
  deal (name + stage + in-EOI/primary + link); single stays a direct link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:15:04 +02:00
2a7f922a01 fix(uat): dashboard snapshots current-state, pulse-chip gate, phone display, chip width
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m54s
Build & Push Docker Images / build-and-push (push) Successful in 8m10s
- pipeline funnel: count active interests by current stage (drop created_at
  window) — backfill had collapsed it to early stages (UAT 2026-06-03)
- pipeline value tile: render current-state (don't thread the date range)
- deal pulse chip: gate on the pulse_enabled master toggle (default ON) —
  was rendering even when admin turned it off; useFeatureFlag gains a
  default arg + the feature-flag endpoint a ?default= param (default-ON safe)
- contact phone display: show international format + country flag (E164),
  not the bare national format that hid the country
- berths: remove the dead row-density toggle; widen "Under offer to" chip on
  desktop so client names aren't truncated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:27:56 +02:00
39c19b2340 feat(berths): click-to-change status from the list (chip → reason modal)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m11s
Build & Push Docker Images / build-and-push (push) Successful in 13m12s
Adds BerthStatusQuickEdit — wraps the status chip on the berths list (card +
table) in a click target that opens a compact change-status dialog: status
dropdown + required reason (quick-pick chips) + optional interest link when
moving to under_offer/sold. Reuses the existing PATCH /api/v1/berths/[id]/status
endpoint + validator + audit (same capability the detail page already had).
Gated by berths.edit (non-editors see a plain chip); stops click propagation
so it doesn't also navigate into the berth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:01:40 +02:00
d1f6d6a427 feat(eoi): signed-EOI hero + send-signed-copy; fix search dropdown z-order
- EOI tab: when an EOI is already signed and none is in flight, lead with a
  SignedEoiCard (preview + download + send-to-client) instead of the big
  "Generate EOI" empty state; quiet "Generate new EOI" remains for re-issue
- history rows + hero gain a "Send to client" action — POST
  /api/v1/documents/[id]/send-signed-copy emails the deal's client the
  finalized signed PDF (sendSignedCopyToClient reuses sendSigningCompleted),
  guarded by a confirm
- topbar: header gets z-30 so the global search dropdown paints above page
  content (charts/tables were bleeding through — header + main are sibling
  normal-flow boxes, so the dropdown's own z-50 couldn't win cross-context).
  Stays below the z-50 modal tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:55:28 +02:00
3b227fe9b2 feat(files): in-app .docx preview + allow office/text mimes
- .docx now renders client-side via docx-preview (fetches bytes from our
  own storage; works with private MinIO/disk). Drops Microsoft's hosted
  Office viewer which can't reach a private object store.
- add office (.docx/.doc/.xlsx/.xls) + text/csv to PREVIEWABLE_MIMES so
  /api/v1/files/[id]/preview returns a URL instead of rejecting them
  (was surfacing as a misleading "Failed to load preview")
- legacy .doc + spreadsheets fall through to a download CTA (can't render
  client-side); text/csv use the existing TextPreview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:45:11 +02:00
95724c8e3a fix(uat): prod UAT batch — reports, sidebar, search, berths, breakpoint
- financial report: drop Expenses KPI, Net Contribution, cash-flow chart,
  expense donut + ledger (expenses are business-trip costs, not net contribution)
- dashboard report PDF: pagination-safe tables (TableSection + per-row wrap)
  so long doc lists no longer overlap/crush
- clients PDF report: rename "Nationality" -> "Country"
- sidebar: hide a section header when all its items gate off (FINANCIAL orphan)
- topbar: move global search into the 1fr grid track so it can't overlap "New"
- clients card: show all linked berths (not just latest interest's primary)
- berths list: hide table-only toggles (ft/m, density, columns) in card mode
- lists: lower table/card breakpoint lg -> md so narrow desktops get tables
- alert-rules: stale floor created_at -> updated_at (survives created_at backfill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:41:31 +02:00
204 changed files with 11661 additions and 1021 deletions

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ tmp/
# Internal docs + Claude instructions: kept local-only, not in the shared repo
docs/
/CLAUDE.md
# Client-facing feature screenshots (real PII — do not commit)
docs/feature-screenshots/

View File

@@ -59,6 +59,9 @@ RUN apk add --no-cache --virtual .merge-deps rsync \
&& rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
&& rm -rf /opt/prod-node-modules \
&& apk del .merge-deps
# pg_dump for the backup/DR bundle engine (src/lib/services/backup.service.ts
# spawns `pg_dump`). Version pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \

View File

@@ -26,6 +26,9 @@ FROM node:20-alpine AS runner
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app
# pg_dump for the scheduled backup-push cron (maintenance worker runs
# runScheduledBackupPush → pg_dump). Pinned to match the postgres:16 server.
RUN apk add --no-cache postgresql16-client
RUN chown -R worker:nodejs /app
USER worker
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./

View File

@@ -80,6 +80,7 @@
"country-flag-icons": "^1.6.17",
"cron-parser": "^5.5.0",
"date-fns": "^4.1.0",
"docx-preview": "^0.3.7",
"drizzle-orm": "^0.45.2",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
@@ -125,6 +126,7 @@
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.7",
"ssh2-sftp-client": "^12.1.1",
"svgo": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tesseract.js": "^7.0.0",
@@ -153,6 +155,7 @@
"@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6",

121
pnpm-lock.yaml generated
View File

@@ -157,6 +157,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
docx-preview:
specifier: ^0.3.7
version: 0.3.7
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9)
@@ -292,6 +295,9 @@ importers:
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
ssh2-sftp-client:
specifier: ^12.1.1
version: 12.1.1
svgo:
specifier: ^4.0.1
version: 4.0.1
@@ -371,6 +377,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14)
'@types/ssh2-sftp-client':
specifier: ^9.0.6
version: 9.0.6
'@types/topojson-client':
specifier: ^3.1.5
version: 3.1.5
@@ -3092,6 +3101,9 @@ packages:
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -3124,6 +3136,12 @@ packages:
'@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==}
'@types/ssh2-sftp-client@9.0.6':
resolution: {integrity: sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -3579,6 +3597,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -3691,6 +3712,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-auth@1.6.11:
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
peerDependencies:
@@ -3853,6 +3877,10 @@ packages:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bullmq@5.76.8:
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
engines: {node: '>=12.22.0'}
@@ -3992,6 +4020,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
conf@15.1.0:
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
engines: {node: '>=20'}
@@ -4020,6 +4052,10 @@ packages:
country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@@ -4226,6 +4262,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
docx-preview@0.3.7:
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
@@ -5717,6 +5756,9 @@ packages:
msgpackr@2.0.1:
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6623,6 +6665,14 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssh2-sftp-client@12.1.1:
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
engines: {node: '>=18.20.4'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -6960,6 +7010,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -6988,6 +7041,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript-eslint@8.59.3:
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7015,6 +7071,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -9984,6 +10043,10 @@ snapshots:
'@types/node@14.18.63': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.41':
dependencies:
undici-types: 6.21.0
@@ -10024,6 +10087,14 @@ snapshots:
dependencies:
'@types/node': 20.19.41
'@types/ssh2-sftp-client@9.0.6':
dependencies:
'@types/ssh2': 1.15.5
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@types/tedious@4.0.14':
dependencies:
'@types/node': 20.19.41
@@ -10576,6 +10647,10 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
@@ -10657,6 +10732,10 @@ snapshots:
baseline-browser-mapping@2.10.29: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
better-auth@1.6.11(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9))(mongodb@7.1.0(socks@2.8.8))(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.6):
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
@@ -10797,6 +10876,9 @@ snapshots:
buffers@0.1.1: {}
buildcheck@0.0.7:
optional: true
bullmq@5.76.8:
dependencies:
cron-parser: 4.9.0
@@ -10930,6 +11012,13 @@ snapshots:
concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
conf@15.1.0:
dependencies:
ajv: 8.20.0
@@ -10965,6 +11054,12 @@ snapshots:
country-flag-icons@1.6.17: {}
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.27.0
optional: true
crc-32@1.2.2: {}
crc32-stream@4.0.3:
@@ -11146,6 +11241,10 @@ snapshots:
dependencies:
esutils: 2.0.3
docx-preview@0.3.7:
dependencies:
jszip: 3.10.1
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
@@ -12726,6 +12825,9 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
nan@2.27.0:
optional: true
nanoid@3.3.12: {}
nanostores@1.3.0: {}
@@ -13755,6 +13857,19 @@ snapshots:
split2@4.2.0: {}
ssh2-sftp-client@12.1.1:
dependencies:
concat-stream: 2.0.0
ssh2: 1.17.0
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.27.0
stable-hash@0.0.5: {}
stackback@0.0.2: {}
@@ -14093,6 +14208,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -14136,6 +14253,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typedarray@0.0.6: {}
typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)
@@ -14162,6 +14281,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici@7.25.0: {}

View File

@@ -0,0 +1,48 @@
/**
* Produce a full disaster-recovery bundle (db.dump + every blob + manifest.json)
* to a local file. Same code path as the admin "Download full backup" button
* (`createFullBackupTar`), minus the HTTP layer — for headless/ops use and for
* rehearsing the restore runbook (docs/backup-restore-runbook.md).
*
* pnpm tsx scripts/create-full-backup.ts [outfile.tar]
*
* Defaults the output name to ./pn-crm-backup-<timestamp>.tar in the CWD.
*/
import 'dotenv/config';
import { copyFile } from 'node:fs/promises';
import path from 'node:path';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
import { logger } from '@/lib/logger';
async function main(): Promise<void> {
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
try {
const dest = path.resolve(process.argv[2] ?? filename);
await copyFile(tarPath, dest);
logger.info(
{
dest,
storageBackend: manifest.storageBackend,
dbDumpBytes: manifest.database.sizeBytes,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
},
'Full backup written',
);
if (manifest.skipped.length) {
logger.warn({ skipped: manifest.skipped }, 'Some referenced blobs were missing in storage');
}
} finally {
await cleanup();
}
}
main()
.then(() => process.stdout.write('', () => process.exit(0)))
.catch((err) => {
logger.error({ err }, 'Full backup failed');
process.stderr.write('', () => process.exit(1));
});

31
scripts/decrypt-backup.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Decrypt an encrypted backup bundle (`*.tar.enc`) produced when a destination
* has bundle encryption enabled. Restore step — see
* docs/backup-restore-runbook.md.
*
* BACKUP_PASSPHRASE='…' pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>
*
* The passphrase is read from $BACKUP_PASSPHRASE (not argv, to keep it out of
* shell history / the process list).
*/
import { decryptFileToFile } from '@/lib/services/backup-destinations/bundle-encryption';
async function main(): Promise<void> {
const [input, output] = process.argv.slice(2);
const passphrase = process.env.BACKUP_PASSPHRASE;
if (!input || !output) {
throw new Error(
'Usage: BACKUP_PASSPHRASE=… pnpm tsx scripts/decrypt-backup.ts <in.tar.enc> <out.tar>',
);
}
if (!passphrase) throw new Error('Set BACKUP_PASSPHRASE in the environment');
await decryptFileToFile(input, output, passphrase);
process.stdout.write(`Decrypted → ${output}\n`, () => process.exit(0));
}
main().catch((err) => {
process.stderr.write(
`Decrypt failed: ${err instanceof Error ? err.message : String(err)}\n`,
() => process.exit(1),
);
});

View File

@@ -0,0 +1,176 @@
/**
* One-off import of historical "Website Contact Form Submissions" from NocoDB
* into the CRM `website_submissions` table, so they show up in the Inquiries
* workbench alongside post-cutover submissions.
*
* The cutover migration imported interests / residential / berths / expenses but
* NOT the contact-form table — those general contact-page inquiries (the
* "broker"/"investor"/"owner" enquiries) were left behind in NocoDB.
*
* Idempotent: each row maps to a deterministic `submission_id`
* (`nocodb-cf-<id>`) guarded by the unique index, plus a `migration_source_links`
* ledger row (`source_system='nocodb_website_submissions'`). Re-running is a
* no-op for already-imported rows.
*
* Usage:
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts # dry-run
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply # write
* pnpm tsx scripts/import-website-inquiries-from-nocodb.ts --apply --port-slug port-nimara
*
* Requires NOCODB_URL + NOCODB_TOKEN in env (same as the migration). Writes to
* whatever DATABASE_URL points at — point it at prod ONLY with explicit approval.
*/
import 'dotenv/config';
import { eq } from 'drizzle-orm';
import { db, closeDb } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import {
loadNocoDbConfig,
fetchAllRows,
NOCO_TABLES,
type NocoDbRow,
} from '@/lib/dedup/nocodb-source';
const SOURCE_SYSTEM = 'nocodb_website_submissions';
const APPLIED_ID = 'import-website-inquiries';
function arg(name: string): string | undefined {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
if (hit) return hit.split('=')[1];
const idx = process.argv.indexOf(`--${name}`);
if (idx !== -1 && process.argv[idx + 1] && !process.argv[idx + 1]!.startsWith('--')) {
return process.argv[idx + 1];
}
return undefined;
}
function str(row: NocoDbRow, ...keys: string[]): string {
for (const k of keys) {
const v = row[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
}
function parseDate(row: NocoDbRow): Date {
const raw = str(row, 'CreatedAt', 'created_at', 'Created At', 'createdAt');
if (raw) {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) return d;
}
return new Date();
}
async function main() {
const apply = process.argv.includes('--apply');
const portSlug = arg('port-slug') ?? 'port-nimara';
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, portSlug))
.limit(1);
if (!port) throw new Error(`Unknown port slug: ${portSlug}`);
const config = loadNocoDbConfig();
console.log(`[import] Fetching contact-form submissions from NocoDB…`);
const rows = await fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config);
console.log(`[import] Fetched ${rows.length} rows from NocoDB.`);
let inserted = 0;
let skipped = 0;
const samples: Array<Record<string, unknown>> = [];
for (const row of rows) {
const legacyId = String(row.Id);
const submissionId = `nocodb-cf-${legacyId}`;
const fullName = str(row, 'Full Name', 'Name', 'full_name');
const email = str(row, 'Email Address', 'Email', 'email');
const interest = str(row, 'Type of Interest', 'interest');
const comments = str(row, 'Comments', 'comments');
const receivedAt = parseDate(row);
const payload = {
name: fullName,
email,
interest,
comments,
imported_from: 'nocodb_contact_form',
legacy_nocodb_id: legacyId,
};
if (samples.length < 3) {
samples.push({
submissionId,
fullName,
email,
interest,
receivedAt: receivedAt.toISOString(),
});
}
if (!apply) {
// Dry-run: count how many are not yet present.
const [existing] = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, submissionId))
.limit(1);
if (existing) skipped += 1;
else inserted += 1;
continue;
}
const result = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId,
kind: 'contact_form',
payload,
contactName: fullName || null,
contactEmail: email || null,
legacyNocodbId: legacyId,
receivedAt,
triageState: 'open',
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (result[0]) {
inserted += 1;
await db
.insert(migrationSourceLinks)
.values({
sourceSystem: SOURCE_SYSTEM,
sourceId: legacyId,
targetEntityType: 'website_submission',
targetEntityId: result[0].id,
appliedId: APPLIED_ID,
})
.onConflictDoNothing();
} else {
skipped += 1;
}
}
console.log('\n[import] Sample rows:');
for (const s of samples) console.log(' ', JSON.stringify(s));
console.log(
`\n[import] ${apply ? 'APPLIED' : 'DRY-RUN'} — port=${portSlug}: ${inserted} ${
apply ? 'inserted' : 'would insert'
}, ${skipped} skipped (already present).`,
);
if (!apply) console.log('[import] Re-run with --apply to write these rows.');
await closeDb();
}
main().catch((err) => {
console.error('[import] FAILED:', err);
process.exitCode = 1;
});

View File

@@ -1,4 +1,5 @@
import { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader
title="Backup & Restore"
eyebrow="ADMIN"
description="Trigger ad-hoc database snapshots, browse the history, and download a .dump file for offline restore."
description="Download a full backup, configure where automated backups are pushed, and browse history. Restore steps live in docs/backup-restore-runbook.md."
/>
<BackupAdminPanel />
<BackupDestinationsCard />
</div>
);
}

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import type { Route } from 'next';
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
import { AlertCircle, Anchor, FileSearch, BadgeDollarSign } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -33,6 +33,13 @@ export default async function BerthsAdminIndex({
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch,
},
{
href: `/${portSlug}/admin/berths/price-reconcile` as Route,
label: 'Price reconciliation',
description:
'Parse the purchase price from each berths current spec sheet and review old→new per berth. Approve per row or in bulk; nothing is written until you approve.',
icon: BadgeDollarSign,
},
] as const;
return (

View File

@@ -0,0 +1,15 @@
import { PageHeader } from '@/components/shared/page-header';
import { BerthPriceReconcileTable } from '@/components/berths/berth-price-reconcile-table';
export default function BerthPriceReconcilePage() {
return (
<div className="space-y-6">
<PageHeader
title="Berth price reconciliation"
eyebrow="ADMIN"
description="Prices parsed from each berth's current spec sheet, shown against the stored price. Review the changes and approve the ones you trust — nothing is written until you approve it."
/>
<BerthPriceReconcileTable />
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
import { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { WarningCallout } from '@/components/ui/warning-callout';
// All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
@@ -22,6 +23,35 @@ export default function DocumensoSettingsPage() {
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
/>
<WarningCallout title="Use Documenso v2, not v1 (v1 API is deprecated)">
<p>
The CRM&apos;s signing features are built for Documenso 2.x (v2). Set the API version
below to <strong>v1</strong> only if this port still points at a Documenso 1.13.x server.
Be aware these CRM functions <strong>do not work (or run degraded)</strong> on v1:
</p>
<ul className="ms-4 mt-1 list-disc space-y-1">
<li>
<strong>Editing an envelope after it is created</strong> (title, subject, redirect URL):
hard-fails, because v1 has no <code>/envelope/update</code> endpoint.
</li>
<li>
<strong>Upload-and-send contracts / reservations</strong> fall back to v1&apos;s
per-field placement: page size is assumed to be A4, and rich field metadata (required
flags, NUMBER min/max, CHECKBOX / DROPDOWN / RADIO option lists) is dropped.
</li>
<li>
<strong>One-call send with per-recipient signing links</strong>,{' '}
<strong>sequential signing enforcement</strong>, and the{' '}
<strong>v2 webhook events</strong> (recipient viewed / signed, declined, reminder sent)
are unavailable or ignored on v1.
</li>
</ul>
<p className="mt-1">
Recommended: upgrade the Documenso server to 2.x, then set the API version to v2 and run
the test-connection button to confirm.
</p>
</WarningCallout>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">

View File

@@ -1,5 +1,15 @@
import { InquiryInbox } from '@/components/admin/inquiry-inbox';
import { redirect } from 'next/navigation';
export default function InquiriesPage() {
return <InquiryInbox />;
/**
* The inquiry inbox is now a top-level, permission-gated page at
* `/[portSlug]/inquiries` (resource `inquiries`), no longer admin-only.
* Redirect the legacy admin URL so old bookmarks/links still land.
*/
interface AdminInquiriesRedirectProps {
params: Promise<{ portSlug: string }>;
}
export default async function AdminInquiriesRedirect({ params }: AdminInquiriesRedirectProps) {
const { portSlug } = await params;
redirect(`/${portSlug}/inquiries`);
}

View File

@@ -0,0 +1,10 @@
import { ClientGroupDetail } from '@/components/client-groups/client-group-detail';
export default async function ClientGroupDetailPage({
params,
}: {
params: Promise<{ portSlug: string; groupId: string }>;
}) {
const { groupId } = await params;
return <ClientGroupDetail groupId={groupId} />;
}

View File

@@ -0,0 +1,5 @@
import { ClientGroupsList } from '@/components/client-groups/client-groups-list';
export default function ClientGroupsPage() {
return <ClientGroupsList />;
}

View File

@@ -0,0 +1,5 @@
import { ClientsByCountryPage } from '@/components/clients/clients-by-country-page';
export default function ClientsByCountryRoute() {
return <ClientsByCountryPage />;
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-64 w-full" />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { InquiryDetail } from '@/components/inquiries/inquiry-detail';
interface InquiryDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function InquiryDetailPage({ params }: InquiryDetailPageProps) {
const { id } = await params;
return <InquiryDetail id={id} />;
}

View File

@@ -0,0 +1,5 @@
import { InquiryList } from '@/components/inquiries/inquiry-list';
export default function InquiriesPage() {
return <InquiryList />;
}

View File

@@ -20,6 +20,7 @@ import { getPortBrandingConfig } from '@/lib/services/port-config';
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-module.service';
import { isMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers();
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries);
// Per-port maintenance-module gate. Defaults to enabled (registry
// default) so existing ports keep the berth Maintenance tab on deploy.
// Resolved server-side so the tab SSRs in/out without flicker.
const maintenanceModuleEntries = await Promise.all(
ports.map(async (p) => {
try {
return [p.id, await isMaintenanceModuleEnabled(p.id)] as const;
} catch {
// Conservative default on lookup failure: keep the feature visible.
return [p.id, true] as const;
}
}),
);
const maintenanceModuleByPort: Record<string, boolean> =
Object.fromEntries(maintenanceModuleEntries);
return (
<QueryProvider>
<PortProvider
ports={ports}
defaultPortId={ports[0]?.id ?? null}
tenanciesModuleByPort={tenanciesModuleByPort}
maintenanceModuleByPort={maintenanceModuleByPort}
>
<PermissionsProvider>
<SocketProvider>

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = {
title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null;
return <ScanShell logoUrl={branding?.logoUrl ?? null} portName={port?.name ?? null} />;
// CM-6: manual-entry mode is resolved server-side so the client can skip
// on-device parsing entirely (no wasted Tesseract pass) and open an empty form.
const ocr = port ? await getResolvedOcrConfig(port.id).catch(() => null) : null;
return (
<ScanShell
logoUrl={branding?.logoUrl ?? null}
portName={port?.name ?? null}
manualEntry={ocr?.manualEntry ?? false}
/>
);
}

View File

@@ -25,6 +25,7 @@ import {
autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
/**
* POST /api/public/website-inquiries
@@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
// Extract contact name/email into real columns so the inquiry list can
// search/sort/display without digging into the JSONB payload per row.
const fields = extractInquiryFields(parsed.payload);
const insertResult = await db
.insert(websiteSubmissions)
.values({
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
contactName: fields.fullName || null,
contactEmail: fields.email || null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? null,

View File

@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
deleteDestination,
updateDestination,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Update a backup destination. Super-admin only. */
export const PUT = withAuth(async (req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.update');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const body = await parseBody(req, backupDestinationSchema);
const updated = await updateDestination(id, body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { name: updated.name, type: updated.type, enabled: updated.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
});
/** Delete a backup destination. Super-admin only. */
export const DELETE = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.delete');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await deleteDestination(id);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'delete',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { pushBackupToDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
// A full backup (pg_dump + every blob) is assembled before the push, so allow
// a long run on large datasets.
export const maxDuration = 3600;
/** Assemble a fresh full backup and push it to this destination now. Super-admin. */
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.run');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
const result = await pushBackupToDestination(id, {
trigger: 'manual',
triggeredBy: ctx.userId,
});
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'backup_destination',
entityId: id,
severity: 'warning',
metadata: { bytes: result.bytes, remoteRef: result.remoteRef },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { testDestination } from '@/lib/services/backup-destinations.service';
export const runtime = 'nodejs';
/**
* Test connectivity to a destination (connect + verify the target dir/bucket).
* Returns `{ data: { ok: true } }` or a structured error the UI can surface.
* Super-admin only.
*/
export const POST = withAuth(async (_req, ctx, params) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.test');
const id = params.id;
if (!id) throw new NotFoundError('Backup destination');
await testDestination(id);
return NextResponse.json({ data: { ok: true } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import {
createDestination,
listDestinations,
type DestinationInput,
} from '@/lib/services/backup-destinations.service';
import { backupDestinationSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** List configured backup destinations (secrets masked). Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.list');
return NextResponse.json({ data: await listDestinations() });
} catch (error) {
return errorResponse(error);
}
});
/** Create a backup destination. Super-admin only. */
export const POST = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.destinations.create');
const body = await parseBody(req, backupDestinationSchema);
const created = await createDestination(body as DestinationInput);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'create',
entityType: 'backup_destination',
entityId: created.id,
severity: 'warning',
metadata: { name: created.name, type: created.type, enabled: created.enabled },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: created }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,71 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { createFullBackupTar } from '@/lib/services/backup-export.service';
export const runtime = 'nodejs';
// A full backup pg_dumps the DB and streams every blob; on a large dataset the
// assembly phase can run for a while before the download starts. Lift the
// platform timeout accordingly (no-op on hosts without a hard cap).
export const maxDuration = 3600;
/**
* Stream a full disaster-recovery bundle (db.dump + all blobs + manifest.json)
* as a tar download. Super-admin only — this egresses every tenant's data.
*
* The bundle is assembled to a temp file first, so any failure (pg_dump,
* storage read) surfaces as a clean JSON error *before* the download begins
* rather than as a truncated tar. The temp file is removed once the response
* stream closes (including on client disconnect).
*/
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.export');
const { tarPath, filename, manifest, cleanup } = await createFullBackupTar();
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'backup_export',
entityType: 'system_backup',
entityId: filename,
severity: 'warning',
source: 'user',
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
metadata: {
storageBackend: manifest.storageBackend,
blobs: manifest.counts.blobs,
blobBytes: manifest.counts.blobBytes,
skipped: manifest.counts.skipped,
dbDumpBytes: manifest.database.sizeBytes,
},
});
const { size } = await stat(tarPath);
const nodeStream = createReadStream(tarPath);
// Remove the temp tar once it's been fully sent or the client bails.
nodeStream.on('close', () => void cleanup());
nodeStream.on('error', () => void cleanup());
const webStream = Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
status: 200,
headers: {
'Content-Type': 'application/x-tar',
'Content-Length': String(size),
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-store',
},
});
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { requireSuperAdmin, withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { createAuditLog } from '@/lib/audit';
import { errorResponse } from '@/lib/errors';
import { getSchedule, setSchedule } from '@/lib/services/backup-destinations.service';
import { backupScheduleSchema } from '@/lib/validators/backup-destinations';
export const runtime = 'nodejs';
/** Read the global automated-backup schedule. Super-admin only. */
export const GET = withAuth(async (_req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.get');
return NextResponse.json({ data: { schedule: await getSchedule() } });
} catch (error) {
return errorResponse(error);
}
});
/** Set the global automated-backup schedule (off | daily | weekly). Super-admin. */
export const PUT = withAuth(async (req, ctx) => {
try {
requireSuperAdmin(ctx, 'admin.backup.schedule.set');
const { schedule } = await parseBody(req, backupScheduleSchema);
await setSchedule(schedule, ctx.userId);
await createAuditLog({
userId: ctx.userId,
portId: ctx.portId,
action: 'update',
entityType: 'backup_schedule',
entityId: 'global',
severity: 'warning',
metadata: { schedule },
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: { schedule } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -15,6 +15,7 @@ const saveSchema = z.object({
clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(),
manualEntry: z.boolean().optional(),
});
// Only role tiers that hold `admin.manage_settings` (director / super_admin)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
},
ctx.userId,
);

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { ALERT_RULES } from '@/lib/db/schema/insights';
import { dismissAllForPort } from '@/lib/services/alerts.service';
const bodySchema = z.object({
ruleId: z.enum(ALERT_RULES).optional(),
severity: z.enum(['info', 'warning', 'critical']).optional(),
});
export const POST = withAuth(async (req, ctx) => {
try {
const { ruleId, severity } = await parseBody(req, bodySchema);
const dismissed = await dismissAllForPort(ctx.portId, ctx.userId, { ruleId, severity });
return NextResponse.json({ data: { dismissed } });
} catch (error) {
return errorResponse(error);
}
});

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/maintenance/[logId]
export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, updateMaintenanceLogSchema);
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
userId: ctx.userId,
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => {
try {
await assertMaintenanceModuleEnabled(ctx.portId);
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,

View File

@@ -4,12 +4,14 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { addMaintenanceLogSchema } from '@/lib/validators/berths';
import { getMaintenanceLogs, addMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/[id]/maintenance
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx, params) => {
try {
await assertMaintenanceModuleEnabled(ctx.portId);
const logs = await getMaintenanceLogs(params.id!, ctx.portId);
return NextResponse.json({ data: logs });
} catch (error) {
@@ -22,6 +24,7 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, addMaintenanceLogSchema);
const log = await addMaintenanceLog(params.id!, ctx.portId, body, {
userId: ctx.userId,

View File

@@ -0,0 +1,36 @@
/**
* Route handler for `/api/v1/berths/price-reconcile/apply` (CM-2 Part A).
*
* Writes a rep-approved slice of parsed prices to the berths. In handlers.ts so
* integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { applyBulkBerthPrices } from '@/lib/services/berth-price-reconcile.service';
const bodySchema = z.object({
approvals: z
.array(
z.object({
berthId: z.string().min(1),
price: z.number().nonnegative(),
currency: z.string().min(1).max(8),
}),
)
.min(1),
});
export const postHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, bodySchema);
const result = await applyBulkBerthPrices(ctx.portId, body.approvals, ctx.userId);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { postHandler } from './handlers';
export const POST = withAuth(withPermission('berths', 'edit', postHandler));

View File

@@ -0,0 +1,21 @@
/**
* Route handlers for `/api/v1/berths/price-reconcile` (CM-2 Part A).
*
* In handlers.ts so integration tests can call them directly, bypassing the
* auth/permission middleware (per CLAUDE.md "Route handler exports").
*/
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { listPriceReconciliation } from '@/lib/services/berth-price-reconcile.service';
export const getHandler: RouteHandler = async (_req, ctx) => {
try {
const data = await listPriceReconciliation(ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler } from './handlers';
export const GET = withAuth(withPermission('berths', 'edit', getHandler));

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import {
archiveClientGroup,
getClientGroupById,
updateClientGroup,
} from '@/lib/services/client-groups.service';
import { updateClientGroupSchema } from '@/lib/validators/client-groups';
export const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const group = await getClientGroupById(params.id!, ctx.portId);
return NextResponse.json({ data: group });
} catch (error) {
return errorResponse(error);
}
};
export const patchHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, updateClientGroupSchema);
const updated = await updateClientGroup(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: updated });
} catch (error) {
return errorResponse(error);
}
};
export const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await archiveClientGroup(params.id!, ctx.portId, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listGroupMembers, setGroupMembers } from '@/lib/services/client-groups.service';
import { setGroupMembersSchema } from '@/lib/validators/client-groups';
export const getMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const members = await listGroupMembers(params.id!, ctx.portId);
return NextResponse.json({ data: members, total: members.length });
} catch (error) {
return errorResponse(error);
}
};
export const putMembersHandler: RouteHandler = async (req, ctx, params) => {
try {
const { clientIds } = await parseBody(req, setGroupMembersSchema);
await setGroupMembers(params.id!, ctx.portId, clientIds, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getMembersHandler, putMembersHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getMembersHandler));
export const PUT = withAuth(withPermission('client_groups', 'manage', putMembersHandler));

View File

@@ -0,0 +1,7 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', getHandler));
export const PATCH = withAuth(withPermission('client_groups', 'manage', patchHandler));
export const DELETE = withAuth(withPermission('client_groups', 'manage', deleteHandler));

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { createClientGroup, listClientGroups } from '@/lib/services/client-groups.service';
import { createClientGroupSchema } from '@/lib/validators/client-groups';
export const listHandler: RouteHandler = async (req, ctx) => {
try {
const groups = await listClientGroups(ctx.portId);
return NextResponse.json({ data: groups, total: groups.length });
} catch (error) {
return errorResponse(error);
}
};
export const createHandler: RouteHandler = async (req, ctx) => {
try {
const body = await parseBody(req, createClientGroupSchema);
const group = await createClientGroup(ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: group }, { status: 201 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,6 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listHandler, createHandler } from './handlers';
export const GET = withAuth(withPermission('client_groups', 'view', listHandler));
export const POST = withAuth(withPermission('client_groups', 'manage', createHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('client');
export const GET = withAuth(withPermission('clients', 'view', getHandler));
export const PUT = withAuth(withPermission('clients', 'edit', putHandler));
export const DELETE = withAuth(withPermission('clients', 'edit', deleteHandler));

View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { sendSignedCopyToClient } from '@/lib/services/documents.service';
/**
* Manually (re)send the finalized signed PDF to the deal's client. Backs
* the "Send signed copy to client" affordance on the EOI tab + document
* detail. Same `documents.edit` gate as the reminder endpoint.
*/
export const POST = withAuth(
withPermission('documents', 'edit', async (_req, ctx, params) => {
try {
const result = await sendSignedCopyToClient(params.id!, ctx.portId);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -48,6 +48,14 @@ export const POST = withAuth(
}
const config = await getResolvedOcrConfig(ctx.portId);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its

View File

@@ -8,6 +8,13 @@ export const GET = withAuth(
withPermission('files', 'view', async (req, ctx, params) => {
try {
const result = await getDownloadUrl(params.id!, ctx.portId);
// `?redirect=1` → 302 straight to the presigned (attachment) URL so a
// plain <a href>/<Link> downloads the file. Without it we return the
// JSON envelope for programmatic consumers (e.g. fetch + anchor click).
// Linking the browser at the JSON form used to dump raw `{data:{url}}`.
if (new URL(req.url).searchParams.has('redirect')) {
return NextResponse.redirect(result.url, 302);
}
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);

View File

@@ -8,6 +8,12 @@ export const GET = withAuth(
withPermission('files', 'view', async (req, ctx, params) => {
try {
const result = await getPreviewUrl(params.id!, ctx.portId);
// `?redirect=1` → 302 to the presigned (inline) URL so a plain
// <a href>/<Link> opens the file in the browser. Default returns the
// JSON envelope for programmatic consumers (e.g. FilePreviewDialog).
if (new URL(req.url).searchParams.has('redirect')) {
return NextResponse.redirect(result.url, 302);
}
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);

View File

@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { convertInquiryToClient, convertInquiryToInterest } from '@/lib/services/inquiries.service';
import { convertInquirySchema } from '@/lib/validators/inquiries';
export const POST = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { target } = await parseBody(req, convertInquirySchema);
const meta = {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
};
const data =
target === 'interest'
? await convertInquiryToInterest(id, ctx.portId, meta)
: await convertInquiryToClient(id, ctx.portId, meta);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { getInquiryById } from '@/lib/services/inquiries.service';
export const GET = withAuth(
withPermission('inquiries', 'view', async (_req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const data = await getInquiryById(id, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse, ValidationError } from '@/lib/errors';
import { triageInquiry } from '@/lib/services/inquiries.service';
import { triageInquirySchema } from '@/lib/validators/inquiries';
export const PATCH = withAuth(
withPermission('inquiries', 'manage', async (req, ctx, params) => {
try {
const id = params.id;
if (!id) throw new ValidationError('id is required');
const { state } = await parseBody(req, triageInquirySchema);
const data = await triageInquiry(id, ctx.portId, state, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseQuery } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { listInquiries } from '@/lib/services/inquiries.service';
import { listInquiriesSchema } from '@/lib/validators/inquiries';
export const GET = withAuth(
withPermission('inquiries', 'view', async (req, ctx) => {
try {
const query = parseQuery(req, listInquiriesSchema);
const result = await listInquiries(ctx.portId, query);
const { page, limit } = query;
const totalPages = Math.ceil(result.total / limit);
return NextResponse.json({
data: result.data,
pagination: {
page,
pageSize: limit,
total: result.total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
});
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,39 @@
/**
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
*
* Sets or clears the deal-specific price override for one (interest, berth).
* In handlers.ts so integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
const bodySchema = z.object({
price: z.number().nonnegative().nullable(),
currency: z.string().min(1).max(8).optional(),
});
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
req,
ctx,
params,
) => {
try {
const body = await parseBody(req, bodySchema);
await setBerthPriceOverride(
params.id!,
params.berthId!,
body.price,
body.currency ?? null,
ctx.portId,
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { putHandler } from './handlers';
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('interest');
export const GET = withAuth(withPermission('interests', 'view', getHandler));
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
export const DELETE = withAuth(withPermission('interests', 'edit', deleteHandler));

View File

@@ -15,7 +15,13 @@ export const GET = withAuth(async (req, ctx) => {
where: and(eq(systemSettings.key, key), eq(systemSettings.portId, ctx.portId)),
});
return NextResponse.json({ enabled: setting?.value === true });
// `default` applies ONLY when the setting was never written for this
// port (row absent). An explicit stored `false` always disables. Lets
// default-ON settings (e.g. pulse_enabled) gate correctly via
// ?default=true while default-OFF flags keep the old behaviour.
const def = req.nextUrl.searchParams.get('default') === 'true';
const enabled = setting ? setting.value === true : def;
return NextResponse.json({ enabled });
} catch (error) {
return errorResponse(error);
}

View File

@@ -0,0 +1,8 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { makeProxyHandlers } from '@/lib/api/proxy-route-handlers';
const { getHandler, putHandler, deleteHandler } = makeProxyHandlers('yacht');
export const GET = withAuth(withPermission('yachts', 'view', getHandler));
export const PUT = withAuth(withPermission('yachts', 'edit', putHandler));
export const DELETE = withAuth(withPermission('yachts', 'edit', deleteHandler));

View File

@@ -1,7 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { Loader2, Download, Database, RefreshCw, AlertTriangle } from 'lucide-react';
import {
Loader2,
Download,
Database,
RefreshCw,
AlertTriangle,
HardDriveDownload,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -94,6 +101,18 @@ export function BackupAdminPanel() {
}
}
function downloadFullBundle() {
// Streams a tar (db.dump + every blob + manifest.json) straight to disk via
// the GET endpoint (cookie auth). The server assembles the bundle before
// the first byte arrives, so the browser download sits "pending" for a
// moment on large datasets — flag that so it doesn't look stuck.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
toast.info(
'Preparing full backup — your download starts once the server finishes assembling it.',
);
triggerUrlDownload('/api/v1/admin/backup/export', `pn-crm-backup-${stamp}.tar`);
}
return (
<div className="space-y-4">
<Card>
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<HardDriveDownload className="h-4 w-4" aria-hidden />
Full disaster-recovery export
</CardTitle>
<CardDescription>
Bundles the database dump <em>and every stored file</em> (documents, berth PDFs,
brochures, GDPR exports) into one <code>.tar</code> and downloads it to this computer.
Use this as an offsite, storage-backend-independent backup.
</CardDescription>
</div>
<Button variant="outline" onClick={downloadFullBundle}>
<HardDriveDownload className="me-1.5 h-4 w-4" aria-hidden />
Download full backup
</Button>
</CardHeader>
<CardContent className="text-xs text-muted-foreground">
The archive contains <code>db.dump</code>, <code>blobs/&lt;key&gt;</code> for every file,
and a <code>manifest.json</code> with a SHA-256 per object for restore-side verification.
Unlike the DB-only backup above, this does not depend on the active storage backend
surviving. Restore steps live in <code>docs/backup-restore-runbook.md</code>.
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>

View File

@@ -0,0 +1,604 @@
'use client';
import { useEffect, useState } from 'react';
import {
Loader2,
Plus,
Server,
Cloud,
FolderTree,
Trash2,
Pencil,
PlugZap,
UploadCloud,
ShieldCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type DestType = 'sftp' | 's3' | 'filesystem';
type Schedule = 'off' | 'daily' | 'weekly';
interface Destination {
id: string;
name: string;
type: DestType;
enabled: boolean;
config: Record<string, unknown>;
retentionCount: number | null;
encryptBundle: boolean;
encryptionKeyIsSet: boolean;
lastRunAt: string | null;
lastStatus: string | null;
lastError: string | null;
lastBackupBytes: number | null;
}
const TYPE_META: Record<DestType, { label: string; icon: typeof Server; hint: string }> = {
sftp: { label: 'SFTP / SSH server', icon: Server, hint: 'Push to a server over SFTP.' },
s3: { label: 'S3-compatible', icon: Cloud, hint: 'AWS S3, Backblaze B2, Wasabi, R2, MinIO.' },
filesystem: {
label: 'Mounted path / NAS',
icon: FolderTree,
hint: 'A directory this server can write to.',
},
};
const STATUS_TONE: Record<string, string> = {
ok: 'text-emerald-700',
failed: 'text-rose-700',
};
function formatBytes(n: number | null): string {
if (n === null) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function BackupDestinationsCard() {
const [destinations, setDestinations] = useState<Destination[]>([]);
const [schedule, setSchedule] = useState<Schedule>('off');
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [editing, setEditing] = useState<Destination | 'new' | null>(null);
useEffect(() => {
void load();
}, []);
async function load() {
setLoading(true);
try {
const [d, s] = await Promise.all([
apiFetch<{ data: Destination[] }>('/api/v1/admin/backup/destinations'),
apiFetch<{ data: { schedule: Schedule } }>('/api/v1/admin/backup/schedule'),
]);
setDestinations(d.data);
setSchedule(s.data.schedule);
} catch (err) {
toastError(err);
} finally {
setLoading(false);
}
}
async function changeSchedule(value: Schedule) {
setSchedule(value);
try {
await apiFetch('/api/v1/admin/backup/schedule', {
method: 'PUT',
body: { schedule: value },
});
toast.success(
value === 'off' ? 'Automated backups turned off' : `Automated backups: ${value}`,
);
} catch (err) {
toastError(err);
void load();
}
}
async function test(id: string) {
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}/test`, { method: 'POST' });
toast.success('Connection OK');
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
async function runNow(id: string) {
setBusyId(id);
try {
toast.info('Backing up — assembling the bundle, then pushing. This can take a minute.');
await apiFetch(`/api/v1/admin/backup/destinations/${id}/run`, { method: 'POST' });
toast.success('Backup pushed');
await load();
} catch (err) {
toastError(err);
await load();
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
if (!confirm('Delete this backup destination? This does not delete already-pushed backups.'))
return;
setBusyId(id);
try {
await apiFetch(`/api/v1/admin/backup/destinations/${id}`, { method: 'DELETE' });
toast.success('Destination deleted');
await load();
} catch (err) {
toastError(err);
} finally {
setBusyId(null);
}
}
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
<div>
<CardTitle className="text-base flex items-center gap-2">
<UploadCloud className="h-4 w-4" aria-hidden />
Automated backup destinations
</CardTitle>
<CardDescription>
Where scheduled backups are pushed. Each destination receives the same full bundle
(database + every file) you can download above.
</CardDescription>
</div>
<Button size="sm" onClick={() => setEditing('new')}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
Add destination
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<Label htmlFor="backup-schedule" className="text-sm">
Schedule
</Label>
<Select value={schedule} onValueChange={(v) => void changeSchedule(v as Schedule)}>
<SelectTrigger id="backup-schedule" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="off">Off</SelectItem>
<SelectItem value="daily">Daily (02:00)</SelectItem>
<SelectItem value="weekly">Weekly (Sun 02:00)</SelectItem>
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
Pushes to every enabled destination below.
</span>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading
</div>
) : destinations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No destinations yet. Add one to enable automated offsite backups.
</p>
) : (
<ul className="divide-y rounded-md border">
{destinations.map((d) => {
const Icon = TYPE_META[d.type].icon;
return (
<li key={d.id} className="flex items-center justify-between gap-3 p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="font-medium truncate">{d.name}</span>
{d.encryptBundle && (
<ShieldCheck
className="h-3.5 w-3.5 text-emerald-600"
aria-label="Encrypted"
/>
)}
{!d.enabled && (
<span className="text-xs rounded-full bg-muted px-1.5 py-0.5 text-muted-foreground">
disabled
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{TYPE_META[d.type].label}
{d.lastStatus && (
<>
{' · '}
<span className={STATUS_TONE[d.lastStatus] ?? ''}>
{d.lastStatus === 'ok' ? 'last OK' : 'last FAILED'}
</span>
{d.lastRunAt && ` ${new Date(d.lastRunAt).toLocaleString()}`}
{d.lastStatus === 'ok' && ` (${formatBytes(d.lastBackupBytes)})`}
</>
)}
{d.lastStatus === 'failed' && d.lastError && (
<span className="block text-rose-700 truncate">{d.lastError}</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Button
size="sm"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void test(d.id)}
>
<PlugZap className="me-1 h-3.5 w-3.5" aria-hidden />
Test
</Button>
<Button
size="sm"
variant="outline"
disabled={busyId === d.id}
onClick={() => void runNow(d.id)}
>
{busyId === d.id ? (
<Loader2 className="me-1 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<UploadCloud className="me-1 h-3.5 w-3.5" aria-hidden />
)}
Back up now
</Button>
<Button size="icon" variant="ghost" onClick={() => setEditing(d)}>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
<Button
size="icon"
variant="ghost"
disabled={busyId === d.id}
onClick={() => void remove(d.id)}
>
<Trash2 className="h-3.5 w-3.5 text-rose-600" aria-hidden />
</Button>
</div>
</li>
);
})}
</ul>
)}
</CardContent>
{editing && (
<DestinationDialog
destination={editing === 'new' ? null : editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void load();
}}
/>
)}
</Card>
);
}
// ─── add/edit dialog ─────────────────────────────────────────────────────────
interface DialogProps {
destination: Destination | null;
onClose: () => void;
onSaved: () => void;
}
function DestinationDialog({ destination, onClose, onSaved }: DialogProps) {
const isEdit = Boolean(destination);
const cfg = (destination?.config ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof cfg[k] === 'string' ? (cfg[k] as string) : '');
const num = (k: string) => (typeof cfg[k] === 'number' ? String(cfg[k]) : '');
const [name, setName] = useState(destination?.name ?? '');
const [type, setType] = useState<DestType>(destination?.type ?? 'sftp');
const [enabled, setEnabled] = useState(destination?.enabled ?? true);
const [retention, setRetention] = useState(
destination?.retentionCount != null ? String(destination.retentionCount) : '',
);
const [encryptBundle, setEncryptBundle] = useState(destination?.encryptBundle ?? false);
const [encryptionKey, setEncryptionKey] = useState('');
const [saving, setSaving] = useState(false);
// Config fields (controlled). Secrets start blank on edit (kept server-side).
const [c, setC] = useState<Record<string, string>>({
directory: str('directory'),
host: str('host'),
port: num('port'),
username: str('username'),
password: '',
privateKey: '',
passphrase: '',
remoteDir: str('remoteDir'),
hostFingerprint: str('hostFingerprint'),
endpoint: str('endpoint'),
region: str('region'),
bucket: str('bucket'),
accessKey: str('accessKey'),
secretKey: '',
prefix: str('prefix'),
});
const set = (k: string, v: string) => setC((prev) => ({ ...prev, [k]: v }));
function buildConfig(): Record<string, unknown> {
if (type === 'filesystem') return { directory: c.directory };
if (type === 'sftp') {
return {
host: c.host,
...(c.port ? { port: Number(c.port) } : {}),
username: c.username,
...(c.password ? { password: c.password } : {}),
...(c.privateKey ? { privateKey: c.privateKey } : {}),
...(c.passphrase ? { passphrase: c.passphrase } : {}),
remoteDir: c.remoteDir,
...(c.hostFingerprint ? { hostFingerprint: c.hostFingerprint } : {}),
};
}
return {
endpoint: c.endpoint,
...(c.region ? { region: c.region } : {}),
bucket: c.bucket,
accessKey: c.accessKey,
...(c.secretKey ? { secretKey: c.secretKey } : {}),
...(c.prefix ? { prefix: c.prefix } : {}),
};
}
async function save() {
setSaving(true);
try {
const body = {
name,
type,
enabled,
config: buildConfig(),
retentionCount: retention ? Number(retention) : null,
encryptBundle,
...(encryptionKey ? { encryptionKey } : {}),
};
if (isEdit && destination) {
await apiFetch(`/api/v1/admin/backup/destinations/${destination.id}`, {
method: 'PUT',
body,
});
} else {
await apiFetch('/api/v1/admin/backup/destinations', {
method: 'POST',
body,
});
}
toast.success(isEdit ? 'Destination updated' : 'Destination added');
onSaved();
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
const secretPlaceholder = isEdit ? 'unchanged — leave blank to keep' : '';
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit destination' : 'Add backup destination'}</DialogTitle>
<DialogDescription>{TYPE_META[type].hint}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Field label="Name">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Hetzner box"
/>
</Field>
<Field label="Type">
<Select value={type} onValueChange={(v) => setType(v as DestType)} disabled={isEdit}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sftp">SFTP / SSH server</SelectItem>
<SelectItem value="s3">S3-compatible</SelectItem>
<SelectItem value="filesystem">Mounted path / NAS</SelectItem>
</SelectContent>
</Select>
</Field>
{type === 'filesystem' && (
<Field label="Directory">
<Input
value={c.directory}
onChange={(e) => set('directory', e.target.value)}
placeholder="/mnt/backups"
/>
</Field>
)}
{type === 'sftp' && (
<>
<div className="grid grid-cols-3 gap-2">
<div className="col-span-2">
<Field label="Host">
<Input
value={c.host}
onChange={(e) => set('host', e.target.value)}
placeholder="backups.example.com"
/>
</Field>
</div>
<Field label="Port">
<Input
value={c.port}
onChange={(e) => set('port', e.target.value)}
placeholder="22"
/>
</Field>
</div>
<Field label="Username">
<Input value={c.username} onChange={(e) => set('username', e.target.value)} />
</Field>
<Field label="Password">
<Input
type="password"
value={c.password}
onChange={(e) => set('password', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Private key (optional, instead of password)">
<textarea
className="w-full rounded-md border px-2 py-1.5 text-xs font-mono"
rows={3}
value={c.privateKey}
onChange={(e) => set('privateKey', e.target.value)}
placeholder={secretPlaceholder || '-----BEGIN OPENSSH PRIVATE KEY-----'}
/>
</Field>
<Field label="Remote directory">
<Input
value={c.remoteDir}
onChange={(e) => set('remoteDir', e.target.value)}
placeholder="/srv/pn-crm-backups"
/>
</Field>
<Field label="Host key fingerprint (optional, sha256 — pins the server)">
<Input
value={c.hostFingerprint}
onChange={(e) => set('hostFingerprint', e.target.value)}
placeholder="aa:bb:cc… or hex"
/>
</Field>
</>
)}
{type === 's3' && (
<>
<Field label="Endpoint">
<Input
value={c.endpoint}
onChange={(e) => set('endpoint', e.target.value)}
placeholder="https://s3.us-west.example.com"
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Bucket">
<Input value={c.bucket} onChange={(e) => set('bucket', e.target.value)} />
</Field>
<Field label="Region (optional)">
<Input
value={c.region}
onChange={(e) => set('region', e.target.value)}
placeholder="us-east-1"
/>
</Field>
</div>
<Field label="Access key">
<Input value={c.accessKey} onChange={(e) => set('accessKey', e.target.value)} />
</Field>
<Field label="Secret key">
<Input
type="password"
value={c.secretKey}
onChange={(e) => set('secretKey', e.target.value)}
placeholder={secretPlaceholder}
/>
</Field>
<Field label="Prefix (optional)">
<Input
value={c.prefix}
onChange={(e) => set('prefix', e.target.value)}
placeholder="crm-backups/"
/>
</Field>
</>
)}
<div className="grid grid-cols-2 gap-2">
<Field label="Keep last N (blank = all)">
<Input
value={retention}
onChange={(e) => setRetention(e.target.value)}
placeholder="7"
/>
</Field>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm">
<Switch checked={enabled} onCheckedChange={setEnabled} />
Enabled (auto-pushed)
</label>
</div>
</div>
<div className="rounded-md border p-3 space-y-2">
<label className="flex items-center gap-2 text-sm">
<Switch checked={encryptBundle} onCheckedChange={setEncryptBundle} />
Encrypt the bundle before sending (AES-256)
</label>
{encryptBundle && (
<Field label="Passphrase (needed to restore — store it safely)">
<Input
type="password"
value={encryptionKey}
onChange={(e) => setEncryptionKey(e.target.value)}
placeholder={
destination?.encryptionKeyIsSet ? 'unchanged — leave blank to keep' : ''
}
/>
</Field>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button onClick={() => void save()} disabled={saving || !name}>
{saving && <Loader2 className="me-1.5 h-4 w-4 animate-spin" aria-hidden />}
{isEdit ? 'Save changes' : 'Add destination'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}

View File

@@ -30,6 +30,7 @@ interface ConfigResp {
hasApiKey: boolean;
useGlobal: boolean;
aiEnabled: boolean;
manualEntry: boolean;
};
models: Record<Provider, string[]>;
}
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed
// from server values cleanly.
const sig = data?.data
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}`
? `${data.data.provider}:${data.data.model}:${data.data.useGlobal}:${data.data.aiEnabled}:${data.data.manualEntry}`
: 'loading';
return (
<SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? false);
const [manualEntry, setManualEntry] = useState(data?.data.manualEntry ?? false);
const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null,
);
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
},
}),
onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div>
) : null}
{scope === 'port' ? (
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
<Checkbox
id={`manualEntry-${scope}`}
checked={manualEntry}
onCheckedChange={(v) => setManualEntry(v === true)}
/>
<div className="space-y-0.5">
<Label htmlFor={`manualEntry-${scope}`} className="text-sm font-medium">
Manual entry only (skip receipt scanning)
</Label>
<p className="text-xs text-muted-foreground">
When on, staff just attach a receipt photo and type the details by hand - no
on-device or AI parsing runs. Takes precedence over AI parsing above.
</p>
</div>
</div>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

@@ -103,6 +103,10 @@ const DEFAULT_PERMISSIONS: Record<string, Record<string, boolean>> = {
delete: false,
change_stage: false,
},
inquiries: {
view: false,
manage: false,
},
};
const GROUP_LABELS: Record<string, string> = {
@@ -126,6 +130,7 @@ const GROUP_LABELS: Record<string, string> = {
admin: 'Administration',
residential_clients: 'Residential Clients',
residential_interests: 'Residential Interests',
inquiries: 'Inquiries',
};
function formatAction(action: string): string {

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{
key: 'tenancies_module_enabled',
label: 'Tenancies Module',
@@ -72,6 +80,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'maintenance_module_enabled',
label: 'Berth Maintenance Module',
description:
'Enable the per-berth maintenance log (the "Maintenance" tab on each berth detail page). On by default. Disabling hides the Maintenance tab everywhere and blocks its log routes; previously-recorded maintenance logs are preserved and reappear when you re-enable.',
type: 'boolean',
defaultValue: true,
},
{
key: 'ai_interest_scoring',
label: 'AI Interest Scoring',

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div>
</div>
{!readOnly ? (
<div className="flex shrink-0 items-start gap-1 opacity-0 transition-opacity duration-base ease-spring group-hover:opacity-100 focus-within:opacity-100">
<div className="flex shrink-0 items-start gap-1 text-muted-foreground">
{!acknowledged ? (
<Button
variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
import { useAlertCount, useAlertList, useAlertRealtime, useDismissAll } from './use-alerts';
import type { AlertStatus } from './types';
/**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0;
const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList>
<TabsContent value={tab} className="mt-4 space-y-2">
{tab === 'open' && alerts.length > 0 ? (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
>
Dismiss all
</Button>
</div>
) : null}
{isLoading ? (
<div className="space-y-2">
<Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss };
}
export function useDismissAll() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filter: { ruleId?: string; severity?: string } = {}) =>
apiFetch('/api/v1/alerts/dismiss-all', { method: 'POST', body: filter }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['alerts'] }),
});
}
export function useAlertRealtime() {
useRealtimeInvalidation({
'alert:created': [['alerts']],

View File

@@ -13,6 +13,7 @@ import {
import { TagBadge } from '@/components/shared/tag-badge';
import { ListCard, ListCardAvatar, ListCardMeta } from '@/components/shared/list-card';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency';
import type { BerthRow } from './berth-columns';
import { mooringLetterDot } from './mooring-letter-tone';
@@ -167,7 +168,9 @@ export function BerthCard({ berth }: BerthCardProps) {
{/* Status pill + tags */}
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
<BerthStatusQuickEdit berthId={berth.id} currentStatus={berth.status}>
<StatusPill status={statusPill}>{statusLabel}</StatusPill>
</BerthStatusQuickEdit>
{tags.slice(0, 2).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}

View File

@@ -15,6 +15,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { BerthStatusQuickEdit } from './berth-status-quick-edit';
import { formatCurrency } from '@/lib/utils/currency';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { apiFetch } from '@/lib/api/client';
@@ -342,7 +343,9 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
const isManualUnreconciled = isManual && !r.latestInterestStage;
return (
<div className="inline-flex items-center gap-1.5">
<StatusBadge status={r.status} />
<BerthStatusQuickEdit berthId={r.id} currentStatus={r.status}>
<StatusBadge status={r.status} />
</BerthStatusQuickEdit>
{isManual ? <ManualBadge variant={isManualUnreconciled ? 'catchup' : 'pinned'} /> : null}
</div>
);

View File

@@ -8,7 +8,7 @@ import { DetailLayout } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { apiFetch } from '@/lib/api/client';
import { useTenanciesModuleEnabled } from '@/providers/port-provider';
import { useTenanciesModuleEnabled, useMaintenanceModuleEnabled } from '@/providers/port-provider';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader, type BerthDetailData } from './berth-detail-header';
import { BerthForm } from './berth-form';
@@ -22,6 +22,7 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const tenanciesModuleEnabled = useTenanciesModuleEnabled();
const maintenanceModuleEnabled = useMaintenanceModuleEnabled();
const { data, isLoading, error } = useQuery<BerthDetailData>({
queryKey: ['berth', berthId],
@@ -86,7 +87,9 @@ export function BerthDetail({ berthId }: BerthDetailProps) {
<DetailLayout
isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth, { tenanciesModuleEnabled }) : []}
tabs={
berth ? buildBerthTabs(berth, { tenanciesModuleEnabled, maintenanceModuleEnabled }) : []
}
defaultTab="overview"
/>
{berth ? <BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} /> : null}

View File

@@ -19,8 +19,10 @@
'use client';
import { useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -29,6 +31,21 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { PdfReconcileDialog } from './pdf-reconcile-dialog';
// pdfjs-dist is ~150kb gzip — lazy-load so the berth page only pulls it
// in when a rep actually expands the spec-sheet preview. ssr:false
// because the pdfjs worker setup needs `window`.
const PdfViewer = dynamic(
() => import('@/components/files/pdf-viewer').then((m) => ({ default: m.PdfViewer })),
{
ssr: false,
loading: () => (
<div className="flex h-[600px] items-center justify-center text-sm text-muted-foreground">
Loading PDF viewer
</div>
),
},
);
interface PdfVersionRow {
id: string;
versionNumber: number;
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
export function BerthDocumentsTab({ berthId }: { berthId: string }) {
const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [previewOpen, setPreviewOpen] = useState(true);
const [pendingDiff, setPendingDiff] = useState<{
versionId: string;
autoApplied: Array<{ field: string; value: string | number }>;
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
</Button>
</div>
</CardHeader>
<CardContent className="pt-0 text-sm">
<CardContent className="space-y-3 pt-0 text-sm">
{isLoading ? (
<p className="text-muted-foreground">Loading</p>
) : current ? (
<div className="flex flex-wrap items-center gap-2">
<a
href={current.downloadUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-2"
>
{current.fileName}
</a>
<span className="text-muted-foreground">
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
</span>
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
</div>
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setPreviewOpen((o) => !o)}
className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
aria-expanded={previewOpen}
>
{previewOpen ? (
<ChevronDown className="size-3.5 shrink-0" aria-hidden />
) : (
<ChevronRight className="size-3.5 shrink-0" aria-hidden />
)}
{current.fileName}
</button>
<span className="text-muted-foreground">
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB
</span>
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null}
<a
href={current.downloadUrl}
target="_blank"
rel="noreferrer"
className="ml-auto inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Download className="size-3.5" aria-hidden />
Download
</a>
</div>
{previewOpen ? (
<div className="h-[600px] overflow-hidden rounded-md border bg-muted/20">
<PdfViewer url={current.downloadUrl} fileName={current.fileName} />
</div>
) : null}
</>
) : (
<p className="text-muted-foreground">No PDF uploaded yet.</p>
)}

View File

@@ -4,16 +4,7 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Anchor,
Archive,
CircleDollarSign,
Plus,
Rows3,
Rows4,
Tag as TagIcon,
TagsIcon,
} from 'lucide-react';
import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
@@ -103,8 +94,10 @@ export function BerthList() {
// Persisted column visibility + row density + dimension unit - same
// pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } =
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN);
const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
'berths',
BERTH_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit);
@@ -187,36 +180,24 @@ export function BerthList() {
applyView({ filters: savedFilters, sort: savedSort });
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDensity(density === 'compact' ? 'comfortable' : 'compact')}
aria-label={
density === 'compact'
? 'Switch to comfortable row spacing'
: 'Switch to compact row spacing'
}
title={density === 'compact' ? 'Comfortable rows' : 'Compact rows'}
>
{density === 'compact' ? (
<Rows3 className="h-4 w-4" aria-hidden />
) : (
<Rows4 className="h-4 w-4" aria-hidden />
)}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
className="font-mono text-xs"
>
{dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
{/* Table-only controls — hidden in card mode (<md, matching
DataTable's table/card switch). The BerthCard ignores the
dimension unit + renders no column set, so these toggles have
no visible effect there. */}
<div className="hidden items-center gap-2 md:flex">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDimensionUnit(dimensionUnit === 'ft' ? 'm' : 'ft')}
aria-label={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
title={`Switch to ${dimensionUnit === 'ft' ? 'metres' : 'feet'}`}
className="font-mono text-xs"
>
{dimensionUnit === 'ft' ? 'ft' : 'm'}
</Button>
<ColumnPicker columns={BERTH_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
<ExportListPdfButton kind="berths" />
{canBulkAdd && (
<Button asChild size="sm" variant="default">
@@ -232,7 +213,6 @@ export function BerthList() {
<DataTable<BerthRow>
columns={berthColumns}
columnVisibility={columnVisibility}
density={density}
data={data}
isLoading={isLoading}
pagination={{

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
@@ -80,27 +81,74 @@ export function BerthOccupancyChip({
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
const extras = competing.length - 1;
return (
<Link
href={`/${portSlug}/interests/${primary.interestId}` as never}
onClick={(e) => e.stopPropagation()}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
compact && 'max-w-[200px]',
)}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
<span
className={cn(
'shrink-0 rounded-full px-1.5 text-xs',
stageBadgeClass(primary.pipelineStage),
)}
const chipClass = cn(
'inline-flex items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs text-amber-900 hover:bg-amber-100 transition-colors',
// Cap tight on narrow viewports, but give the name room on desktop
// so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
compact && 'max-w-[200px] md:max-w-[460px]',
);
const stageChip = (stage: string) => (
<span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
{stageLabel(stage)}
</span>
);
// Single competing interest → the chip is a direct link to it.
if (competing.length === 1) {
return (
<Link
href={`/${portSlug}/interests/${primary.interestId}` as never}
onClick={(e) => e.stopPropagation()}
className={chipClass}
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`}
>
{stageLabel(primary.pipelineStage)}
</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null}
</Link>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
{stageChip(primary.pipelineStage)}
</Link>
);
}
// Multiple competing interests → the chip opens a popover that lists
// every competing deal so no name is hidden behind "+N more" (UAT
// 2026-06-03). Each row links to its interest.
return (
<Popover>
<PopoverTrigger asChild>
<button type="button" onClick={(e) => e.stopPropagation()} className={chipClass}>
<span className="font-medium">Under offer to:</span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
{stageChip(primary.pipelineStage)}
<span className="shrink-0 text-amber-700">+{extras} more</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0" onClick={(e) => e.stopPropagation()}>
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
{competing.length} interests competing for this berth
</div>
<ul className="max-h-72 divide-y overflow-y-auto">
{competing.map((r) => (
<li key={r.interestId}>
<Link
href={`/${portSlug}/interests/${r.interestId}` as never}
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/60"
>
<span className="min-w-0 flex-1 truncate">
{r.clientName}
{r.isInEoiBundle ? (
<span className="ml-1.5 text-xs text-amber-700">· in EOI</span>
) : r.isPrimary ? (
<span className="ml-1.5 text-xs text-muted-foreground">· primary</span>
) : null}
</span>
{stageChip(r.pipelineStage)}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
/**
* Bulk berth price-reconcile table (CM-2 Part A).
*
* Lists the price parsed from each berth's current spec sheet next to the stored
* price, with per-row + select-all approval. Nothing is written until the rep
* approves — the apply mutation posts only the checked, changed rows.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { EmptyState } from '@/components/ui/empty-state';
interface Row {
berthId: string;
mooringNumber: string;
area: string | null;
currentPrice: number | null;
currentCurrency: string;
parsedPrice: number | null;
parsedCurrency: string | null;
status: 'changed' | 'matched' | 'needs_review' | 'no_pdf';
warning?: string;
}
const STATUS_STYLE: Record<Row['status'], string> = {
changed: 'bg-amber-100 text-amber-800',
matched: 'bg-muted text-muted-foreground',
needs_review: 'bg-red-100 text-red-700',
no_pdf: 'bg-slate-100 text-slate-500',
};
const STATUS_LABEL: Record<Row['status'], string> = {
changed: 'Changed',
matched: 'Matched',
needs_review: 'Needs review',
no_pdf: 'No PDF',
};
const fmt = (n: number | null, ccy: string | null) =>
n == null ? '—' : `${n.toLocaleString()} ${ccy ?? ''}`.trim();
export function BerthPriceReconcileTable() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ data: Row[] }>({
queryKey: ['berths', 'price-reconcile'],
queryFn: () => apiFetch('/api/v1/berths/price-reconcile'),
});
const rows = useMemo(() => data?.data ?? [], [data]);
const selectable = useMemo(() => rows.filter((r) => r.status === 'changed'), [rows]);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const apply = useMutation({
mutationFn: async (): Promise<{ data: { updated: number } }> => {
const approvals = selectable
.filter((r) => checked[r.berthId] && r.parsedPrice != null)
.map((r) => ({
berthId: r.berthId,
price: r.parsedPrice as number,
currency: r.parsedCurrency ?? r.currentCurrency,
}));
return apiFetch('/api/v1/berths/price-reconcile/apply', {
method: 'POST',
body: { approvals },
});
},
onSuccess: (res) => {
toast.success(`Updated ${res.data.updated} berth price(s).`);
setChecked({});
void qc.invalidateQueries({ queryKey: ['berths'] });
},
onError: (e: Error) => toastError(e),
});
if (isLoading) {
return <p className="p-6 text-sm text-muted-foreground">Parsing spec sheets</p>;
}
if (rows.length === 0) {
return (
<EmptyState title="No berths to reconcile" body="No active berths found for this port." />
);
}
const allChecked = selectable.length > 0 && selectable.every((r) => checked[r.berthId]);
const selectedCount = selectable.filter((r) => checked[r.berthId]).length;
const reviewCount = rows.filter((r) => r.status === 'needs_review').length;
const noPdfCount = rows.filter((r) => r.status === 'no_pdf').length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{selectable.length} changed · {reviewCount} need review · {noPdfCount} without a PDF
</p>
<Button
size="sm"
disabled={selectedCount === 0 || apply.isPending}
onClick={() => apply.mutate()}
>
{apply.isPending ? 'Applying…' : `Approve selected (${selectedCount})`}
</Button>
</div>
<div className="overflow-hidden rounded-md border bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30 text-start text-xs text-muted-foreground">
<th className="w-10 p-2 ps-3">
<Checkbox
aria-label="Select all changed"
checked={allChecked}
onCheckedChange={(c) =>
setChecked(
c === true
? Object.fromEntries(selectable.map((r) => [r.berthId, true]))
: {},
)
}
/>
</th>
<th className="p-2">Mooring</th>
<th className="p-2">Area</th>
<th className="p-2 text-end">Current</th>
<th className="p-2 text-end">Parsed</th>
<th className="p-2">Status</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.berthId} className="border-b last:border-0">
<td className="p-2 ps-3">
{r.status === 'changed' ? (
<Checkbox
aria-label={`Approve ${r.mooringNumber}`}
checked={!!checked[r.berthId]}
onCheckedChange={(c) =>
setChecked((p) => ({ ...p, [r.berthId]: c === true }))
}
/>
) : null}
</td>
<td className="p-2 font-medium">{r.mooringNumber}</td>
<td className="p-2 text-muted-foreground">{r.area ?? '—'}</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.currentPrice, r.currentCurrency)}
</td>
<td className="p-2 text-end tabular-nums">
{fmt(r.parsedPrice, r.parsedCurrency)}
</td>
<td className="p-2">
<span className={`rounded px-2 py-0.5 text-xs ${STATUS_STYLE[r.status]}`}>
{STATUS_LABEL[r.status]}
</span>
{r.warning ? (
<span className="ms-2 text-xs text-muted-foreground">{r.warning}</span>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { FormErrorSummary } from '@/components/forms/form-error-summary';
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { useVocabulary } from '@/hooks/use-vocabulary';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils';
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
interface InterestOption {
id: string;
clientName: string;
pipelineStage: string;
}
/**
* Click-to-change berth status from the berths LIST. Wraps the status chip
* (passed as children) in a button that opens a compact change-status dialog
* — status dropdown + required reason (with quick-pick chips) + an optional
* interest link when moving to under_offer/sold. Same PATCH endpoint +
* validator + audit as the berth detail page. Reps without `berths.edit` see
* a plain, non-interactive chip via the PermissionGate fallback.
*/
export function BerthStatusQuickEdit({
berthId,
currentStatus,
children,
className,
}: {
berthId: string;
currentStatus: string;
children: ReactNode;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<PermissionGate resource="berths" action="edit" fallback={<>{children}</>}>
<button
type="button"
onClick={(e) => {
// The chip usually sits inside a clickable list card/row — stop the
// click from also navigating to the berth detail page.
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title="Change status"
aria-label="Change berth status"
className={cn(
'cursor-pointer rounded-full outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
className,
)}
>
{children}
</button>
{open && (
<BerthStatusQuickEditDialog
berthId={berthId}
currentStatus={currentStatus}
open={open}
onOpenChange={setOpen}
/>
)}
</PermissionGate>
);
}
function BerthStatusQuickEditDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const reasonChips = useVocabulary('berth_status_change_reasons');
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as (typeof BERTH_STATUSES)[number], reason: '' },
});
const submitWithScroll = useFormScrollToError(handleSubmit, errors);
const status = watch('status');
const interestId = watch('interestId');
const showInterestPicker = status === 'under_offer' || status === 'sold';
// Active interests for the picker — only fetched once the picker is shown.
const interestsQuery = useQuery<{ data: InterestOption[] }>({
queryKey: ['interests', 'status-link-picker'],
queryFn: () => apiFetch('/api/v1/interests?pageSize=200&sort=updatedAt&order=desc'),
enabled: open && showInterestPicker,
staleTime: 60_000,
});
const interestOptions = interestsQuery.data?.data ?? [];
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, { method: 'PATCH', body: data });
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err) {
toastError(err);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change status</DialogTitle>
</DialogHeader>
<form onSubmit={submitWithScroll(onSubmit)} className="space-y-4">
<FormErrorSummary
errors={errors}
labels={{ status: 'Status', reason: 'Reason', interestId: 'Linked interest' }}
/>
<div className="space-y-2">
<Label>New status</Label>
<Select
value={status}
onValueChange={(v) => {
setValue('status', v as (typeof BERTH_STATUSES)[number]);
// Clear any stale interest pick when returning to available.
if (v === 'available') setValue('interestId', undefined);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
{reasonChips.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{reasonChips.map((chip) => (
<button
type="button"
key={chip}
onClick={() => setValue('reason', chip, { shouldDirty: true })}
className="rounded-full border border-muted-foreground/20 bg-muted px-2.5 py-0.5 text-xs hover:bg-accent"
>
{chip}
</button>
))}
</div>
)}
<Textarea {...register('reason')} placeholder="Reason for status change…" rows={3} />
</div>
{showInterestPicker && (
<div className="space-y-2">
<Label>Linked interest (optional)</Label>
<Select
value={interestId ?? '__none__'}
onValueChange={(v) => setValue('interestId', v === '__none__' ? undefined : v)}
>
<SelectTrigger>
<SelectValue placeholder="No interest" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No interest</SelectItem>
{interestOptions.map((o) => (
<SelectItem key={o.id} value={o.id}>
{`${o.clientName || '(unnamed)'} · ${stageLabel(o.pipelineStage)}`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Links this change to the interest it relates to it shows on that interest&apos;s
timeline and the berth attaches to it automatically.
</p>
</div>
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving…' : 'Update status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -427,7 +427,10 @@ function OverviewTab({ berth }: { berth: BerthData }) {
export function buildBerthTabs(
berth: BerthData,
opts: { tenanciesModuleEnabled: boolean } = { tenanciesModuleEnabled: false },
opts: { tenanciesModuleEnabled: boolean; maintenanceModuleEnabled: boolean } = {
tenanciesModuleEnabled: false,
maintenanceModuleEnabled: true,
},
): DetailTab[] {
const tabs: DetailTab[] = [
{
@@ -448,12 +451,15 @@ export function buildBerthTabs(
content: <BerthTenanciesTab berthId={berth.id} />,
});
}
tabs.push(...buildBerthDetailRemainder(berth));
tabs.push(...buildBerthDetailRemainder(berth, opts));
return tabs;
}
function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
return [
function buildBerthDetailRemainder(
berth: BerthData,
opts: { maintenanceModuleEnabled: boolean } = { maintenanceModuleEnabled: true },
): DetailTab[] {
const tabs: DetailTab[] = [
{
id: 'spec',
label: 'Spec',
@@ -469,20 +475,23 @@ function buildBerthDetailRemainder(berth: BerthData): DetailTab[] {
label: 'Waiting List',
content: <WaitingListManager berthId={berth.id} />,
},
{
];
if (opts.maintenanceModuleEnabled) {
tabs.push({
id: 'maintenance',
label: 'Maintenance',
content: <BerthMaintenanceTab berthId={berth.id} />,
},
{
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/berths/${berth.id}/activity`}
emptyText="No activity recorded for this berth yet."
/>
),
},
];
});
}
tabs.push({
id: 'activity',
label: 'Activity',
content: (
<EntityActivityFeed
endpoint={`/api/v1/berths/${berth.id}/activity`}
emptyText="No activity recorded for this berth yet."
/>
),
});
return tabs;
}

View File

@@ -0,0 +1,304 @@
'use client';
import { useMemo, useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams, useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Copy, CopyCheck, Trash2, UserCog, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface GroupMember {
clientId: string;
fullName: string;
email: string | null;
}
interface ClientOption {
id: string;
fullName: string;
primaryEmail: string | null;
}
async function copyToClipboard(text: string, successMsg: string) {
try {
await navigator.clipboard.writeText(text);
toast.success(successMsg);
} catch {
toast.error('Copy failed — clipboard unavailable');
}
}
export function ClientGroupDetail({ groupId }: { groupId: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const qc = useQueryClient();
const [manageOpen, setManageOpen] = useState(false);
const { data: groupResp } = useQuery<{ data: { id: string; name: string; color: string } }>({
queryKey: ['client-group', groupId],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}`),
});
const { data: membersResp, isLoading } = useQuery<{ data: GroupMember[] }>({
queryKey: ['client-group', groupId, 'members'],
queryFn: () => apiFetch(`/api/v1/client-groups/${groupId}/members`),
});
const group = groupResp?.data;
const members = useMemo(() => membersResp?.data ?? [], [membersResp]);
const emails = members.map((m) => m.email).filter((e): e is string => !!e);
const archive = useMutation({
mutationFn: () => apiFetch(`/api/v1/client-groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Group archived');
qc.invalidateQueries({ queryKey: ['client-groups'] });
router.push(`/${portSlug}/client-groups` as Route);
},
onError: (err) => toastError(err),
});
return (
<div className="space-y-6">
<Link
href={`/${portSlug}/client-groups` as Route}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" aria-hidden />
All groups
</Link>
<PageHeader
title={group?.name ?? 'Group'}
eyebrow="Mailing group"
kpiLine={
<span className="inline-flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" aria-hidden />
{members.length} {members.length === 1 ? 'member' : 'members'}
{emails.length < members.length ? (
<span className="text-amber-700">
· {members.length - emails.length} without email
</span>
) : null}
</span>
}
variant="gradient"
actions={
<>
<Button
variant="outline"
disabled={emails.length === 0}
onClick={() =>
copyToClipboard(emails.join(', '), `Copied ${emails.length} email addresses`)
}
>
<CopyCheck className="me-1.5 h-4 w-4" aria-hidden />
Copy all emails
</Button>
<PermissionGate resource="client_groups" action="manage">
<Button variant="outline" onClick={() => setManageOpen(true)}>
<UserCog className="me-1.5 h-4 w-4" aria-hidden />
Manage members
</Button>
</PermissionGate>
<PermissionGate resource="client_groups" action="manage">
<Button
variant="ghost"
className="text-destructive"
onClick={() => {
if (confirm('Archive this group? Members are kept; the group is hidden.')) {
archive.mutate();
}
}}
>
<Trash2 className="me-1.5 h-4 w-4" aria-hidden />
Archive
</Button>
</PermissionGate>
</>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading members</p>
) : members.length === 0 ? (
<EmptyState
icon={Users}
title="No members yet"
description="Use “Manage members” to add clients to this group."
/>
) : (
<div className="overflow-hidden rounded-xl border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">Client</th>
<th className="px-4 py-2 font-medium">Email</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{members.map((m) => (
<tr key={m.clientId} className="hover:bg-muted/30">
<td className="px-4 py-2">
<Link
href={`/${portSlug}/clients/${m.clientId}` as Route}
className="text-foreground hover:underline"
>
{m.fullName}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">{m.email ?? '—'}</td>
<td className="px-4 py-2 text-end">
{m.email ? (
<button
type="button"
onClick={() => copyToClipboard(m.email!, 'Email copied')}
aria-label={`Copy ${m.email}`}
title="Copy email"
className="rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<Copy className="h-4 w-4" aria-hidden />
</button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{manageOpen ? (
<ManageMembersDialog
groupId={groupId}
open={manageOpen}
onOpenChange={setManageOpen}
currentIds={members.map((m) => m.clientId)}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['client-group', groupId, 'members'] });
qc.invalidateQueries({ queryKey: ['client-groups'] });
}}
/>
) : null}
</div>
);
}
function ManageMembersDialog({
groupId,
open,
onOpenChange,
currentIds,
onSaved,
}: {
groupId: string;
open: boolean;
onOpenChange: (v: boolean) => void;
currentIds: string[];
onSaved: () => void;
}) {
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const { data, isLoading } = useQuery<{ data: ClientOption[] }>({
queryKey: ['clients', 'group-picker'],
queryFn: () => apiFetch('/api/v1/clients?limit=1000'),
enabled: open,
});
const clients = data?.data ?? [];
const filtered = clients.filter((c) =>
`${c.fullName} ${c.primaryEmail ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()),
);
const save = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/client-groups/${groupId}/members`, {
method: 'PUT',
body: { clientIds: Array.from(selected) },
}),
onSuccess: () => {
toast.success('Members updated');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
function toggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Manage members</DialogTitle>
<DialogDescription>
Tick the clients who belong in this group. {selected.size} selected.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Search clients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="max-h-80 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{isLoading ? (
<p className="p-2 text-sm text-muted-foreground">Loading clients</p>
) : filtered.length === 0 ? (
<p className="p-2 text-sm text-muted-foreground">No matching clients.</p>
) : (
filtered.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
>
<Checkbox checked={selected.has(c.id)} onCheckedChange={() => toggle(c.id)} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{c.fullName}</span>
{c.primaryEmail ? (
<span className="block truncate text-xs text-muted-foreground">
{c.primaryEmail}
</span>
) : null}
</span>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Save members
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Users } from 'lucide-react';
import { toast } from 'sonner';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { EmptyState } from '@/components/shared/empty-state';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface ClientGroupRow {
id: string;
name: string;
description: string | null;
color: string;
memberCount: number;
}
export function ClientGroupsList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6B7280');
const { data, isLoading } = useQuery<{ data: ClientGroupRow[] }>({
queryKey: ['client-groups'],
queryFn: () => apiFetch('/api/v1/client-groups'),
});
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/client-groups', {
method: 'POST',
body: { name: name.trim(), description: description.trim() || null, color },
}),
onSuccess: () => {
toast.success('Group created');
qc.invalidateQueries({ queryKey: ['client-groups'] });
setOpen(false);
setName('');
setDescription('');
setColor('#6B7280');
},
onError: (err) => toastError(err),
});
const groups = data?.data ?? [];
return (
<div className="space-y-6">
<PageHeader
title="Client Groups"
eyebrow="Mailing"
description="Group clients into mailing lists. View members, copy their emails, and (once wired) sync to Mailchimp."
variant="gradient"
actions={
<PermissionGate resource="client_groups" action="manage">
<Button onClick={() => setOpen(true)}>
<Plus className="me-1.5 h-4 w-4" aria-hidden />
New group
</Button>
</PermissionGate>
}
/>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : groups.length === 0 ? (
<EmptyState
icon={Users}
title="No groups yet"
description="Create a group to start organising clients into mailing lists."
/>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<Link
key={g.id}
href={`/${portSlug}/client-groups/${g.id}` as Route}
className="group rounded-xl border border-border bg-card p-4 transition-colors hover:border-brand/40 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: g.color }}
aria-hidden
/>
<h3 className="truncate font-medium text-foreground">{g.name}</h3>
</div>
{g.description ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{g.description}</p>
) : null}
<p className="mt-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5" aria-hidden />
{g.memberCount} {g.memberCount === 1 ? 'member' : 'members'}
</p>
</Link>
))}
</div>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New client group</DialogTitle>
<DialogDescription>A named mailing/segment group for this port.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="cg-name">Name</Label>
<Input
id="cg-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Newsletter subscribers"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-desc">Description (optional)</Label>
<Input
id="cg-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cg-color">Color</Label>
<input
id="cg-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-9 w-16 cursor-pointer rounded-md border border-border bg-background"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={() => create.mutate()} disabled={!name.trim() || create.isPending}>
Create group
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -54,11 +54,22 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr
const interest = client.latestInterest ?? null;
const interestCount = client.interestCount ?? 0;
const interestBerthLabel = interest
? interest.mooringNumber
? `Berth ${interest.mooringNumber}`
: 'General interest'
: null;
// Show ALL berths the client has interests in (across every interest),
// not just the latest interest's primary mooring — matches the desktop
// table's Berths column + the interest header. Cap the inline list so
// the card stays compact; overflow folds into a "+N" suffix.
const linkedBerths = client.linkedBerths ?? [];
const MAX_BERTHS_SHOWN = 4;
const shownMoorings = linkedBerths.slice(0, MAX_BERTHS_SHOWN).map((b) => b.mooringNumber);
const extraBerths = linkedBerths.length - shownMoorings.length;
const interestBerthLabel =
shownMoorings.length > 0
? `${linkedBerths.length === 1 ? 'Berth' : 'Berths'} ${shownMoorings.join(', ')}${
extraBerths > 0 ? ` +${extraBerths}` : ''
}`
: interest
? 'General interest'
: null;
const interestStageLabel = interest ? stageLabel(interest.stage) : null;
const interestStageBadge = interest ? stageBadgeClass(interest.stage) : null;
const extraInterests = interestCount > 1 ? interestCount - 1 : 0;

View File

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next';
import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone');
const primaryPhone = primaryPhoneContact?.value;
// wa.me requires the E.164 number without the leading "+". Strip from the
// canonical E.164 form when available; otherwise strip non-digits from the
// display value as a best-effort fallback.
const whatsappNumber = primaryPhoneContact?.valueE164
? primaryPhoneContact.valueE164.replace(/^\+/, '')
: primaryPhoneContact?.value
? primaryPhoneContact.value.replace(/[^\d]/g, '')
: null;
const country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p>
) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{primaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
<Mail />
Email
</a>
</Button>
) : null}
{primaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a href={`tel:${primaryPhone}`} aria-label={`Call ${primaryPhone}`}>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message ${primaryPhone} on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled === true ? (
{/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
request. GDPR export moved to the top-right action cluster.
Portal-invite stays as the one primary CTA here. */}
{!isArchived && client.clientPortalEnabled === true ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div>
) : null}
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */}
<div className="flex items-start gap-1">
{/* CM-4: GDPR export relocated here as a compact icon trigger,
alongside reminder/archive/delete. Self-gates on permission. */}
<GdprExportButton clientId={client.id} variant="icon" />
{isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients">
<button

View File

@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
@@ -156,6 +157,9 @@ function OverviewTab({
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
{/* CM-9: point-of-contact (default level for the client). */}
<ProxyCard entityType="client" entityId={clientId} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">

View File

@@ -0,0 +1,119 @@
'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { CountryFlag } from '@/components/shared/country-flag';
import { getCountryName } from '@/lib/i18n/countries';
import { cn } from '@/lib/utils';
interface ClientsByCountryRow {
country: string;
count: number;
}
interface ClientsByCountryResponse {
data: ClientsByCountryRow[];
total: number;
}
/**
* Full per-country breakdown of the active (non-archived) client book — the
* "Show all" destination for the dashboard `ClientsByCountryWidget`, which
* only shows the top N. Same endpoint (it already returns every row); this
* page just renders the complete ranked list. Each row deep-links into the
* clients list filtered by that nationality.
*/
export function ClientsByCountryPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<ClientsByCountryResponse>({
queryKey: ['dashboard', 'clients-by-country', 'all'],
queryFn: () => apiFetch<ClientsByCountryResponse>('/api/v1/dashboard/clients-by-country'),
staleTime: 60_000,
});
const rows = data?.data ?? [];
const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0);
const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1;
return (
<div className="space-y-6">
<PageHeader
eyebrow="Clients"
title="Clients by country"
description="Every country represented in the active client book, ranked by client count. Select a row to view those clients."
kpiLine={
rows.length > 0
? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${
rows.length === 1 ? 'country' : 'countries'
}.`
: undefined
}
/>
<Card>
<CardContent className="p-4 sm:p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" aria-hidden />
))}
</div>
) : rows.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
<Globe className="size-6" aria-hidden />
<p>No clients with a country recorded yet.</p>
</div>
) : (
<ol className="space-y-1">
{rows.map((row, i) => {
const pct = (row.count / maxCount) * 100;
const name = getCountryName(row.country) || row.country;
return (
<li key={row.country}>
<Link
href={
`/${portSlug}/clients?nationality=${encodeURIComponent(row.country)}` as Route
}
className="group flex items-center justify-between gap-3 rounded-md px-2 py-2 hover:bg-foreground/5"
title={`${row.count} client${row.count === 1 ? '' : 's'} in ${name}`}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<span className="w-6 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
{i + 1}
</span>
<CountryFlag code={row.country} className="h-3.5 w-5" decorative />
<span className="truncate text-sm font-medium">{name}</span>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full rounded-full bg-brand-500')}
style={{ width: `${pct}%` }}
aria-hidden
/>
</div>
<span className="w-10 text-end text-sm tabular-nums text-foreground">
{row.count}
</span>
</div>
</Link>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -48,7 +48,15 @@ const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'des
failed: 'destructive',
};
export function GdprExportButton({ clientId }: { clientId: string }) {
export function GdprExportButton({
clientId,
variant = 'button',
}: {
clientId: string;
/** `button` = standalone outline button (default). `icon` = compact icon-only
* trigger for the detail-header top-right action cluster (CM-4). */
variant?: 'button' | 'icon';
}) {
const { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
{variant === 'icon' ? (
<button
type="button"
aria-label="GDPR export"
title="GDPR export"
className="shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-foreground/5 hover:text-foreground"
>
<FileDown className="size-4" aria-hidden />
</button>
) : (
<Button variant="outline" size="sm" className="h-8">
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden />
GDPR export
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import type { Route } from 'next';
import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { Globe } from 'lucide-react';
import { ArrowRight, Globe } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
@@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
);
})}
{hiddenCount > 0 ? (
<li className="pt-1 text-xs text-muted-foreground">
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown.
<li className="border-t pt-2 text-right">
<Link
href={`/${portSlug}/clients/by-country` as Route}
className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
Show all {rows.length} countries
<ArrowRight className="size-3" aria-hidden />
</Link>
</li>
) : null}
</ol>

View File

@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
label: 'Pipeline Value',
description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.',
render: (range) => <PipelineValueTile range={range} />,
// Current-state snapshot: pipeline value = sum across ALL active deals,
// not "added in the selected window". Don't thread the range (UAT
// 2026-06-03 — windowing it dropped older deals + confused the headline).
render: () => <PipelineValueTile />,
// Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles.

View File

@@ -299,7 +299,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) {
{isComplete && doc.signedFileId ? (
<>
<Button asChild size="sm">
<Link href={`/api/v1/files/${doc.signedFileId}/download`}>
<Link href={`/api/v1/files/${doc.signedFileId}/download?redirect=1`}>
<Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
</Link>
</Button>

View File

@@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
import {
ChevronDown,
ChevronRight,
Download,
FileText,
Folder,
Lock,
Plus,
Upload,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -16,9 +25,11 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -336,6 +347,30 @@ function FlatFolderListing({
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false);
// File selected for inline preview. Clicking a file row opens the shared
// FilePreviewDialog rather than navigating the browser at the JSON-returning
// `/files/[id]/download` endpoint (which used to dump raw `{data:{url}}`).
const [previewFile, setPreviewFile] = useState<HubFile | null>(null);
// Force-download a stored file: the `/download` route returns a presigned
// URL (content-disposition=attachment) as a JSON envelope, so we fetch it
// then click a hidden anchor. Avoids navigating the tab to the raw JSON.
const downloadFile = useCallback(async (file: HubFile) => {
try {
const { data } = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = data.url;
a.rel = 'noopener';
a.download = file.originalName ?? file.filename;
document.body.appendChild(a);
a.click();
a.remove();
} catch {
// apiFetch surfaces its own toast on failure; nothing else to do here.
}
}, []);
const queryClient = useQueryClient();
const queryParams = useMemo(() => {
@@ -489,9 +524,10 @@ function FlatFolderListing({
};
// Uploaded-file row — simpler than a signature doc since there's no
// signer/status concept. Links to the underlying file via download URL
// and surfaces an "Uploaded" type pill so the rep distinguishes it
// from signature workflows at a glance.
// signer/status concept. Clicking the name opens an inline preview
// (FilePreviewDialog); a dedicated download button saves the file. An
// "Uploaded" type pill distinguishes it from signature workflows.
const fileLabel = (file: HubFile) => file.originalName ?? file.filename;
const renderFileRow = (file: HubFile) => {
return (
<li
@@ -501,14 +537,14 @@ function FlatFolderListing({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3 text-sm sm:grid sm:grid-cols-[auto_1fr_auto_auto_auto_auto] sm:gap-3">
{/* Empty action column to align with doc-row layout */}
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
<a
href={`/api/v1/files/${file.id}/download`}
target="_blank"
rel="noreferrer"
className="min-w-0 truncate font-medium text-foreground hover:text-brand"
<button
type="button"
onClick={() => setPreviewFile(file)}
className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand"
title={`Preview ${fileLabel(file)}`}
>
{file.originalName ?? file.filename}
</a>
{fileLabel(file)}
</button>
<span className="text-xs text-muted-foreground">Uploaded file</span>
<StatusPill status="completed" withDot>
Stored
@@ -516,9 +552,21 @@ function FlatFolderListing({
<span className="text-xs tabular-nums text-muted-foreground">
{(file.sizeBytes / 1024).toFixed(0)} KB
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{new Date(file.createdAt).toLocaleDateString(undefined)}
</span>
<div className="flex items-center gap-2">
<span className="text-xs tabular-nums text-muted-foreground">
{new Date(file.createdAt).toLocaleDateString(undefined)}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => void downloadFile(file)}
title={`Download ${fileLabel(file)}`}
aria-label={`Download ${fileLabel(file)}`}
>
<Download className="h-4 w-4" aria-hidden />
</Button>
</div>
</div>
</li>
);
@@ -526,6 +574,15 @@ function FlatFolderListing({
return (
<div className="space-y-4">
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(o) => {
if (!o) setPreviewFile(null);
}}
fileId={previewFile?.id}
fileName={previewFile ? fileLabel(previewFile) : undefined}
mimeType={previewFile?.mimeType ?? undefined}
/>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by title..."

View File

@@ -75,7 +75,7 @@ function ReceiptThumbnail({ fileId }: { fileId: string }) {
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
<a
href={`/api/v1/files/${fileId}/download`}
href={`/api/v1/files/${fileId}/download?redirect=1`}
className="inline-flex items-center gap-1 text-primary hover:underline"
>
<Download className="h-3 w-3" aria-hidden /> Download

View File

@@ -0,0 +1,87 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Loader2 } from 'lucide-react';
/**
* In-app .docx viewer.
*
* Renders Word OOXML (.docx) client-side via `docx-preview` (lazy-loaded
* so the ~library cost only lands on routes that actually preview a docx).
* We fetch the bytes from our own storage URL and render them in-browser —
* deliberately NOT delegating to Microsoft's hosted Office viewer, which
* requires a publicly-reachable URL and so can't render documents stored
* in our private object store.
*
* Legacy .doc / .xls / .xlsx are not handled here (docx-preview is OOXML-
* Word only); the preview dialog routes those to a download CTA instead.
*/
export function DocxViewer({ url, fileName }: { url: string; fileName?: string }) {
// Key-based remount on url change keeps render state (loading/error +
// the imperatively-populated container) re-initialised from scratch,
// mirroring PdfViewer.
return <DocxViewerBody key={url} url={url} fileName={fileName} />;
}
function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function render() {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load document (${res.status})`);
const blob = await res.blob();
if (cancelled) return;
const { renderAsync } = await import('docx-preview');
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
await renderAsync(blob, container, undefined, {
className: 'docx',
inWrapper: true,
// Let the document flow to the container width rather than
// forcing fixed A4 page metrics that overflow the dialog.
ignoreWidth: true,
ignoreHeight: true,
breakPages: true,
});
if (!cancelled) setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to render document');
}
} finally {
if (!cancelled) setLoading(false);
}
}
void render();
return () => {
cancelled = true;
};
}, [url]);
return (
<div className="relative h-full overflow-auto bg-muted/30 p-4">
{loading && (
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
Rendering document
</div>
)}
{error && !loading && (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
{error}
</div>
)}
<div
ref={containerRef}
aria-label={fileName ?? 'Document preview'}
className="mx-auto max-w-3xl [&_.docx-wrapper]:bg-transparent [&_.docx-wrapper]:p-0 [&_.docx-wrapper>section.docx]:mx-auto [&_.docx-wrapper>section.docx]:mb-4 [&_.docx-wrapper>section.docx]:bg-white [&_.docx-wrapper>section.docx]:shadow-sm"
/>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { isWordDocx } from '@/lib/constants/file-validation';
// yet-another-react-lightbox is ~50kb, lazy-load it.
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
@@ -30,6 +31,16 @@ const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m
),
});
// docx-preview is lazy-loaded the same way — only .docx previews pull it in.
const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading document viewer
</div>
),
});
interface FilePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -93,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId,
@@ -102,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName);
// Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -110,12 +127,24 @@ export function FilePreviewDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span>
{fileId && (
<a
href={`/api/v1/files/${fileId}/download?redirect=1`}
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Download"
aria-label={`Download ${fileName ?? 'file'}`}
>
<Download className="h-4 w-4" />
</a>
)}
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
title="Open in new tab"
aria-label="Open in new tab"
>
<ExternalLink className="h-4 w-4" />
</a>
@@ -185,24 +214,34 @@ export function FilePreviewDialog({
</div>
)}
{!loading && !error && previewUrl && kind === 'office' && (
// Office documents render via Microsoft's hosted Office viewer
// - public URL only; presigned download URLs include a token
// in the query string so they work here even though the file
// isn't world-public. The viewer streams the document and
// renders a high-fidelity preview without us shipping a
// headless LibreOffice. Falls back to "download to view" if
// the embed loads but renders nothing (e.g. CORS rejected) -
// detection is hard so we just keep the download CTA below.
<iframe
title={fileName ?? 'Office document preview'}
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
previewUrl,
)}`}
className="h-full w-full"
sandbox="allow-scripts allow-same-origin allow-popups"
/>
)}
{!loading &&
!error &&
previewUrl &&
kind === 'office' &&
// Word .docx renders in-browser via docx-preview (fetches the
// bytes from our own storage — works with private MinIO/disk).
// We do NOT use Microsoft's hosted Office viewer: it requires a
// publicly-reachable URL, which our private object store isn't.
// Legacy .doc + spreadsheet formats can't be rendered client-
// side, so they fall through to a download CTA.
(isWordDocx(mimeType, fileName) ? (
<DocxViewer url={previewUrl} fileName={fileName} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<FileWarning className="size-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">In-browser preview isn&apos;t available</p>
<p className="max-w-xs text-xs text-muted-foreground">
This Office format ({mimeType ?? 'unknown'}) can&apos;t be rendered in the
browser. Download it to view locally.
</p>
<Button asChild>
<a href={previewUrl} download={fileName ?? 'download'}>
<Download className="mr-1.5 size-4" aria-hidden />
Download
</a>
</Button>
</div>
))}
{!loading && !error && previewUrl && kind === 'unknown' && (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">

View File

@@ -0,0 +1,35 @@
'use client';
import Link from 'next/link';
import { formatDistanceToNowStrict } from 'date-fns';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { KIND_LABELS, TRIAGE_TONE, type InquiryRow } from '@/components/inquiries/inquiry-columns';
export function InquiryCard({ inquiry, portSlug }: { inquiry: InquiryRow; portSlug: string }) {
return (
<Link href={`/${portSlug}/inquiries/${inquiry.id}`} className="block">
<Card className="transition-shadow hover:shadow-sm">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{inquiry.contactName || '(no name)'}</p>
{inquiry.contactEmail ? (
<p className="truncate text-sm text-muted-foreground">{inquiry.contactEmail}</p>
) : null}
</div>
<Badge className={TRIAGE_TONE[inquiry.triageState]}>{inquiry.triageState}</Badge>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>{KIND_LABELS[inquiry.kind]}</span>
<span>·</span>
<span>
{formatDistanceToNowStrict(new Date(inquiry.receivedAt), { addSuffix: true })}
</span>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import Link from 'next/link';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { MoreHorizontal, UserCheck, X, ExternalLink } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export type InquiryKind = 'berth_inquiry' | 'residence_inquiry' | 'contact_form';
export type InquiryTriageState = 'open' | 'assigned' | 'converted' | 'dismissed';
export interface InquiryRow {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
receivedAt: string;
triageState: InquiryTriageState;
convertedClientId: string | null;
convertedInterestId: string | null;
sourceIp: string | null;
utmSource?: string | null;
}
export const KIND_LABELS: Record<InquiryKind, string> = {
berth_inquiry: 'Berth',
residence_inquiry: 'Residence',
contact_form: 'Contact',
};
const KIND_TONE: Record<InquiryKind, string> = {
berth_inquiry: 'bg-blue-100 text-blue-800',
residence_inquiry: 'bg-amber-100 text-amber-900',
contact_form: 'bg-slate-100 text-slate-800',
};
export const TRIAGE_TONE: Record<InquiryTriageState, string> = {
open: 'bg-blue-100 text-blue-800',
assigned: 'bg-amber-100 text-amber-900',
converted: 'bg-emerald-100 text-emerald-800',
dismissed: 'bg-slate-100 text-slate-600',
};
export const TRIAGE_LABELS: Record<InquiryTriageState, string> = {
open: 'Open',
assigned: 'Assigned',
converted: 'Converted',
dismissed: 'Dismissed',
};
export const INQUIRY_COLUMN_OPTIONS: Array<{ id: string; label: string }> = [
{ id: 'contactEmail', label: 'Email' },
{ id: 'kind', label: 'Type' },
{ id: 'triageState', label: 'Status' },
{ id: 'utmSource', label: 'UTM source' },
{ id: 'receivedAt', label: 'Received' },
];
export const INQUIRY_DEFAULT_HIDDEN: string[] = ['utmSource'];
interface GetColumnsOptions {
portSlug: string;
onTriage: (row: InquiryRow, state: InquiryTriageState) => void;
}
export function getInquiryColumns({
portSlug,
onTriage,
}: GetColumnsOptions): ColumnDef<InquiryRow, unknown>[] {
return [
{
id: 'contactName',
accessorKey: 'contactName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/inquiries/${row.original.id}`}
className="truncate font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.contactName || '(no name)'}
</Link>
),
},
{
id: 'contactEmail',
accessorKey: 'contactEmail',
header: 'Email',
enableSorting: false,
cell: ({ getValue }) => {
const email = getValue() as string | null;
return email ? (
<span className="text-sm">{email}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'kind',
accessorKey: 'kind',
header: 'Type',
cell: ({ getValue }) => {
const kind = getValue() as InquiryKind;
return <Badge className={KIND_TONE[kind]}>{KIND_LABELS[kind]}</Badge>;
},
},
{
id: 'triageState',
accessorKey: 'triageState',
header: 'Status',
cell: ({ row }) => {
const state = row.original.triageState;
return (
<div className="flex items-center gap-1.5">
<Badge className={TRIAGE_TONE[state]}>{TRIAGE_LABELS[state]}</Badge>
{row.original.convertedInterestId ? (
<Link
href={`/${portSlug}/interests/${row.original.convertedInterestId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
interest
</Link>
) : row.original.convertedClientId ? (
<Link
href={`/${portSlug}/clients/${row.original.convertedClientId}`}
className="text-primary hover:underline text-xs"
onClick={(e) => e.stopPropagation()}
>
client
</Link>
) : null}
</div>
);
},
},
{
id: 'utmSource',
accessorKey: 'utmSource',
header: 'UTM source',
enableSorting: false,
cell: ({ getValue }) => {
const utm = getValue() as string | null;
return utm ? (
<span className="text-sm">{utm}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'receivedAt',
accessorKey: 'receivedAt',
header: 'Received',
cell: ({ getValue }) => {
const iso = getValue() as string;
const d = new Date(iso);
return (
<span className="text-muted-foreground text-sm tabular-nums" title={format(d, 'PPpp')}>
{formatDistanceToNowStrict(d, { addSuffix: true })}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => {
const isResolved =
row.original.triageState === 'converted' || row.original.triageState === 'dismissed';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={`Row actions for ${row.original.contactName ?? 'inquiry'}`}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/inquiries/${row.original.id}`}>
<ExternalLink className="mr-2 h-3.5 w-3.5" aria-hidden />
Open
</Link>
</DropdownMenuItem>
{!isResolved ? (
<>
<DropdownMenuItem onClick={() => onTriage(row.original, 'assigned')}>
<UserCheck className="mr-2 h-3.5 w-3.5" aria-hidden />
Assign to me
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onTriage(row.original, 'dismissed')}>
<X className="mr-2 h-3.5 w-3.5" aria-hidden />
Dismiss
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onClick={() => onTriage(row.original, 'open')}>
Reopen
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,119 @@
'use client';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowRight, UserPlus, UserCheck, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PermissionGate } from '@/components/shared/permission-gate';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface InquiryConvertActionsProps {
portSlug: string;
inquiry: {
id: string;
triageState: string;
convertedClientId: string | null;
convertedInterestId: string | null;
};
}
export function InquiryConvertActions({ portSlug, inquiry }: InquiryConvertActionsProps) {
const router = useRouter();
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
};
const convert = useMutation({
mutationFn: (target: 'client' | 'interest') =>
apiFetch<{ data: { clientId: string; interestId: string | null } }>(
`/api/v1/inquiries/${inquiry.id}/convert`,
{ method: 'POST', body: { target } },
),
onSuccess: (res, target) => {
invalidate();
if (target === 'interest' && res.data.interestId) {
toast.success('Converted to interest.');
router.push(`/${portSlug}/interests/${res.data.interestId}`);
} else {
toast.success('Converted to client.');
router.push(`/${portSlug}/clients/${res.data.clientId}`);
}
},
onError: (err: unknown) => toastError(err, 'Convert failed'),
});
const triage = useMutation({
mutationFn: (state: 'open' | 'assigned' | 'dismissed') =>
apiFetch(`/api/v1/inquiries/${inquiry.id}/triage`, { method: 'PATCH', body: { state } }),
onSuccess: (_d, state) => {
invalidate();
toast.success(`Marked ${state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const busy = convert.isPending || triage.isPending;
const alreadyInterest = Boolean(inquiry.convertedInterestId);
return (
<PermissionGate resource="inquiries" action="manage">
<div className="flex flex-wrap items-center gap-2">
{alreadyInterest ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/interests/${inquiry.convertedInterestId}`}>View interest</a>
</Button>
) : (
<Button size="sm" disabled={busy} onClick={() => convert.mutate('interest')}>
<ArrowRight className="mr-1.5 h-4 w-4" aria-hidden />
Convert to interest
</Button>
)}
{inquiry.convertedClientId ? (
<Button variant="outline" size="sm" asChild>
<a href={`/${portSlug}/clients/${inquiry.convertedClientId}`}>View client</a>
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => convert.mutate('client')}
>
<UserPlus className="mr-1.5 h-4 w-4" aria-hidden />
Convert to client
</Button>
)}
{inquiry.triageState === 'open' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('assigned')}
>
<UserCheck className="mr-1.5 h-4 w-4" aria-hidden />
Assign to me
</Button>
) : null}
{inquiry.triageState !== 'dismissed' && inquiry.triageState !== 'converted' ? (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => triage.mutate('dismissed')}
>
<X className="mr-1.5 h-4 w-4" aria-hidden />
Dismiss
</Button>
) : null}
</div>
</PermissionGate>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { format } from 'date-fns';
import { DetailLayout, type DetailTab } from '@/components/shared/detail-layout';
import { DetailNotFound } from '@/components/shared/detail-not-found';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { usePermissions } from '@/hooks/use-permissions';
import {
KIND_LABELS,
TRIAGE_LABELS,
TRIAGE_TONE,
type InquiryKind,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryConvertActions } from '@/components/inquiries/inquiry-convert-actions';
interface InquiryDetailData {
id: string;
kind: InquiryKind;
contactName: string | null;
contactEmail: string | null;
payload: Record<string, unknown> | null;
receivedAt: string;
sourceIp: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
triageState: InquiryTriageState;
triagedAt: string | null;
convertedClientId: string | null;
convertedInterestId: string | null;
convertedClient: { id: string; fullName: string } | null;
convertedInterest: { id: string; pipelineStage: string } | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="min-w-0 break-words">
{value || <span className="text-muted-foreground"></span>}
</span>
</div>
);
}
export function InquiryDetail({ id }: { id: string }) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { isSuperAdmin } = usePermissions();
const { data, isLoading, error } = useQuery<InquiryDetailData>({
queryKey: ['inquiries', id],
queryFn: () =>
apiFetch<{ data: InquiryDetailData }>(`/api/v1/inquiries/${id}`).then((r) => r.data),
retry: (count, err) => {
const status = (err as { status?: number })?.status;
return status === 404 || status === 403 ? false : count < 2;
},
});
if (error && !isLoading) {
const status = (error as { status?: number })?.status;
return (
<DetailNotFound
entity="inquiry"
backHref={`/${portSlug}/inquiries`}
backLabel="Back to inquiries"
status={status}
/>
);
}
const p = (data?.payload ?? {}) as Record<string, unknown>;
const str = (k: string) => (typeof p[k] === 'string' ? (p[k] as string) : '');
// The free-text message a lead left. Website forms use different keys
// (contact form -> `comments`; others -> `message`/`comment`), so probe the
// common ones and surface it for every inquiry kind.
const comment = str('comments') || str('message') || str('comment') || str('notes');
const tabs: DetailTab[] = [
{
id: 'overview',
label: 'Overview',
content: (
<div className="max-w-xl">
<Row label="Name" value={data?.contactName} />
<Row label="Email" value={data?.contactEmail} />
<Row label="Phone" value={str('phone')} />
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{comment ? (
<Row label="Message" value={<span className="whitespace-pre-wrap">{comment}</span>} />
) : null}
<Row label="Type" value={data ? KIND_LABELS[data.kind] : ''} />
<Row label="Received" value={data ? format(new Date(data.receivedAt), 'PPpp') : ''} />
<Row label="Source IP" value={data?.sourceIp} />
<Row label="UTM source" value={data?.utmSource} />
<Row label="UTM medium" value={data?.utmMedium} />
<Row label="UTM campaign" value={data?.utmCampaign} />
</div>
),
},
{
id: 'tracking',
label: 'Tracking',
content: (
<div className="max-w-xl">
<Row
label="Status"
value={
data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : (
''
)
}
/>
<Row
label="Triaged at"
value={data?.triagedAt ? format(new Date(data.triagedAt), 'PPpp') : ''}
/>
<Row
label="Converted client"
value={
data?.convertedClient ? (
<a
href={`/${portSlug}/clients/${data.convertedClient.id}`}
className="text-primary hover:underline"
>
{data.convertedClient.fullName}
</a>
) : null
}
/>
<Row
label="Converted interest"
value={
data?.convertedInterest ? (
<a
href={`/${portSlug}/interests/${data.convertedInterest.id}`}
className="text-primary hover:underline"
>
View interest ({data.convertedInterest.pipelineStage})
</a>
) : null
}
/>
</div>
),
},
{
id: 'payload',
label: 'Raw payload',
content: (
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(data?.payload ?? {}, null, 2)}
</pre>
),
},
].filter((tab) => tab.id !== 'payload' || isSuperAdmin);
return (
<DetailLayout
isLoading={isLoading}
header={
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-semibold">{data?.contactName || '(no name)'}</h1>
{data ? (
<Badge className={TRIAGE_TONE[data.triageState]}>
{TRIAGE_LABELS[data.triageState]}
</Badge>
) : null}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{data ? KIND_LABELS[data.kind] : ''} inquiry
{data?.contactEmail ? ` · ${data.contactEmail}` : ''}
</p>
</div>
{data ? <InquiryConvertActions portSlug={portSlug} inquiry={data} /> : null}
</div>
}
tabs={tabs}
defaultTab="overview"
/>
);
}

View File

@@ -0,0 +1,33 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const inquiryFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search name or email…',
},
{
key: 'kind',
label: 'Type',
type: 'select',
options: [
{ label: 'Berth', value: 'berth_inquiry' },
{ label: 'Residence', value: 'residence_inquiry' },
{ label: 'Contact', value: 'contact_form' },
],
},
{
key: 'state',
label: 'Status',
type: 'select',
options: [
{ label: 'Inbox (open + assigned)', value: 'inbox' },
{ label: 'Open', value: 'open' },
{ label: 'Assigned', value: 'assigned' },
{ label: 'Converted', value: 'converted' },
{ label: 'Dismissed', value: 'dismissed' },
{ label: 'All', value: 'all' },
],
},
];

View File

@@ -0,0 +1,127 @@
'use client';
import { useEffect } from 'react';
import { useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { ColumnPicker } from '@/components/shared/column-picker';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useTablePreferences } from '@/hooks/use-table-preferences';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { inquiryFilterDefinitions } from '@/components/inquiries/inquiry-filters';
import {
getInquiryColumns,
INQUIRY_COLUMN_OPTIONS,
INQUIRY_DEFAULT_HIDDEN,
type InquiryRow,
type InquiryTriageState,
} from '@/components/inquiries/inquiry-columns';
import { InquiryCard } from '@/components/inquiries/inquiry-card';
export function InquiryList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { setChrome } = useMobileChrome();
useEffect(() => {
setChrome({ title: 'Inquiries', showBackButton: false });
return () => setChrome({ title: null, showBackButton: false });
}, [setChrome]);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InquiryRow>({
queryKey: ['inquiries'],
endpoint: '/api/v1/inquiries',
initialSort: { field: 'receivedAt', direction: 'desc' },
filterDefinitions: inquiryFilterDefinitions,
});
const triageMutation = useMutation({
mutationFn: (args: { id: string; state: InquiryTriageState }) =>
apiFetch(`/api/v1/inquiries/${args.id}/triage`, {
method: 'PATCH',
body: { state: args.state },
}),
onSuccess: (_d, vars) => {
queryClient.invalidateQueries({ queryKey: ['inquiries'] });
toast.success(`Marked ${vars.state}.`);
},
onError: (err: unknown) => toastError(err, 'Update failed'),
});
const columns = getInquiryColumns({
portSlug,
onTriage: (row, state) => triageMutation.mutate({ id: row.id, state }),
});
const { hidden, setHidden } = useTablePreferences('inquiries', INQUIRY_DEFAULT_HIDDEN);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
return (
<div className="space-y-4">
<PageHeader
title="Inquiries"
description="Submissions captured from the public marketing site (berth, residence, and contact forms)."
variant="gradient"
/>
<div className="flex flex-wrap items-center gap-2">
<FilterBar
filters={inquiryFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<div className="ml-auto flex flex-wrap items-center gap-2">
<ColumnPicker columns={INQUIRY_COLUMN_OPTIONS} hidden={hidden} onChange={setHidden} />
</div>
</div>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
cardRender={(row) => <InquiryCard inquiry={row.original} portSlug={portSlug} />}
emptyState={
<EmptyState
title="No inquiries found"
description="Submissions from the marketing site will appear here."
/>
}
/>
)}
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { Activity, ExternalLink } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = {
@@ -31,9 +32,13 @@ const PULSE_LABEL: Record<'cold' | 'warm' | 'hot', string> = {
*/
export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false);
// Master toggle: Admin → Pulse → "Show deal pulse chips" (pulse_enabled).
// Defaults ON (chip visible) when the port hasn't set it; hidden only when
// explicitly disabled.
const pulseEnabled = useFeatureFlag('pulse_enabled', true);
// Closed / archived deals don't get a pulse - UX would be confusing.
if (interest.archivedAt || interest.outcome) return null;
// Hidden when the port disabled pulse chips, or for closed/archived deals.
if (!pulseEnabled || interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest);
const tint = PULSE_TINT[health.pulse];

View File

@@ -11,14 +11,11 @@ import {
Trophy,
XCircle,
RefreshCcw,
Mail,
MessageSquarePlus,
Phone,
AlarmClock,
User,
} from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
@@ -35,6 +32,7 @@ import { AssignedToChip } from '@/components/interests/assigned-to-chip';
import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string;
clientId: string;
clientName: string | null;
/** Primary contact channels resolved from the linked client. The header
* uses these to render Email / Call / WhatsApp buttons so the rep
* doesn't have to navigate to the client page just to reach out. */
/** Primary contact channels resolved from the linked client. The
* Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.)
// CM-5: assignment UI is hidden when the per-port toggle is off (default).
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!interest.outcome;
// Contact deep-links - resolved from the linked client's primary channels.
// wa.me requires the digits-only E.164 number (no leading "+"); fall back to
// stripping non-digits from the display value when the canonical form is
// missing.
const whatsappNumber = interest.clientPrimaryPhoneE164
? interest.clientPrimaryPhoneE164.replace(/^\+/, '')
: interest.clientPrimaryPhone
? interest.clientPrimaryPhone.replace(/[^\d]/g, '')
: null;
const reopenMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount}
</span>
) : null}
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
{assignmentEnabled ? (
<PermissionGate resource="interests" action="edit">
<AssignedToChip
interestId={interest.id}
currentAssignedTo={interest.assignedTo ?? null}
currentAssignedToName={interest.assignedToName ?? null}
/>
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} />
<DealPulseChip
interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div>
)}
{/* Contact deep-links - let the rep email / call / WhatsApp the
client without leaving the interest workspace. Resolved from
the linked client's primary contact channels (server-side
fetch in getInterestById). */}
{interest.clientPrimaryEmail ||
interest.clientPrimaryPhone ||
whatsappNumber ||
interest.clientId ? (
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
) : null}
{interest.clientPrimaryEmail ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`mailto:${interest.clientPrimaryEmail}`}
aria-label={`Email ${interest.clientPrimaryEmail}`}
>
<Mail />
Email
</a>
</Button>
) : null}
{interest.clientPrimaryPhone ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`tel:${interest.clientPrimaryPhone}`}
aria-label={`Call ${interest.clientPrimaryPhone}`}
>
<Phone />
Call
</a>
</Button>
) : null}
{whatsappNumber ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
>
<a
href={`https://wa.me/${whatsappNumber}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`Message on WhatsApp`}
>
<WhatsAppIcon className="h-4 w-4" />
WhatsApp
</a>
</Button>
) : null}
{/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
Client-page link + Log-contact action stay - the rep can still
jump to the client and record outreach without leaving here. */}
<div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientId ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/${interest.clientId}` as any}
aria-label="Open client page"
>
<User />
Client page
</Link>
</Button>
</div>
) : null}
) : null}
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
onClick={() => setLogContactOpen(true)}
aria-label="Log a contact for this interest"
>
<MessageSquarePlus />
Log contact
</Button>
</div>
</div>
{/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -13,6 +13,7 @@ import {
FileSignature,
GitBranch,
Loader2,
Mail,
RefreshCw,
Upload,
XCircle,
@@ -122,6 +123,32 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
// (which the storage backend serves with Content-Disposition=attachment,
// forcing a download even when the rep just wants to inspect the PDF).
const [previewFile, setPreviewFile] = useState<{ id: string; name?: string } | null>(null);
const { confirm, dialog: confirmDialog } = useConfirmation();
// Manually (re)send the finalized signed PDF to the deal's client.
// Lifted to the parent (like the preview dialog) so every row + the
// signed-EOI hero share one confirm + handler. Guarded by a confirm so
// a stray click can't fire a real client email.
const handleSendCopy = useCallback(
async (documentId: string) => {
const ok = await confirm({
title: 'Send signed copy to client?',
description: 'Emails the deals client the finalized signed PDF as an attachment.',
confirmLabel: 'Send copy',
});
if (!ok) return;
try {
const res = await apiFetch<{ data: { recipientEmail: string } }>(
`/api/v1/documents/${documentId}/send-signed-copy`,
{ method: 'POST' },
);
toast.success(`Signed copy sent to ${res.data.recipientEmail}.`);
} catch (err) {
toastError(err, 'Failed to send signed copy');
}
},
[confirm],
);
const { data: docsRes, isLoading: docsLoading } = useQuery<{ data: DocumentRow[] }>({
queryKey: ['documents', { interestId, documentType: 'eoi' }],
@@ -134,6 +161,22 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
const docs = docsRes?.data ?? [];
const activeDoc = useMemo(() => docs.find((d) => ACTIVE_STATUSES.has(d.status)) ?? null, [docs]);
const completedDocs = useMemo(() => docs.filter((d) => !ACTIVE_STATUSES.has(d.status)), [docs]);
// Most-recent fully-signed EOI. When no EOI is in flight, this becomes
// the hero (instead of the generate/upload empty state) so a deal whose
// EOI is already done leads with the signed document, per UAT 2026-06-03.
const latestSignedDoc = useMemo(() => {
return (
docs
.filter((d) => d.status === 'completed')
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt))[0] ?? null
);
}, [docs]);
// History strip excludes whichever signed doc is shown as the hero so it
// isn't listed twice.
const historyDocs = useMemo(
() => completedDocs.filter((d) => d.id !== latestSignedDoc?.id),
[completedDocs, latestSignedDoc],
);
// Pulled at the parent so we can thread the active EOI's signers into the
// ExternalEoiUploadDialog as a prefill seed. ActiveEoiCard hits the same
@@ -176,6 +219,15 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
portSlug={portSlug ?? null}
onUploadSigned={() => setUploadSignedOpen(true)}
onView={(id, name) => setPreviewFile({ id, name })}
onSendCopy={handleSendCopy}
/>
) : latestSignedDoc ? (
<SignedEoiCard
doc={latestSignedDoc}
portSlug={portSlug ?? null}
onView={(id, name) => setPreviewFile({ id, name })}
onSendCopy={handleSendCopy}
onGenerateNew={() => setGenerateOpen(true)}
/>
) : (
<EmptyEoiState
@@ -189,18 +241,18 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
{/* History strip - completed + cancelled EOIs from earlier in the
deal's life. Quiet and skimmable; the active document above
carries the day-to-day attention. */}
{completedDocs.length > 0 && (
{historyDocs.length > 0 && (
<section className="rounded-lg border bg-background">
<header className="flex items-center justify-between border-b px-4 py-2.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
EOI history
</h3>
<span className="text-xs text-muted-foreground">
{completedDocs.length} {completedDocs.length === 1 ? 'document' : 'documents'}
{historyDocs.length} {historyDocs.length === 1 ? 'document' : 'documents'}
</span>
</header>
<ul className="divide-y">
{completedDocs.map((d) => (
{historyDocs.map((d) => (
<li key={d.id} className="flex items-center gap-3 px-4 py-2.5 text-sm">
<StatusBadge status={d.status} />
<span className="flex-1 truncate font-medium">{d.title}</span>
@@ -210,8 +262,11 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
{d.signedFileId ? (
<SignedPdfActions
fileId={d.signedFileId}
documentId={d.id}
isSignedCopySendable={d.status === 'completed'}
title={d.title}
onView={(id, name) => setPreviewFile({ id, name })}
onSendCopy={handleSendCopy}
/>
) : null}
{portSlug && (
@@ -272,6 +327,7 @@ export function InterestEoiTab({ interestId, clientId }: InterestEoiTabProps) {
fileId={previewFile?.id}
fileName={previewFile?.name}
/>
{confirmDialog}
</div>
);
}
@@ -283,11 +339,13 @@ function ActiveEoiCard({
portSlug,
onUploadSigned,
onView,
onSendCopy,
}: {
doc: DocumentRow;
portSlug: string | null;
onUploadSigned: () => void;
onView: (fileId: string, fileName?: string) => void;
onSendCopy: (documentId: string) => void;
}) {
const queryClient = useQueryClient();
const { confirm, dialog: confirmDialog } = useConfirmation();
@@ -614,7 +672,14 @@ function ActiveEoiCard({
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed document
</h3>
<SignedPdfActions fileId={doc.signedFileId} title={doc.title} onView={onView} />
<SignedPdfActions
fileId={doc.signedFileId}
documentId={doc.id}
isSignedCopySendable={doc.status === 'completed'}
title={doc.title}
onView={onView}
onSendCopy={onSendCopy}
/>
</div>
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
@@ -711,6 +776,90 @@ function ActiveEoiCard({
);
}
// ─── Signed EOI hero (no active EOI, but one is already signed) ───────────────
/**
* Shown when the deal has a fully-signed EOI and nothing is in flight. Leads
* with the signed document (preview + download + send-to-client) instead of
* the generate/upload empty state — a deal whose EOI is done shouldn't open
* on a big "Generate EOI" CTA. A quiet "Generate new EOI" remains for the
* re-issue case.
*/
function SignedEoiCard({
doc,
portSlug,
onView,
onSendCopy,
onGenerateNew,
}: {
doc: DocumentRow;
portSlug: string | null;
onView: (fileId: string, fileName?: string) => void;
onSendCopy: (documentId: string) => void;
onGenerateNew: () => void;
}) {
return (
<section className="rounded-xl border bg-gradient-brand-soft p-5 shadow-xs">
<header className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<CheckCircle2 className="size-4 text-emerald-600" aria-hidden />
<h2 className="truncate text-base font-semibold text-foreground">{doc.title}</h2>
<StatusBadge status={doc.status} />
</div>
<p className="text-xs text-muted-foreground">
Signed · {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-x-3 gap-y-1">
{doc.signedFileId ? (
<SignedPdfActions
fileId={doc.signedFileId}
documentId={doc.id}
isSignedCopySendable={doc.status === 'completed'}
title={doc.title}
onView={onView}
onSendCopy={onSendCopy}
/>
) : null}
{portSlug && (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/documents/${doc.id}` as any}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Open in Documents
<ExternalLink className="size-3" aria-hidden />
</Link>
)}
</div>
</header>
{doc.signedFileId ? (
<div className="mt-4 rounded-lg border bg-background p-4">
<SignedPdfPreview fileId={doc.signedFileId} />
</div>
) : (
<p className="mt-4 rounded-md border border-dashed bg-background p-3 text-xs text-muted-foreground">
The signed PDF isn&apos;t linked to this EOI yet, so inline preview, download, and send
aren&apos;t available. Open it in Documents this lights up once migrated EOIs are
reconciled to their signed files.
</p>
)}
<footer className="mt-3 flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
This deal&apos;s EOI is signed. Generate a new one only if you need to re-issue it.
</p>
<Button variant="ghost" size="sm" onClick={onGenerateNew} className="gap-1.5">
<FileSignature className="size-4" aria-hidden />
Generate new EOI
</Button>
</footer>
</section>
);
}
/**
* Inline iframe preview of a signed PDF. Fetches a short-lived presigned
* URL from `/api/v1/files/[id]/download` and renders the browser's native
@@ -822,12 +971,21 @@ function StatusBadge({ status }: { status: DocumentRow['status'] }) {
*/
function SignedPdfActions({
fileId,
documentId,
isSignedCopySendable = false,
title,
onView,
onSendCopy,
}: {
fileId: string;
/** Document id — required for the "Send to client" action (which targets
* the document, not the raw file). */
documentId?: string;
/** Only show "Send to client" for a fully-completed document. */
isSignedCopySendable?: boolean;
title?: string;
onView: (fileId: string, fileName?: string) => void;
onSendCopy?: (documentId: string) => void;
}) {
const handleDownload = async () => {
try {
@@ -855,6 +1013,15 @@ function SignedPdfActions({
>
<Download className="size-3" aria-hidden /> Download
</button>
{onSendCopy && documentId && isSignedCopySendable ? (
<button
type="button"
onClick={() => onSendCopy(documentId)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Mail className="size-3" aria-hidden /> Send to client
</button>
) : null}
</>
);
}

View File

@@ -379,7 +379,7 @@ export function InterestList() {
type="button"
onClick={() => setCreateOpen(true)}
aria-label="New interest"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+86px)] right-4 z-40 inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 md:hidden"
>
<Plus className="h-6 w-6" aria-hidden />
</button>

View File

@@ -19,6 +19,7 @@ import {
AccordionTrigger,
} from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
@@ -848,7 +849,18 @@ function OverviewTab({
deposit_paid: 'deposit',
contract: 'contract',
};
const stageOwnedMilestone = STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
const stageOwnedMilestoneRaw =
STAGE_TO_MILESTONE[interest.pipelineStage as PipelineStage] ?? null;
// B2 (2026-06-18): if the stage-owned milestone is already COMPLETE — e.g. a
// migrated deal left at stage=eoi with a signed EOI that never auto-advanced —
// don't pin it as the current "NEXT STEP". Falling back to null makes phaseFor
// use completion ordering, so the signed milestone shows as done/past and the
// next incomplete one (Reservation) becomes current. Display-only; the
// pipeline_stage column is unchanged.
const stageOwnedMilestoneComplete = stageOwnedMilestoneRaw
? milestoneCompletion[stageOwnedMilestoneRaw]
: false;
const stageOwnedMilestone = stageOwnedMilestoneComplete ? null : stageOwnedMilestoneRaw;
const stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed.
@@ -1122,6 +1134,9 @@ function OverviewTab({
archivedAt={null}
/>
{/* CM-9: per-deal point-of-contact (overrides the client's default). */}
<ProxyCard entityType="interest" entityId={interestId} />
{/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */}

View File

@@ -20,7 +20,7 @@ import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Anchor, Loader2, Plus, Star, Trash2 } from 'lucide-react';
import { Anchor, Loader2, Pin, Plus, Star, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -67,9 +67,12 @@ export interface LinkedBerthRow {
addedBy: string | null;
addedAt: string;
notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null;
area: string | null;
status: string;
statusOverrideMode: string | null;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
@@ -192,6 +195,24 @@ function useRemoveLink(interestId: string) {
});
}
// CM-2 Part B: set/clear the deal-specific price override for one berth.
function useSetBerthPrice(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { berthId: string; price: number | null }) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
method: 'PUT',
body: { price: args.price },
}),
onSuccess: (_data, args) => {
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (e: Error) => toastError(e),
});
}
// ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps {
@@ -288,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
const setBerthPrice = useSetBerthPrice(interestId);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed';
const commitPrice = () => {
const raw = priceDraft.replace(/[,\s]/g, '');
const next = raw === '' ? null : Number(raw);
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
if (next === prev) return;
setBerthPrice.mutate({ berthId: row.berthId, price: next });
};
return (
<div
className={cn(
@@ -330,6 +362,15 @@ function LinkedBerthRowItem({
EOI bypassed
</span>
) : null}
{row.isSpecificInterest && row.statusOverrideMode === 'manual' ? (
<span
className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-900"
title={`This berth's status is manually pinned, which overrides "Specifically pitching" on the public map. It will display as "${formatStatus(row.status)}" — not "Under Offer" — until the pin is cleared (edit the berth's status).`}
>
<Pin className="size-3" aria-hidden />
Pin overrides pitch
</span>
) : null}
</div>
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
</div>
@@ -400,7 +441,11 @@ function LinkedBerthRowItem({
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
{row.isSpecificInterest ? SPECIFIC_CONSEQUENCE_ON : SPECIFIC_CONSEQUENCE_OFF}
{row.isSpecificInterest && row.statusOverrideMode === 'manual'
? `Overridden: this berth's status is manually pinned, so the public map shows “${formatStatus(row.status)}”, not “Under Offer”. Clear the pin on the berth to let this take effect.`
: row.isSpecificInterest
? SPECIFIC_CONSEQUENCE_ON
: SPECIFIC_CONSEQUENCE_OFF}
</p>
</div>
<div className="space-y-1">
@@ -444,6 +489,34 @@ function LinkedBerthRowItem({
</div>
</TooltipProvider>
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
this interest only; flows into the EOI/document {{berth.price}} token. */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Deal price</p>
<p className="text-xs text-muted-foreground">
Overrides the berth&apos;s list price for this deal only. Leave blank to use the list
price.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
placeholder="List price"
value={priceDraft}
disabled={isPending || setBerthPrice.isPending}
onChange={(e) => setPriceDraft(e.target.value)}
onBlur={commitPrice}
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
/>
{row.priceOverrideCurrency ? (
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
) : null}
</div>
</div>
{showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't

View File

@@ -105,7 +105,10 @@ export function InvoiceCard({
</DropdownMenuItem>
{invoice.pdfFileId ? (
<DropdownMenuItem asChild>
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
<Link
href={`/api/v1/files/${invoice.pdfFileId}/preview?redirect=1`}
target="_blank"
>
<FileText className="mr-2 h-3.5 w-3.5" />
View PDF
</Link>

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