30 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
188 changed files with 10758 additions and 570 deletions

3
.gitignore vendored
View File

@@ -65,3 +65,6 @@ tmp/
# Internal docs + Claude instructions: kept local-only, not in the shared repo # Internal docs + Claude instructions: kept local-only, not in the shared repo
docs/ docs/
/CLAUDE.md /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/ \ && rsync -a --ignore-existing /opt/prod-node-modules/ ./node_modules/ \
&& rm -rf /opt/prod-node-modules \ && rm -rf /opt/prod-node-modules \
&& apk del .merge-deps && 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 USER nextjs
EXPOSE 3000 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ 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 corepack enable && corepack prepare pnpm@10.33.2 --activate
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 worker
WORKDIR /app 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 RUN chown -R worker:nodejs /app
USER worker USER worker
COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./ COPY --chown=worker:nodejs package.json pnpm-lock.yaml ./

View File

@@ -126,6 +126,7 @@
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"ssh2-sftp-client": "^12.1.1",
"svgo": "^4.0.1", "svgo": "^4.0.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
@@ -154,6 +155,7 @@
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.6", "@vitest/coverage-v8": "^4.1.6",

111
pnpm-lock.yaml generated
View File

@@ -295,6 +295,9 @@ importers:
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) 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: svgo:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@@ -374,6 +377,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.3 specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@types/ssh2-sftp-client':
specifier: ^9.0.6
version: 9.0.6
'@types/topojson-client': '@types/topojson-client':
specifier: ^3.1.5 specifier: ^3.1.5
version: 3.1.5 version: 3.1.5
@@ -3095,6 +3101,9 @@ packages:
'@types/node@14.18.63': '@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==} resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.41': '@types/node@20.19.41':
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
@@ -3127,6 +3136,12 @@ packages:
'@types/readdir-glob@1.1.5': '@types/readdir-glob@1.1.5':
resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} 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': '@types/tedious@4.0.14':
resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==}
@@ -3582,6 +3597,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -3694,6 +3712,9 @@ packages:
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-auth@1.6.11: better-auth@1.6.11:
resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==} resolution: {integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==}
peerDependencies: peerDependencies:
@@ -3856,6 +3877,10 @@ packages:
resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==} resolution: {integrity: sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==}
engines: {node: '>=0.2.0'} 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: bullmq@5.76.8:
resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==} resolution: {integrity: sha512-v3WTwA8diFtsADaJ8eK2ozyi2CYK9rDZCeoKF+dIPF/MUL8HxAOa3H72Gmu1lC4yKlho6t1PwNr/QpDVqaNEZQ==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
@@ -3995,6 +4020,10 @@ packages:
concat-map@0.0.1: concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
conf@15.1.0: conf@15.1.0:
resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==} resolution: {integrity: sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -4023,6 +4052,10 @@ packages:
country-flag-icons@1.6.17: country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} 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: crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@@ -5723,6 +5756,9 @@ packages:
msgpackr@2.0.1: msgpackr@2.0.1:
resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==} resolution: {integrity: sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==}
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
nanoid@3.3.12: nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -6629,6 +6665,14 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} 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: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
@@ -6966,6 +7010,9 @@ packages:
tw-animate-css@1.4.0: tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -6994,6 +7041,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
typescript-eslint@8.59.3: typescript-eslint@8.59.3:
resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -7021,6 +7071,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -9990,6 +10043,10 @@ snapshots:
'@types/node@14.18.63': {} '@types/node@14.18.63': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.41': '@types/node@20.19.41':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -10030,6 +10087,14 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.41 '@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': '@types/tedious@4.0.14':
dependencies: dependencies:
'@types/node': 20.19.41 '@types/node': 20.19.41
@@ -10582,6 +10647,10 @@ snapshots:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
is-array-buffer: 3.0.5 is-array-buffer: 3.0.5
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {} ast-types-flow@0.0.8: {}
@@ -10663,6 +10732,10 @@ snapshots:
baseline-browser-mapping@2.10.29: {} 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): 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: 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) '@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)
@@ -10803,6 +10876,9 @@ snapshots:
buffers@0.1.1: {} buffers@0.1.1: {}
buildcheck@0.0.7:
optional: true
bullmq@5.76.8: bullmq@5.76.8:
dependencies: dependencies:
cron-parser: 4.9.0 cron-parser: 4.9.0
@@ -10936,6 +11012,13 @@ snapshots:
concat-map@0.0.1: {} 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: conf@15.1.0:
dependencies: dependencies:
ajv: 8.20.0 ajv: 8.20.0
@@ -10971,6 +11054,12 @@ snapshots:
country-flag-icons@1.6.17: {} 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: {} crc-32@1.2.2: {}
crc32-stream@4.0.3: crc32-stream@4.0.3:
@@ -12736,6 +12825,9 @@ snapshots:
optionalDependencies: optionalDependencies:
msgpackr-extract: 3.0.3 msgpackr-extract: 3.0.3
nan@2.27.0:
optional: true
nanoid@3.3.12: {} nanoid@3.3.12: {}
nanostores@1.3.0: {} nanostores@1.3.0: {}
@@ -13765,6 +13857,19 @@ snapshots:
split2@4.2.0: {} 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: {} stable-hash@0.0.5: {}
stackback@0.0.2: {} stackback@0.0.2: {}
@@ -14103,6 +14208,8 @@ snapshots:
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -14146,6 +14253,8 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 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): typescript-eslint@8.59.3(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3):
dependencies: 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) '@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)
@@ -14172,6 +14281,8 @@ snapshots:
has-symbols: 1.1.0 has-symbols: 1.1.0
which-boxed-primitive: 1.1.1 which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici@7.25.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 { BackupAdminPanel } from '@/components/admin/backup-admin-panel';
import { BackupDestinationsCard } from '@/components/admin/backup-destinations-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
export default function BackupManagementPage() { export default function BackupManagementPage() {
@@ -7,9 +8,10 @@ export default function BackupManagementPage() {
<PageHeader <PageHeader
title="Backup & Restore" title="Backup & Restore"
eyebrow="ADMIN" 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 /> <BackupAdminPanel />
<BackupDestinationsCard />
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import type { Route } from 'next'; 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 { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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.", "Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
icon: FileSearch, 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; ] as const;
return ( 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 { WebhookHealthCard } from '@/components/admin/documenso/webhook-health-card';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 // All field arrays removed - every Documenso setting now flows through
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global // `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." 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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <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 { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service'; import { isExpensesModuleEnabled } from '@/lib/services/expenses-module.service';
import { isResidentialModuleEnabled } from '@/lib/services/residential-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 }) { export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const headerList = await headers(); const headerList = await headers();
@@ -127,12 +128,29 @@ export default async function DashboardLayout({ children }: { children: React.Re
const residentialModuleByPort: Record<string, boolean> = const residentialModuleByPort: Record<string, boolean> =
Object.fromEntries(residentialModuleEntries); 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 ( return (
<QueryProvider> <QueryProvider>
<PortProvider <PortProvider
ports={ports} ports={ports}
defaultPortId={ports[0]?.id ?? null} defaultPortId={ports[0]?.id ?? null}
tenanciesModuleByPort={tenanciesModuleByPort} tenanciesModuleByPort={tenanciesModuleByPort}
maintenanceModuleByPort={maintenanceModuleByPort}
> >
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>

View File

@@ -5,6 +5,7 @@ import { ScanShell } from '@/components/scan/scan-shell';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports'; import { ports } from '@/lib/db/schema/ports';
import { getPortBrandingConfig } from '@/lib/services/port-config'; import { getPortBrandingConfig } from '@/lib/services/port-config';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt', title: 'Scan receipt',
@@ -14,5 +15,14 @@ export default async function ScanPage({ params }: { params: Promise<{ portSlug:
const { portSlug } = await params; const { portSlug } = await params;
const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) }); const port = await db.query.ports.findFirst({ where: eq(ports.slug, portSlug) });
const branding = port ? await getPortBrandingConfig(port.id).catch(() => null) : null; 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, autoPromoteWebsiteBerthInquiry,
isWebsiteBerthAutopromoteEnabled, isWebsiteBerthAutopromoteEnabled,
} from '@/lib/services/website-intake-promote.service'; } from '@/lib/services/website-intake-promote.service';
import { extractInquiryFields } from '@/lib/services/website-intake-fields';
/** /**
* POST /api/public/website-inquiries * 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 // 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 // return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup. // 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 const insertResult = await db
.insert(websiteSubmissions) .insert(websiteSubmissions)
.values({ .values({
@@ -157,6 +162,8 @@ export async function POST(req: NextRequest) {
kind: parsed.kind, kind: parsed.kind,
payload: parsed.payload, payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null, legacyNocodbId: parsed.legacy_nocodb_id ?? null,
contactName: fields.fullName || null,
contactEmail: fields.email || null,
sourceIp: ip, sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null, userAgent: req.headers.get('user-agent') ?? null,
utmSource: parsed.utm_source ?? 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(), clearApiKey: z.boolean().optional(),
useGlobal: z.boolean().optional(), useGlobal: z.boolean().optional(),
aiEnabled: z.boolean().optional(), aiEnabled: z.boolean().optional(),
manualEntry: z.boolean().optional(),
}); });
// Only role tiers that hold `admin.manage_settings` (director / super_admin) // Only role tiers that hold `admin.manage_settings` (director / super_admin)
@@ -58,6 +59,7 @@ export const PUT = withAuth(
clearApiKey: body.clearApiKey, clearApiKey: body.clearApiKey,
useGlobal: body.useGlobal, useGlobal: body.useGlobal,
aiEnabled: body.aiEnabled, aiEnabled: body.aiEnabled,
manualEntry: body.manualEntry,
}, },
ctx.userId, 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 { parseBody } from '@/lib/api/route-helpers';
import { updateMaintenanceLogSchema } from '@/lib/validators/berths'; import { updateMaintenanceLogSchema } from '@/lib/validators/berths';
import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service'; import { updateMaintenanceLog, deleteMaintenanceLog } from '@/lib/services/berths.service';
import { assertMaintenanceModuleEnabled } from '@/lib/services/maintenance-module.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// PATCH /api/v1/berths/[id]/maintenance/[logId] // PATCH /api/v1/berths/[id]/maintenance/[logId]
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => { withPermission('berths', 'edit', async (req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
const body = await parseBody(req, updateMaintenanceLogSchema); const body = await parseBody(req, updateMaintenanceLogSchema);
const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, { const log = await updateMaintenanceLog(params.id!, params.logId!, ctx.portId, body, {
userId: ctx.userId, userId: ctx.userId,
@@ -28,6 +30,7 @@ export const PATCH = withAuth(
export const DELETE = withAuth( export const DELETE = withAuth(
withPermission('berths', 'edit', async (_req, ctx, params) => { withPermission('berths', 'edit', async (_req, ctx, params) => {
try { try {
await assertMaintenanceModuleEnabled(ctx.portId);
await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, { await deleteMaintenanceLog(params.id!, params.logId!, ctx.portId, {
userId: ctx.userId, userId: ctx.userId,
portId: ctx.portId, portId: ctx.portId,

View File

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

@@ -48,6 +48,14 @@ export const POST = withAuth(
} }
const config = await getResolvedOcrConfig(ctx.portId); 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 // Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on // an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its // 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) => { withPermission('files', 'view', async (req, ctx, params) => {
try { try {
const result = await getDownloadUrl(params.id!, ctx.portId); 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 }); return NextResponse.json({ data: result });
} catch (error) { } catch (error) {
return errorResponse(error); return errorResponse(error);

View File

@@ -8,6 +8,12 @@ export const GET = withAuth(
withPermission('files', 'view', async (req, ctx, params) => { withPermission('files', 'view', async (req, ctx, params) => {
try { try {
const result = await getPreviewUrl(params.id!, ctx.portId); 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 }); return NextResponse.json({ data: result });
} catch (error) { } catch (error) {
return errorResponse(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)), 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) { } catch (error) {
return errorResponse(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'; 'use client';
import { useEffect, useState } from 'react'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button'; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card> <Card>
@@ -126,6 +145,32 @@ export function BackupAdminPanel() {
</CardContent> </CardContent>
</Card> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">History</CardTitle> <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; hasApiKey: boolean;
useGlobal: boolean; useGlobal: boolean;
aiEnabled: boolean; aiEnabled: boolean;
manualEntry: boolean;
}; };
models: Record<Provider, string[]>; models: Record<Provider, string[]>;
} }
@@ -54,7 +55,7 @@ function SettingsBlock(props: SettingsBlockProps) {
// Key the body on the loaded payload so useState initializers seed // Key the body on the loaded payload so useState initializers seed
// from server values cleanly. // from server values cleanly.
const sig = data?.data 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'; : 'loading';
return ( return (
<SettingsBlockBody <SettingsBlockBody
@@ -89,6 +90,7 @@ function SettingsBlockBody({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false); const [useGlobal, setUseGlobal] = useState(data?.data.useGlobal ?? false);
const [aiEnabled, setAiEnabled] = useState(data?.data.aiEnabled ?? 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 }>( const [testStatus, setTestStatus] = useState<null | { ok: true } | { ok: false; reason: string }>(
null, null,
); );
@@ -105,6 +107,7 @@ function SettingsBlockBody({
clearApiKey: Boolean(clearApiKey), clearApiKey: Boolean(clearApiKey),
useGlobal: scope === 'global' ? false : useGlobal, useGlobal: scope === 'global' ? false : useGlobal,
aiEnabled: scope === 'global' ? false : aiEnabled, aiEnabled: scope === 'global' ? false : aiEnabled,
manualEntry: scope === 'global' ? false : manualEntry,
}, },
}), }),
onSuccess: () => { onSuccess: () => {
@@ -190,6 +193,25 @@ function SettingsBlockBody({
</div> </div>
) : null} ) : 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="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor={`provider-${scope}`}>Provider</Label> <Label htmlFor={`provider-${scope}`}>Provider</Label>

View File

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

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, 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', key: 'tenancies_module_enabled',
label: 'Tenancies Module', label: 'Tenancies Module',
@@ -72,6 +80,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean', type: 'boolean',
defaultValue: true, 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', key: 'ai_interest_scoring',
label: 'AI Interest Scoring', label: 'AI Interest Scoring',

View File

@@ -62,7 +62,7 @@ export function AlertCard({ alert, readOnly = false }: AlertCardProps) {
</div> </div>
</div> </div>
{!readOnly ? ( {!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 ? ( {!acknowledged ? (
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from 'react';
import { ShieldAlert } from 'lucide-react'; import { ShieldAlert } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { AlertCard, AlertCardEmpty } from './alert-card'; 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'; import type { AlertStatus } from './types';
/** /**
@@ -30,6 +31,7 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
const total = count?.total ?? 0; const total = count?.total ?? 0;
const alerts = data?.data ?? []; const alerts = data?.data ?? [];
const dismissAll = useDismissAll();
return ( return (
<div className={embedded ? 'space-y-3' : 'space-y-6'}> <div className={embedded ? 'space-y-3' : 'space-y-6'}>
@@ -62,6 +64,18 @@ export function AlertsPageShell({ embedded = false }: AlertsPageShellProps = {})
</TabsList> </TabsList>
<TabsContent value={tab} className="mt-4 space-y-2"> <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 ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-20 w-full" /> <Skeleton className="h-20 w-full" />

View File

@@ -41,6 +41,15 @@ export function useAlertActions() {
return { acknowledge, dismiss }; 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() { export function useAlertRealtime() {
useRealtimeInvalidation({ useRealtimeInvalidation({
'alert:created': [['alerts']], 'alert:created': [['alerts']],

View File

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

View File

@@ -19,8 +19,10 @@
'use client'; 'use client';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import dynamic from 'next/dynamic';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChevronDown, ChevronRight, Download } from 'lucide-react';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; 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 { Badge } from '@/components/ui/badge';
import { PdfReconcileDialog } from './pdf-reconcile-dialog'; 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 { interface PdfVersionRow {
id: string; id: string;
versionNumber: number; versionNumber: number;
@@ -53,6 +70,7 @@ interface UploadUrlResponse {
export function BerthDocumentsTab({ berthId }: { berthId: string }) { export function BerthDocumentsTab({ berthId }: { berthId: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [previewOpen, setPreviewOpen] = useState(true);
const [pendingDiff, setPendingDiff] = useState<{ const [pendingDiff, setPendingDiff] = useState<{
versionId: string; versionId: string;
autoApplied: Array<{ field: string; value: string | number }>; autoApplied: Array<{ field: string; value: string | number }>;
@@ -187,24 +205,45 @@ export function BerthDocumentsTab({ berthId }: { berthId: string }) {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 text-sm"> <CardContent className="space-y-3 pt-0 text-sm">
{isLoading ? ( {isLoading ? (
<p className="text-muted-foreground">Loading</p> <p className="text-muted-foreground">Loading</p>
) : current ? ( ) : current ? (
<div className="flex flex-wrap items-center gap-2"> <>
<a <div className="flex flex-wrap items-center gap-2">
href={current.downloadUrl} <button
target="_blank" type="button"
rel="noreferrer" onClick={() => setPreviewOpen((o) => !o)}
className="font-medium underline underline-offset-2" className="inline-flex items-center gap-1 font-medium underline-offset-2 hover:underline"
> aria-expanded={previewOpen}
{current.fileName} >
</a> {previewOpen ? (
<span className="text-muted-foreground"> <ChevronDown className="size-3.5 shrink-0" aria-hidden />
v{current.versionNumber} · {(current.fileSizeBytes / 1024 / 1024).toFixed(2)} MB ) : (
</span> <ChevronRight className="size-3.5 shrink-0" aria-hidden />
{current.parseEngine ? <ParseEngineBadge engine={current.parseEngine} /> : null} )}
</div> {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> <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 Link from 'next/link';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import { Anchor, Archive, CircleDollarSign, Plus, Tag as TagIcon, TagsIcon } from 'lucide-react';
Anchor,
Archive,
CircleDollarSign,
Plus,
Rows3,
Rows4,
Tag as TagIcon,
TagsIcon,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
@@ -103,8 +94,10 @@ export function BerthList() {
// Persisted column visibility + row density + dimension unit - same // Persisted column visibility + row density + dimension unit - same
// pattern as ClientList / InterestList; density falls back to // pattern as ClientList / InterestList; density falls back to
// 'comfortable' and dimensionUnit to 'ft' for users who haven't picked. // 'comfortable' and dimensionUnit to 'ft' for users who haven't picked.
const { hidden, setHidden, density, setDensity, dimensionUnit, setDimensionUnit } = const { hidden, setHidden, dimensionUnit, setDimensionUnit } = useTablePreferences(
useTablePreferences('berths', BERTH_DEFAULT_HIDDEN); 'berths',
BERTH_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const berthColumns = getBerthColumns(dimensionUnit); const berthColumns = getBerthColumns(dimensionUnit);
@@ -187,29 +180,11 @@ export function BerthList() {
applyView({ filters: savedFilters, sort: savedSort }); applyView({ filters: savedFilters, sort: savedSort });
}} }}
/> />
{/* Table-only controls — hidden in card mode (<lg, matching {/* Table-only controls — hidden in card mode (<md, matching
DataTable's table/card switch). The BerthCard ignores row DataTable's table/card switch). The BerthCard ignores the
density + dimension unit and renders no column set, so these dimension unit + renders no column set, so these toggles have
toggles have no visible effect there and read as broken. */} no visible effect there. */}
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<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 <Button
type="button" type="button"
size="sm" size="sm"
@@ -238,7 +213,6 @@ export function BerthList() {
<DataTable<BerthRow> <DataTable<BerthRow>
columns={berthColumns} columns={berthColumns}
columnVisibility={columnVisibility} columnVisibility={columnVisibility}
density={density}
data={data} data={data}
isLoading={isLoading} isLoading={isLoading}
pagination={{ pagination={{

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { stageBadgeClass, stageLabel } from '@/lib/constants'; import { stageBadgeClass, stageLabel } from '@/lib/constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -80,27 +81,74 @@ export function BerthOccupancyChip({
competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!; competing.find((r) => r.isInEoiBundle) ?? competing.find((r) => r.isPrimary) ?? competing[0]!;
const extras = competing.length - 1; const extras = competing.length - 1;
return ( const chipClass = cn(
<Link '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',
href={`/${portSlug}/interests/${primary.interestId}` as never} // Cap tight on narrow viewports, but give the name room on desktop
onClick={(e) => e.stopPropagation()} // so it isn't truncated to "Philippe Ca…" (UAT 2026-06-03).
className={cn( compact && 'max-w-[200px] md:max-w-[460px]',
'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]',
)} const stageChip = (stage: string) => (
title={`Open ${primary.clientName} (${stageLabel(primary.pipelineStage)})`} <span className={cn('shrink-0 rounded-full px-1.5 text-xs', stageBadgeClass(stage))}>
> {stageLabel(stage)}
<span className="font-medium">Under offer to:</span> </span>
<span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span> );
<span
className={cn( // Single competing interest → the chip is a direct link to it.
'shrink-0 rounded-full px-1.5 text-xs', if (competing.length === 1) {
stageBadgeClass(primary.pipelineStage), 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 className="font-medium">Under offer to:</span>
</span> <span className={cn(compact && 'truncate min-w-0')}>{primary.clientName}</span>
{extras > 0 ? <span className="shrink-0 text-amber-700">+{extras} more</span> : null} {stageChip(primary.pipelineStage)}
</Link> </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

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

@@ -3,11 +3,9 @@
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import type { Route } from 'next'; import type { Route } from 'next';
import { useState } from 'react'; import { useState } from 'react';
import { Archive, Bell, Mail, Phone, RotateCcw, Trash2 } from 'lucide-react'; import { Archive, Bell, RotateCcw, Trash2 } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge'; import { TagBadge } from '@/components/shared/tag-badge';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
@@ -56,18 +54,6 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email')?.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 country = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null;
const addedLabel = client.createdAt const addedLabel = client.createdAt
@@ -107,52 +93,11 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
</p> </p>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-1.5 pt-1"> {/* CM-4: Email/Call/WhatsApp deep-link pills removed at client
{primaryEmail ? ( request. GDPR export moved to the top-right action cluster.
<Button Portal-invite stays as the one primary CTA here. */}
asChild {!isArchived && client.clientPortalEnabled === true ? (
variant="outline" <div className="flex flex-wrap items-center gap-1.5 pt-1">
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 ? (
<div className="hidden sm:inline-flex"> <div className="hidden sm:inline-flex">
<PortalInviteButton <PortalInviteButton
clientId={client.id} clientId={client.id}
@@ -160,11 +105,8 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
defaultEmail={primaryEmail} defaultEmail={primaryEmail}
/> />
</div> </div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div> </div>
</div> ) : null}
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -179,6 +121,9 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
right perm) permanently-delete. Destructive actions sit out right perm) permanently-delete. Destructive actions sit out
of the primary action flow. */} of the primary action flow. */}
<div className="flex items-start gap-1"> <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 && ( {isArchived && (
<PermissionGate resource="admin" action="permanently_delete_clients"> <PermissionGate resource="admin" action="permanently_delete_clients">
<button <button

View File

@@ -11,6 +11,7 @@ import { RemindersInline } from '@/components/reminders/reminders-inline';
import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab'; import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary'; import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
@@ -156,6 +157,9 @@ function OverviewTab({
<ClientPipelineSummary clientId={clientId} variant="panel" /> <ClientPipelineSummary clientId={clientId} variant="panel" />
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <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', 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 { can, isSuperAdmin } = usePermissions();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -110,10 +118,21 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="sm" className="h-8"> {variant === 'icon' ? (
<FileDown className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <button
GDPR export type="button"
</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> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import type { Route } from 'next'; import type { Route } from 'next';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query'; 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) {
); );
})} })}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<li className="pt-1 text-xs text-muted-foreground"> <li className="border-t pt-2 text-right">
+ {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown. <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> </li>
) : null} ) : null}
</ol> </ol>

View File

@@ -164,7 +164,10 @@ export const DASHBOARD_WIDGETS: readonly DashboardWidget[] = [
label: 'Pipeline Value', label: 'Pipeline Value',
description: description:
'Gross + weighted forecast, broken down by pipeline stage so leadership can see what is near-close vs speculative.', '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 // Lives in the chart grid (not the narrow rail) so the per-stage
// breakdown rows have room to breathe alongside the headline numbers, // breakdown rows have room to breathe alongside the headline numbers,
// and the rail stays reserved for reminders / alerts / glance tiles. // 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 ? ( {isComplete && doc.signedFileId ? (
<> <>
<Button asChild size="sm"> <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 <Download className="mr-1.5 h-4 w-4" aria-hidden /> Download signed PDF
</Link> </Link>
</Button> </Button>

View File

@@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Button } from '@/components/ui/button';
import { import {
@@ -16,9 +25,11 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api/client';
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
import { EmptyState } from '@/components/ui/empty-state'; import { EmptyState } from '@/components/ui/empty-state';
import { FileUploadZone } from '@/components/files/file-upload-zone'; import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
import { PermissionGate } from '@/components/shared/permission-gate'; import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { usePaginatedQuery } from '@/hooks/use-paginated-query';
@@ -336,6 +347,30 @@ function FlatFolderListing({
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined); const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
const [expandedDocId, setExpandedDocId] = useState<string | null>(null); const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
const [uploadOpen, setUploadOpen] = useState(false); 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 queryClient = useQueryClient();
const queryParams = useMemo(() => { const queryParams = useMemo(() => {
@@ -489,9 +524,10 @@ function FlatFolderListing({
}; };
// Uploaded-file row — simpler than a signature doc since there's no // Uploaded-file row — simpler than a signature doc since there's no
// signer/status concept. Links to the underlying file via download URL // signer/status concept. Clicking the name opens an inline preview
// and surfaces an "Uploaded" type pill so the rep distinguishes it // (FilePreviewDialog); a dedicated download button saves the file. An
// from signature workflows at a glance. // "Uploaded" type pill distinguishes it from signature workflows.
const fileLabel = (file: HubFile) => file.originalName ?? file.filename;
const renderFileRow = (file: HubFile) => { const renderFileRow = (file: HubFile) => {
return ( return (
<li <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"> <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 */} {/* Empty action column to align with doc-row layout */}
<span className="hidden h-[44px] w-[44px] sm:block" aria-hidden /> <span className="hidden h-[44px] w-[44px] sm:block" aria-hidden />
<a <button
href={`/api/v1/files/${file.id}/download`} type="button"
target="_blank" onClick={() => setPreviewFile(file)}
rel="noreferrer" className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand"
className="min-w-0 truncate font-medium text-foreground hover:text-brand" title={`Preview ${fileLabel(file)}`}
> >
{file.originalName ?? file.filename} {fileLabel(file)}
</a> </button>
<span className="text-xs text-muted-foreground">Uploaded file</span> <span className="text-xs text-muted-foreground">Uploaded file</span>
<StatusPill status="completed" withDot> <StatusPill status="completed" withDot>
Stored Stored
@@ -516,9 +552,21 @@ function FlatFolderListing({
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{(file.sizeBytes / 1024).toFixed(0)} KB {(file.sizeBytes / 1024).toFixed(0)} KB
</span> </span>
<span className="text-xs tabular-nums text-muted-foreground"> <div className="flex items-center gap-2">
{new Date(file.createdAt).toLocaleDateString(undefined)} <span className="text-xs tabular-nums text-muted-foreground">
</span> {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> </div>
</li> </li>
); );
@@ -526,6 +574,15 @@ function FlatFolderListing({
return ( return (
<div className="space-y-4"> <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"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="Search by title..." 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"> <div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span> <span className="truncate">{mime || (isError ? 'Receipt' : 'File')}</span>
<a <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" className="inline-flex items-center gap-1 text-primary hover:underline"
> >
<Download className="h-3 w-3" aria-hidden /> Download <Download className="h-3 w-3" aria-hidden /> Download

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The // useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set. // 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], queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`), queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId, enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading; const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -121,12 +127,24 @@ export function FilePreviewDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate"> <DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span> <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 && ( {previewUrl && (
<a <a
href={previewUrl} href={previewUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground" 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" /> <ExternalLink className="h-4 w-4" />
</a> </a>

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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health'; import { computeDealHealth, type DealHealthInput } from '@/lib/services/deal-health';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const PULSE_TINT: Record<'cold' | 'warm' | 'hot', string> = { 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 }) { export function DealPulseChip({ interest }: { interest: DealHealthInput }) {
const [open, setOpen] = useState(false); 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. // Hidden when the port disabled pulse chips, or for closed/archived deals.
if (interest.archivedAt || interest.outcome) return null; if (!pulseEnabled || interest.archivedAt || interest.outcome) return null;
const health = computeDealHealth(interest); const health = computeDealHealth(interest);
const tint = PULSE_TINT[health.pulse]; const tint = PULSE_TINT[health.pulse];

View File

@@ -11,14 +11,11 @@ import {
Trophy, Trophy,
XCircle, XCircle,
RefreshCcw, RefreshCcw,
Mail,
MessageSquarePlus, MessageSquarePlus,
Phone,
AlarmClock, AlarmClock,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab'; import { ComposeDialog as ContactLogComposeSheet } from '@/components/interests/interest-contact-log-tab';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@/components/ui/button'; 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 { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
import { DealPulseChip } from '@/components/interests/deal-pulse-chip'; import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { formatOutcome } from '@/lib/constants'; import { formatOutcome } from '@/lib/constants';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label'; import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -74,9 +72,9 @@ interface InterestDetailHeaderProps {
id: string; id: string;
clientId: string; clientId: string;
clientName: string | null; clientName: string | null;
/** Primary contact channels resolved from the linked client. The header /** Primary contact channels resolved from the linked client. The
* uses these to render Email / Call / WhatsApp buttons so the rep * Email/Call/WhatsApp pills were removed (CM-4); these stay on the payload
* doesn't have to navigate to the client page just to reach out. */ * for downstream reuse (e.g. proxy comms routing, CM-9). */
clientPrimaryEmail?: string | null; clientPrimaryEmail?: string | null;
clientPrimaryPhone?: string | null; clientPrimaryPhone?: string | null;
clientPrimaryPhoneE164?: string | null; clientPrimaryPhoneE164?: string | null;
@@ -144,21 +142,13 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [logContactOpen, setLogContactOpen] = useState(false); const [logContactOpen, setLogContactOpen] = useState(false);
const [reminderOpen, setReminderOpen] = useState(false); const [reminderOpen, setReminderOpen] = useState(false);
// (Upload-paper-signed-EOI dialog moved to the EOI tab.) // (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 isArchived = !!interest.archivedAt;
const outcomeBadge = resolveOutcomeBadge(interest.outcome); const outcomeBadge = resolveOutcomeBadge(interest.outcome);
const isClosed = !!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({ const reopenMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }), apiFetch(`/api/v1/interests/${interest.id}/outcome`, { method: 'DELETE', body: {} }),
@@ -285,13 +275,15 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
{interest.activeReminderCount} {interest.activeReminderCount}
</span> </span>
) : null} ) : null}
<PermissionGate resource="interests" action="edit"> {assignmentEnabled ? (
<AssignedToChip <PermissionGate resource="interests" action="edit">
interestId={interest.id} <AssignedToChip
currentAssignedTo={interest.assignedTo ?? null} interestId={interest.id}
currentAssignedToName={interest.assignedToName ?? null} currentAssignedTo={interest.assignedTo ?? null}
/> currentAssignedToName={interest.assignedToName ?? null}
</PermissionGate> />
</PermissionGate>
) : null}
<MultiEoiChip interestId={interest.id} /> <MultiEoiChip interestId={interest.id} />
<DealPulseChip <DealPulseChip
interest={{ interest={{
@@ -340,94 +332,38 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</div> </div>
)} )}
{/* Contact deep-links - let the rep email / call / WhatsApp the {/* CM-4: Email/Call/WhatsApp deep-links removed at client request.
client without leaving the interest workspace. Resolved from Client-page link + Log-contact action stay - the rep can still
the linked client's primary contact channels (server-side jump to the client and record outreach without leaving here. */}
fetch in getInterestById). */} <div className="flex flex-wrap items-center gap-1.5 pt-1">
{interest.clientPrimaryEmail || {interest.clientId ? (
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}
<Button <Button
asChild
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5" 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 /> <Link
Log contact // 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> </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> </div>
{/* Top-right actions. Won/Lost are sales-critical and read as text {/* Top-right actions. Won/Lost are sales-critical and read as text

View File

@@ -19,6 +19,7 @@ import {
AccordionTrigger, AccordionTrigger,
} from '@/components/ui/accordion'; } from '@/components/ui/accordion';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import { ProxyCard } from '@/components/shared/proxy-card';
import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history'; import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { ClientChannelEditor } from '@/components/clients/client-channel-editor'; import { ClientChannelEditor } from '@/components/clients/client-channel-editor';
@@ -848,7 +849,18 @@ function OverviewTab({
deposit_paid: 'deposit', deposit_paid: 'deposit',
contract: 'contract', 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 stageOwnedIdx = stageOwnedMilestone ? order.indexOf(stageOwnedMilestone) : -1;
const phaseFor = (k: (typeof order)[number]): Phase => { const phaseFor = (k: (typeof order)[number]): Phase => {
// Stage owns this milestone → always current, never collapsed. // Stage owns this milestone → always current, never collapsed.
@@ -1122,6 +1134,9 @@ function OverviewTab({
archivedAt={null} 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 {/* Qualification checklist - surfaces the port's per-port criteria so
the rep can mark each one confirmed before the deal advances out the rep can mark each one confirmed before the deal advances out
of 'enquiry'. Hidden when the port has no enabled criteria. */} 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 Link from 'next/link';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -67,9 +67,12 @@ export interface LinkedBerthRow {
addedBy: string | null; addedBy: string | null;
addedAt: string; addedAt: string;
notes: string | null; notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string; status: string;
statusOverrideMode: string | null;
lengthFt: string | null; lengthFt: string | null;
widthFt: string | null; widthFt: string | null;
draftFt: 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 ────────────────────────────────────────────────────────── // ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps { interface BypassDialogProps {
@@ -288,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) { }: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false); const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = 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 dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed'; 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 ( return (
<div <div
className={cn( className={cn(
@@ -330,6 +362,15 @@ function LinkedBerthRowItem({
EOI bypassed EOI bypassed
</span> </span>
) : null} ) : 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> </div>
{dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null} {dims ? <div className="text-xs text-muted-foreground">{dims}</div> : null}
</div> </div>
@@ -400,7 +441,11 @@ function LinkedBerthRowItem({
</Tooltip> </Tooltip>
</div> </div>
<p className="text-xs text-muted-foreground"> <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> </p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -444,6 +489,34 @@ function LinkedBerthRowItem({
</div> </div>
</TooltipProvider> </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 ? ( {showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description // Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't // on the left, action button inline with the description so it doesn't

View File

@@ -105,7 +105,10 @@ export function InvoiceCard({
</DropdownMenuItem> </DropdownMenuItem>
{invoice.pdfFileId ? ( {invoice.pdfFileId ? (
<DropdownMenuItem asChild> <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" /> <FileText className="mr-2 h-3.5 w-3.5" />
View PDF View PDF
</Link> </Link>

View File

@@ -144,7 +144,10 @@ export function getInvoiceColumns({
</DropdownMenuItem> </DropdownMenuItem>
{invoice.pdfFileId && ( {invoice.pdfFileId && (
<DropdownMenuItem asChild> <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" /> <FileText className="mr-2 h-3.5 w-3.5" />
View PDF View PDF
</Link> </Link>

View File

@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/use-notifications'; import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from '@/components/notifications/notification-item'; import { NotificationItem } from '@/components/notifications/notification-item';
import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card'; import { AlertCard, AlertCardEmpty } from '@/components/alerts/alert-card';
import { useAlertCount, useAlertList, useAlertRealtime } from '@/components/alerts/use-alerts'; import {
useAlertCount,
useAlertList,
useAlertRealtime,
useDismissAll,
} from '@/components/alerts/use-alerts';
interface NotificationListResponse { interface NotificationListResponse {
data: Array<{ data: Array<{
@@ -66,6 +71,7 @@ export function Inbox() {
const systemCritical = alertCount?.bySeverity.critical ?? 0; const systemCritical = alertCount?.bySeverity.critical ?? 0;
const systemAlerts = alertList?.data ?? []; const systemAlerts = alertList?.data ?? [];
const systemTop = systemAlerts.slice(0, 8); const systemTop = systemAlerts.slice(0, 8);
const dismissAll = useDismissAll();
// ── Personal (notifications) ── // ── Personal (notifications) ──
const { unreadCount: personalUnread } = useNotifications(); const { unreadCount: personalUnread } = useNotifications();
@@ -230,13 +236,25 @@ export function Inbox() {
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active alerts Active alerts
</h4> </h4>
<Link <div className="flex items-center gap-3">
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ {systemAlerts.length > 0 ? (
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)} <button
className="text-xs text-muted-foreground hover:text-foreground" type="button"
> onClick={() => dismissAll.mutate({})}
View all disabled={dismissAll.isPending}
</Link> className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Dismiss all
</button>
) : null}
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={portSlug ? (`/${portSlug}/alerts` as any) : ('/alerts' as any)}
className="text-xs text-muted-foreground hover:text-foreground"
>
View all
</Link>
</div>
</div> </div>
<Separator /> <Separator />
<ScrollArea className="max-h-[400px]"> <ScrollArea className="max-h-[400px]">

View File

@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
/** /**
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium: * Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
* deep-navy gradient surface with white type, the brand "PN" mark on the * deep-navy gradient surface with white type, a back arrow on the left when
* left when there's no back affordance, and a soft glow shadow underneath * there's a back affordance (otherwise a balancing spacer), and a soft glow
* for depth instead of a hard divider line. * shadow underneath for depth instead of a hard divider line.
* *
* Slots: title (auto-truncating), back arrow, primary action - all driven by * Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the * `useMobileChrome()` from the active page. When no page has set a title the
@@ -47,17 +47,6 @@ export function MobileTopbar() {
portTitle || portTitle ||
'CRM'; 'CRM';
// Brand-mark initials derived from the port slug
// ("port-nimara" → "PN", "marina-alpha" → "MA"). Cheap, self-contained,
// no extra DB round-trip.
const initials = portSlug
? portSlug
.split('-')
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
.slice(0, 2)
: 'CR';
return ( return (
<header <header
className={cn( className={cn(
@@ -71,15 +60,10 @@ export function MobileTopbar() {
{backTarget ? ( {backTarget ? (
<BackButton variant="mobile" /> <BackButton variant="mobile" />
) : ( ) : (
<div // No back affordance on top-level pages. Render an empty spacer the
aria-label={portTitle || 'Home'} // same width as the right-hand action slot so the centered title
className={cn( // stays optically centered (the brand "PN" mark was removed here).
'size-9 shrink-0 rounded-lg flex items-center justify-center', <div className="size-11 shrink-0" aria-hidden />
'bg-[#3a7bc8] shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.25)]',
)}
>
<span className="text-white font-bold text-[13px] tracking-tight">{initials}</span>
</div>
)} )}
<h1 <h1

View File

@@ -6,6 +6,7 @@ import {
Bookmark, Bookmark,
Building2, Building2,
FileSignature, FileSignature,
MailQuestion,
FileText, FileText,
Globe, Globe,
Home, Home,
@@ -53,6 +54,7 @@ const MORE_GROUPS: MoreGroup[] = [
items: [ items: [
{ label: 'Documents', icon: FileSignature, segment: 'documents' }, { label: 'Documents', icon: FileSignature, segment: 'documents' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' }, { label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Inquiries', icon: MailQuestion, segment: 'inquiries' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' }, { label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' }, { label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' }, { label: 'Residential', icon: Home, segment: 'residential/clients' },

View File

@@ -7,6 +7,7 @@ import { usePathname } from 'next/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
UsersRound,
Bookmark, Bookmark,
Anchor, Anchor,
KeyRound, KeyRound,
@@ -16,6 +17,7 @@ import {
FileText, FileText,
FileBarChart, FileBarChart,
Inbox, Inbox,
MailQuestion,
Camera, Camera,
Globe, Globe,
Settings, Settings,
@@ -112,9 +114,11 @@ function buildNavSections(portSlug: string | undefined): NavSection[] {
items: [ items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard }, { href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users }, { href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/client-groups`, label: 'Client Groups', icon: UsersRound },
{ href: `${base}/yachts`, label: 'Yachts', icon: Ship }, { href: `${base}/yachts`, label: 'Yachts', icon: Ship },
{ href: `${base}/companies`, label: 'Companies', icon: Building2 }, { href: `${base}/companies`, label: 'Companies', icon: Building2 },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark }, { href: `${base}/interests`, label: 'Interests', icon: Bookmark },
{ href: `${base}/inquiries`, label: 'Inquiries', icon: MailQuestion },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor }, { href: `${base}/berths`, label: 'Berths', icon: Anchor },
{ {
href: `${base}/tenancies`, href: `${base}/tenancies`,

View File

@@ -128,7 +128,9 @@ export function ReportRunsPageClient({ portSlug }: { portSlug: string }) {
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{r.status === 'complete' && r.storageKey ? ( {r.status === 'complete' && r.storageKey ? (
<Button asChild size="sm" variant="ghost" title="Download artefact"> <Button asChild size="sm" variant="ghost" title="Download artefact">
<Link href={`/api/v1/files/${r.storageKey}/download` as Route}> <Link
href={`/api/v1/files/${r.storageKey}/download?redirect=1` as Route}
>
<Download className="h-3.5 w-3.5" aria-hidden /> <Download className="h-3.5 w-3.5" aria-hidden />
</Link> </Link>
</Button> </Button>

View File

@@ -0,0 +1,180 @@
'use client';
import Link from 'next/link';
import type { Route } from 'next';
import { format } from 'date-fns';
import { Mail, Phone } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import type { ColumnDef } from '@tanstack/react-table';
import { Badge } from '@/components/ui/badge';
import type { ColumnPickerOption } from '@/components/shared/column-picker';
export interface ResidentialClientRow {
id: string;
fullName: string;
email: string | null;
phone: string | null;
placeOfResidence: string | null;
status: string;
source: string | null;
createdAt: string;
updatedAt: string;
}
const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
active: 'Active',
inactive: 'Inactive',
};
/**
* Column manifest for the residential clients list `<ColumnPicker>`.
* Mirrors the marina-side clients/interests pattern so the residential
* team gets the same show/hide affordance.
*/
export const RESIDENTIAL_CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [
{ id: 'fullName', label: 'Name', alwaysVisible: true },
{ id: 'email', label: 'Email' },
{ id: 'phone', label: 'Phone' },
{ id: 'residence', label: 'Residence' },
{ id: 'status', label: 'Status' },
{ id: 'source', label: 'Source' },
{ id: 'createdAt', label: 'Date added' },
];
/**
* "Residence" is empty for nearly every residential client (we don't
* capture it at intake — it rendered as a column of "-"), so it's hidden
* by default and "Date added" takes its place. Users can re-enable
* Residence via the picker; their choice then persists.
*/
export const RESIDENTIAL_CLIENT_DEFAULT_HIDDEN: string[] = ['residence'];
export function getResidentialClientColumns({
portSlug,
}: {
portSlug: string;
}): ColumnDef<ResidentialClientRow, unknown>[] {
return [
{
id: 'fullName',
accessorKey: 'fullName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/residential/clients/${row.original.id}` as Route}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.fullName}
</Link>
),
},
{
id: 'email',
header: 'Email',
enableSorting: false,
cell: ({ row }) => {
const value = row.original.email;
if (!value) return <span className="text-muted-foreground">-</span>;
return (
<a
href={`mailto:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline"
title={`Email ${value}`}
>
<Mail className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="truncate">{value}</span>
</a>
);
},
},
{
id: 'phone',
header: 'Phone',
enableSorting: false,
cell: ({ row }) => {
const value = row.original.phone;
if (!value) return <span className="text-muted-foreground">-</span>;
const waDigits = value.replace(/[^\d]/g, '');
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<a
href={`tel:${value}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline"
title={`Call ${value}`}
>
<Phone className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
<span>{value}</span>
</a>
<a
href={`https://wa.me/${waDigits}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-emerald-600 hover:text-emerald-700"
title={`WhatsApp ${value}`}
aria-label={`WhatsApp ${value}`}
>
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
</span>
);
},
},
{
id: 'residence',
accessorKey: 'placeOfResidence',
header: 'Residence',
enableSorting: false,
cell: ({ getValue }) => {
const v = getValue() as string | null;
return v ? (
<span className="text-muted-foreground">{v}</span>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => {
const s = getValue() as string;
return (
<Badge variant="outline" className="text-xs">
{STATUS_LABELS[s] ?? s}
</Badge>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground">-</span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{source}
</Badge>
);
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Date added',
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
];
}

View File

@@ -2,10 +2,10 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useParams } from 'next/navigation'; import type { Route } from 'next';
import { useParams, useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { WhatsAppIcon } from '@/components/icons/whatsapp';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -22,17 +22,15 @@ import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error'; import { toastError } from '@/lib/api/toast-error';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { DataTable } from '@/components/shared/data-table';
interface ResidentialClientRow { import { ColumnPicker } from '@/components/shared/column-picker';
id: string; import { useTablePreferences } from '@/hooks/use-table-preferences';
fullName: string; import {
email: string | null; getResidentialClientColumns,
phone: string | null; RESIDENTIAL_CLIENT_COLUMN_OPTIONS,
placeOfResidence: string | null; RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
status: string; type ResidentialClientRow,
source: string | null; } from '@/components/residential/residential-client-columns';
updatedAt: string;
}
interface ListResponse { interface ListResponse {
data: ResidentialClientRow[]; data: ResidentialClientRow[];
@@ -48,6 +46,7 @@ const STATUS_LABELS: Record<string, string> = {
export function ResidentialClientsList() { export function ResidentialClientsList() {
const params = useParams<{ portSlug: string }>(); const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? ''; const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -66,6 +65,17 @@ export function ResidentialClientsList() {
'residential_client:restored': [['residential-clients']], 'residential_client:restored': [['residential-clients']],
}); });
const columns = getResidentialClientColumns({ portSlug });
// Per-user column visibility, persisted via /api/v1/me — same hook + UX as
// the marina clients/interests lists. "Residence" is hidden by default
// (it's empty for nearly every residential client); "Date added" is shown.
const { hidden, setHidden } = useTablePreferences(
'residential-clients',
RESIDENTIAL_CLIENT_DEFAULT_HIDDEN,
);
const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false]));
const rows = data?.data ?? [];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader <PageHeader
@@ -79,156 +89,82 @@ export function ResidentialClientsList() {
} }
/> />
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
placeholder="Search by name, email, phone, residence…" placeholder="Search by name, email, phone, residence…"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-sm" className="max-w-sm"
/> />
<ColumnPicker
columns={RESIDENTIAL_CLIENT_COLUMN_OPTIONS}
hidden={hidden}
onChange={setHidden}
/>
</div> </div>
{/* Desktop: table layout. Hidden below lg because the 6 columns clip <DataTable
off the viewport at phone widths. */} columns={columns}
<div className="hidden md:block rounded-lg border bg-card overflow-hidden"> columnVisibility={columnVisibility}
<table className="w-full text-sm"> data={rows}
<thead className="bg-muted/40 text-xs text-muted-foreground"> isLoading={isLoading}
<tr> getRowId={(r) => r.id}
<th className="text-left font-medium px-3 py-2">Name</th> onRowClick={(r) => router.push(`/${portSlug}/residential/clients/${r.id}` as Route)}
<th className="text-left font-medium px-3 py-2">Email</th> emptyState={
<th className="text-left font-medium px-3 py-2">Phone</th> <div className="px-3 py-8 text-center text-sm text-muted-foreground">
<th className="text-left font-medium px-3 py-2">Residence</th>
<th className="text-left font-medium px-3 py-2">Status</th>
<th className="text-left font-medium px-3 py-2">Source</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
Loading
</td>
</tr>
)}
{!isLoading && data?.data.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-8 text-center text-muted-foreground">
No residential clients yet.
</td>
</tr>
)}
{data?.data.map((c) => (
<tr
key={c.id}
className="border-t hover:bg-muted/30 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<Link
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="font-medium hover:underline"
>
{c.fullName}
</Link>
</td>
<td className="px-3 py-2 text-muted-foreground">
{c.email ? (
<a
href={`mailto:${c.email}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{c.email}
</a>
) : (
'-'
)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{c.phone ? (
<span className="inline-flex items-center gap-1.5">
<a
href={`tel:${c.phone}`}
className="text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{c.phone}
</a>
<a
href={`https://wa.me/${c.phone.replace(/[^\d+]/g, '')}`}
target="_blank"
rel="noreferrer"
title="WhatsApp"
aria-label="Message on WhatsApp"
className="text-emerald-600 hover:text-emerald-700"
onClick={(e) => e.stopPropagation()}
>
<WhatsAppIcon className="h-3.5 w-3.5" />
</a>
</span>
) : (
'-'
)}
</td>
<td className="px-3 py-2 text-muted-foreground">{c.placeOfResidence ?? '-'}</td>
<td className="px-3 py-2">{STATUS_LABELS[c.status] ?? c.status}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{c.source ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: card list. Each card mirrors the table row data with
name + status pill on top, then meta line(s) below. */}
<div className="md:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
No residential clients yet. No residential clients yet.
</div> </div>
)} }
{data?.data.map((c) => ( cardRender={(row) => <ResidentialClientCard portSlug={portSlug} client={row.original} />}
<Link />
key={c.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm truncate">{c.fullName}</p>
<span
className={cn(
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
c.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: c.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[c.status] ?? c.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{c.email ? <span className="truncate">{c.email}</span> : null}
{c.phone ? <span>{c.phone}</span> : null}
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
{c.source ? <span className="capitalize">· {c.source}</span> : null}
</div>
</Link>
))}
</div>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} /> <NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );
} }
/**
* Mobile card for a residential client — DataTable swaps to this below the
* md breakpoint. Self-navigating `<Link>` (DataTable's onRowClick only wires
* the desktop table rows). Mirrors the marina-side card density.
*/
function ResidentialClientCard({
portSlug,
client,
}: {
portSlug: string;
client: ResidentialClientRow;
}) {
return (
<Link
href={`/${portSlug}/residential/clients/${client.id}` as Route}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="truncate text-sm font-medium">{client.fullName}</p>
<span
className={cn(
'inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium uppercase tracking-wide',
client.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: client.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[client.status] ?? client.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{client.email ? <span className="truncate">{client.email}</span> : null}
{client.phone ? <span>{client.phone}</span> : null}
{client.placeOfResidence ? <span>{client.placeOfResidence}</span> : null}
{client.source ? <span className="capitalize">· {client.source}</span> : null}
</div>
</Link>
);
}
function NewResidentialClientSheet({ function NewResidentialClientSheet({
open, open,
onOpenChange, onOpenChange,

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