38 Commits

Author SHA1 Message Date
866930c943 feat(intake): residence-type capture + CRM-owned inquiry emails for website cutover
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m17s
Website register-interest form now offers the 3 residence types as a
multi-select; the choice + preferred-contact flow into the CRM inquiry
payload, the inbox detail, and the residential emails.

- inquiry inbox detail surfaces residence type(s), preferred contact,
  type-of-interest, comments (full data capture)
- residential-inquiry emails: client confirmation names the chosen
  villa(s); sales alert converted to the canonical detail-line format
  (uniform with berth/contact) + residence type(s)/preferred contact +
  plain-text part
- website-intake-fields parses residence_types[] + method_of_contact
- contact_form alerts split to their own recipient key
  (contact_notification_recipients)
- Residential Interests section: new residence_type field (schema +
  migration 0099, validators, inline select on the detail)
- contact-form-alert email refactor shipped (interest-alert style)

Tests: website-intake-fields, residential-inquiry templates,
contact-form-alert, residential-interest validators.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2qc3xZTfif7N4Wq3QDa8X
2026-06-25 20:58:53 +02:00
64a488dc15 fix(signing): branded embed URL for developer + Copy link
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m6s
Two bugs in the marketing-site embed-URL rewrite, both surfaced by
testing through the actual invitation emails:

1. Developer invite linked to `…/sign/undefined/<token>`. document_signers
   persists Documenso's normalized role, so the order-2 EOI developer
   arrives as 'signer', which ROLE_TO_URL_SEGMENT didn't have — the lookup
   returned undefined. Add a 'signer' → 'developer' alias and a 'cc'
   fallback so an unknown role can never emit `/sign/undefined/`.

2. "Copy link" copied the bare Documenso URL, not the branded embed URL.
   listDocumentSigners (feeds the EOI tab signers + Copy link) now runs
   each signing_url through transformSigningUrl, so the copied link
   matches what the invitation email sends. Send-invitation/automation
   read the raw rows directly, so they're unaffected.

Regression tests pin 'signer' → /sign/developer and the unknown-role
fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:14:28 +02:00
2bc2cfac6f fix(eoi): render signed-PDF preview inline (preview endpoint + font-src)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m5s
After adding frame-src the preview iframe stopped showing the broken-file
icon but went blank: it pointed at /api/v1/files/[id]/download, which
presigns with the filename so S3 returns Content-Disposition: attachment
— the browser downloaded the PDF instead of rendering it. Point the
SignedPdfPreview iframe at the existing /preview endpoint, which presigns
WITHOUT a filename (inline disposition) so the native PDF viewer renders.

Also widen font-src to include https: so react-pdf/pdf.js can load its
standard-font pack (LiberationSans*) — previously blocked by
font-src 'self' data:, breaking the pdf.js-based viewers' glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:45:34 +02:00
3f6f845c02 fix(csp): add frame-src so signed-PDF previews + embedded signing load
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m55s
Build & Push Docker Images / build-and-push (push) Successful in 8m22s
The CSP set default-src 'self' with no frame-src, so it fell back to
'self' for frames. Inline signed-EOI previews iframe a presigned
s3.portnimara.com URL and the admin embedded-signing card iframes the
Documenso host — both were blocked, rendering a broken-file placeholder
("Framing 'https://s3.portnimara.com/' violates ... default-src 'self'").

Add `frame-src 'self' blob: https:` to both CSP definitions (proxy.ts
middleware + next.config.ts), matching the existing https: posture of
img-src/connect-src. frame-ancestors 'none' is unchanged, so we still
can't be embedded by third parties.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:26:38 +02:00
fc994cd88b fix(eoi): silence Documenso's own lifecycle emails on createDocument
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m53s
The local-fill EOI pathway creates fresh Documenso envelopes via
createDocument, which (unlike the template pathway that inherits
template 8's all-false emailSettings) used Documenso's defaults — every
email event defaults to true on both the v1 and v2.13 APIs. So Documenso
fired its OWN unbranded "Waiting for others to complete signing." and
"Signing Complete!" emails (signed PDF attached, reply-to sales@),
bypassing EMAIL_REDIRECT_TO and duplicating the CRM's branded sends.

Force emailSettings to all-false (DOCUMENSO_SILENT_EMAIL_SETTINGS) on
every createDocument call (v1 JSON + v2 multipart). The CRM stays the
sole sender of signing comms. Verified against the live v2.13 OpenAPI +
template 8's stored meta.

Also stop the EMAIL_REDIRECT_TO gate from appending "(was: <email>)" to
the recipient NAME: a "Name" field auto-fills from it into the signed
PDF, so the annotation overlapped the signature. Redirect the email
only; the original is still logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:20:55 +02:00
e17476f3e3 fix(eoi): v1 field placement uses percent coords, not absolute points
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m50s
Build & Push Docker Images / build-and-push (push) Successful in 8m38s
Documenso 2.x's v1-compat POST /documents/{id}/fields expects percent
coordinates (0-100), same as v2 — but placeFields multiplied by a hardcoded
A4 page size, so 39.6% became ~237 and was read as 237%, placing the EOI
signature fields far off-page. Send percent straight through (matches
template-native fields, which render correctly). Drop the now-dead
getPageDimensions/DEFAULT_PAGE_DIMENSIONS A4 fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:05:42 +02:00
f4cfc5600f fix(eoi): uppercase Documenso recipient roles at the API boundary
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m9s
The local-fill EOI pathway passed lowercase roles ('signer'/'approver') to
createDocument; Documenso's API requires UPPERCASE (CC|SIGNER|VIEWER|APPROVER|
ASSISTANT) and rejected them with a 400 "Invalid enum value", surfacing as a
502 DOCUMENSO_UPSTREAM_ERROR on generate. Normalize role to uppercase where
safeRecipients is built so both v1 + v2 paths and all callers are covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 03:37:14 +02:00
0ca9b2c3b5 feat(eoi): toggleable local-fill pathway — clean detail render + address wrapping
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m48s
Build & Push Docker Images / build-and-push (push) Successful in 8m34s
EOI detail fields (address, name, yacht, berth) rendered oversized and
top-clipped because Documenso auto-sizes AcroForm text when *it* fills the
template (ignores the PDF's 12pt font; a taller box → bigger font → more clip,
and a 2-line address box renders huge). Proven: filling the same source PDF
locally at 12pt renders cleanly and wraps long addresses to a 2nd line.

Add a per-port `eoi_fill_method` setting (default `local`), toggleable in
admin → Documenso → Templates & signing pathway:
- local:     CRM fills + flattens the source PDF (pdf-lib, fixed 12pt +
             multiline address wrap), uploads the flattened PDF to Documenso,
             and places ONLY the 6 page-3 signature fields. Documenso never
             re-renders the body text → no clipping.
- documenso: legacy template AcroForm fill (auto-sizes/clips) — fallback only.

Both still flow through Documenso for signing, so branded invites, embedded
signing, webhooks, signer rows, and the EOI milestone are unchanged.

- computeEoiSignatureLayout(): 6 page-3 fields at template-8 coords (unit-tested)
- createDocument (v1): PUT bytes to Documenso's presigned uploadUrl (2.x v1-compat
  ignores the base64 field) so the uploaded document actually has content
- placeFields (v1): pass fieldMeta through so the Place-of-Signing TEXT field
  keeps its label/required

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:48:11 +02:00
af05bb18dc fix(documenso): follow v1 /download JSON {downloadUrl} to fetch the real signed PDF
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m47s
Build & Push Docker Images / build-and-push (push) Successful in 8m39s
Documenso 2.13's v1-compat `GET /api/v1/documents/{id}/download` returns JSON
`{ downloadUrl }` (a presigned S3 URL), not raw PDF bytes. `downloadSignedPdf`
was doing `res.arrayBuffer()` directly, so it stored the ~500-byte JSON as the
"signed PDF" — every signer got a corrupt attachment ("Adobe could not open …
damaged") and the corrupt file was filed against the deal in the CRM.

Fix: magic-byte detection — if the v1 /download body isn't a `%PDF-`, parse the
JSON and follow `downloadUrl` to fetch the actual file; validate the result is a
real PDF before returning. Backward-compatible with older v1 servers that return
the PDF directly. This also protects the CRM deposit + email fan-out, since both
consume downloadSignedPdf's buffer — a non-PDF now throws instead of depositing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 23:55:16 +02:00
1c91d76c52 feat(signing): internal "who signed" + completion email alerts to configurable recipients
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m56s
Build & Push Docker Images / build-and-push (push) Successful in 8m56s
Adds a per-port `signing_notification_recipients` setting (users / roles /
explicit emails via the existing RecipientPicker) and fires a branded
internal email on (a) each party signing and (b) full completion —
replicating the legacy "Document Signed" / "EOI Complete Update Status"
Activepieces flows that staff relied on.

- New branded template `signing-status-notification.tsx` (per-signer
  progress + completion variants, deep-links into the CRM).
- New `sendSigningStatusNotification` resolver in document-signing-emails:
  resolves recipients, falls back to the port reply-to (sales@) when the
  list is empty so alerts are never silently dropped, per-recipient send.
- Wired into `handleRecipientSigned` (first signed transition) and
  `handleDocumentCompleted` (idempotent, fires once) — reached by both the
  Documenso webhook and the 5-min poll. Fully guarded so a notification
  failure never undoes a signing side effect. Respects EMAIL_REDIRECT_TO.
- Admin UI: `Document signing alerts` recipients card in settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012iJPYbh5X53iBh9h7ffQoy
2026-06-24 21:38:22 +02:00
352b2420b7 fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m2s
Build & Push Docker Images / build-and-push (push) Successful in 8m28s
Responsive-overflow sweep findings (tests/e2e/matrix/responsive-overflow.spec.ts):

- R1: the onboarding banner's verbose "N of M steps done. Next: <link>" was
  clipped on mobile (extended ~160px past a 390px viewport) and duplicated the
  always-visible "View checklist" button. Now hidden below sm:; mobile shows
  just "Setup X% complete" + the checklist button.
- R2: yacht card owner subtitle used inline-flex + truncate, so a long owner
  name overflowed ~11px on the narrowest widths. Switched to flex min-w-0 so it
  truncates within the card.
- Detector: skip SVG internals (icons / the react-grab dev overlay) and elements
  inside overflow-x scroll containers (data tables scroll on purpose) to drop
  false positives. Sweep now confirms mobile/tablet clean + no real desktop
  overflow (berths wide table is the DataTable's intended horizontal scroll).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:23:56 +02:00
459c68a2c3 feat(rbac): residential-partner route lockdown + role-aware mobile nav
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m0s
Build & Push Docker Images / build-and-push (push) Successful in 8m32s
UAT (residential partners must have zero access to anything non-residential;
no marina dashboard). Server-side their permission map already 403s every
marina domain — this locks the client surface to match:

- AppShell: a residential-only user (residential_clients.view && !clients.view,
  non-super-admin) is redirected off ANY non-residential route to
  /residential/clients. Blocks the marina dashboard + every marina page in one
  place; personal surfaces (settings, inbox) stay reachable. (Fixes F4 — they
  no longer land on a marina dashboard of 403-ing empty widgets.)
- Mobile bottom tabs were hardcoded Dashboard/Clients/Berths regardless of role;
  now role-aware — residential-only users get Residential Clients/Interests
  instead of marina tabs they 403 on. (Fixes F5.)
- e2e: stale `#email` login selector → `#identifier` (smoke helper) — a real
  reason the smoke auth specs fail independent of the dev-server OOM.
- New crash-safe `matrix` Playwright project (role×viewport access matrix +
  responsive overflow sweep) — lean alternative to the full suite which
  OOM-crashes next dev locally.

Verified: matrix run shows residential_partner redirected to residential +
residential-scoped mobile tabs; 403s unchanged; tsc + eslint + 42 permission
tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:22 +02:00
adc9802361 fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m23s
UAT findings from the Sales-role functional walkthrough:

F1 — The deal-alert feed (stale interest, hot-lead-silent, EOI unsigned,
signer overdue, reservation-needs-agreement, berth stalled, expense dupes)
was gated on admin.view_audit_log, so salespeople got a 403 on the Alerts
inbox. None of the 9 alert rules are audit/security signals — they're all
operational — so re-gate the list route to interests.view (sales, director,
viewer get it; external residential partners don't) and hide the Alerts
section in the inbox for users without it instead of letting the query 403.

F2 — Non-admins triggered /api/v1/admin/onboarding/status (admin-only) and
ate a 403 in the console. Make useOnboardingStatus strictly opt-in
(enabled: opts.enabled === true) so a transient/stale isSuperAdmin during
permission hydration can't fire the privileged request.

1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:49:12 +02:00
d8f739a7c2 feat(rbac): GDPR export becomes a toggleable clients.gdpr_export permission
Previously the GDPR export trigger + download routes were gated by
admin.manage_settings, so sales roles couldn't run a client data export.
Per request, make it a dedicated, toggleable permission that's on by
default for sales-capable roles and hides the button when withheld.

- New RolePermissions leaf clients.gdpr_export (+ PERMISSION_CATALOG entry);
  strict type forces every role map + fixture to declare it.
- Granted true for super_admin / director / sales_manager / sales_agent;
  false for viewer / residential_partner.
- GDPR export POST (trigger) and [exportId] GET (download) re-gated from
  admin.manage_settings -> clients.gdpr_export.
- GdprExportButton visibility now keys off clients.gdpr_export, so toggling
  it off per-user hides the function entirely.
- Migration 0098 backfills the key onto existing role rows (idempotent).

Verified end-to-end as a Sales user: trigger (202) -> worker build (ready)
-> list (200) -> download (200). 1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:20:31 +02:00
93989b1e1d feat(admin): single Sales role, welcome-email password setup, Director=sales
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m5s
Build & Push Docker Images / build-and-push (push) Successful in 9m24s
- Collapse the two sales roles in the create-user dropdown to one "Sales"
  (sales_manager relabelled). Hide super_admin + sales_agent from selection
  via NON_ASSIGNABLE_ROLE_NAMES; the form keeps a user's *current* role even
  if hidden so existing assignments stay editable.
- Director becomes a senior-title twin of Sales: DIRECTOR_PERMISSIONS now
  equals SALES_MANAGER_PERMISSIONS (no admin/settings — Super-Admin only).
  Migration 0097 updates the existing global director row (idempotent,
  data-only; 0 users assigned on prod, so no blast radius).
- Admin create-user defaults to emailing a set-password link instead of an
  inline password (manual entry still available via a toggle). createUserSchema:
  password optional + sendSetupEmail; createUser provisions with a throwaway
  password then triggers the set-password email.
- New users get a dedicated, unique WELCOME email (crmWelcomeEmail), not the
  self-service "reset your password" email. A pending-welcome flag routes the
  shared better-auth sendResetPassword callback via account-setup-email.ts.
- Phone confirmed already optional for staff accounts (no change needed).

Tests: +welcome-routing, +create-user-setup; permission-matrix director block
realigned to no-admin. 1662 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:40:55 +02:00
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
164 changed files with 7996 additions and 705 deletions

View File

@@ -51,8 +51,13 @@ const csp = [
`script-src 'self' 'unsafe-inline'${isProd ? '' : " 'unsafe-eval'"}${devScriptHosts}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
// https: so react-pdf/pdf.js can load its standard-font pack + branding fonts.
"font-src 'self' data: https:",
`connect-src 'self' ws: wss: https:${devConnectHosts}`,
// PDF previews iframe a presigned storage URL; embedded-signing iframes the
// Documenso host. Both are per-port/per-env, so allow https: (matching
// img-src). frame-ancestors 'none' still blocks others from embedding us.
"frame-src 'self' blob: https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",

View File

@@ -24,6 +24,28 @@ export default defineConfig({
name: 'setup',
testMatch: /smoke\/global-setup\.ts/,
},
{
// Permission-matrix UX sweep. Users + roles are seeded separately via
// `pnpm tsx tests/e2e/permissions/seed-permission-matrix.ts` (no global
// setup dependency — relies on the already-seeded dev DB).
name: 'permissions',
testMatch: /permissions\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
// Lean role × viewport access matrix. Users pre-seeded (admin/director/
// sales/viewer/residential_partner) — no global-setup dependency. Few
// route compilations, so it stays under the dev-server OOM threshold.
name: 'matrix',
testMatch: /matrix\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 900 },
},
},
{
name: 'smoke',
testMatch: /smoke\/\d{2}-.*\.spec\.ts/,

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

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

View File

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

View File

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

View File

@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved';
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent
// signals. Gated on admin.view_audit_log - same permission the audit log
// page uses.
// The alert feed is entirely operational/deal signals (stale interest, hot lead
// silent, EOI unsigned, signer overdue, reservation needs agreement, berth
// stalled, duplicate/unscanned expense) — there are no audit/security alert
// rules. Gated on interests.view so the operational roles that act on these
// (sales, director, viewer) see them; external residential partners don't.
export const GET = withAuth(
withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => {
withPermission('interests', 'view', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;

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

@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
*/
export const GET = withAuth(
withPermission(
'admin',
'manage_settings',
'clients',
'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => {
try {
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);

View File

@@ -26,8 +26,8 @@ export const GET = withAuth(
export const POST = withAuth(
withPermission(
'admin',
'manage_settings',
'clients',
'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => {
try {
const body = await parseBody(req, requestSchema);

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);
// CM-6: manual-entry mode short-circuits ALL parsing - the operator
// types the details by hand. The client should skip this route entirely
// in manual mode, but we guard server-side too.
if (config.manualEntry) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'manual-mode' },
});
}
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its

View File

@@ -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

@@ -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

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

View File

@@ -50,8 +50,12 @@ export function OnboardingBanner() {
<div className="flex min-w-0 items-center gap-2">
<Sparkles className="size-4 shrink-0" aria-hidden />
<span className="truncate">
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps
done.{' '}
<strong>Setup is {data.percent}% complete</strong>
{/* Verbose progress + the "Next:" deep-link are hidden on mobile,
where they get clipped (R1) and duplicate the always-visible
"View checklist" button. Shown from sm: up. */}
<span className="hidden sm:inline">
. {data.completed} of {data.total} steps done.{' '}
{next ? (
<>
Next:{' '}
@@ -65,6 +69,7 @@ export function OnboardingBanner() {
</>
) : null}
</span>
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-xs">

View File

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

View File

@@ -48,6 +48,14 @@ const KNOWN_SETTINGS: Array<{
type: 'boolean',
defaultValue: true,
},
{
key: 'assignment_enabled',
label: 'Interest Assignment',
description:
'Allow assigning interests to sales users (the "Assigned to" owner chip + auto-assign on create). Off by default - turn on only when more than one person works the pipeline. Disabling hides the assignment UI and stops auto-assigning new interests; existing assignment data is preserved and reappears if you re-enable.',
type: 'boolean',
defaultValue: false,
},
{
key: 'tenancies_module_enabled',
label: 'Tenancies Module',
@@ -141,9 +149,17 @@ const KNOWN_SETTINGS: Array<{
},
{
key: 'inquiry_notification_recipients',
label: 'Berth & contact inquiry alerts',
label: 'Berth inquiry alerts',
description:
'Who receives staff alerts for new berth + contact-form inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
'Who receives staff alerts for new berth inquiries: specific users, roles, everyone with inquiry access, and/or explicit email addresses.',
type: 'recipients',
defaultValue: [],
},
{
key: 'contact_notification_recipients',
label: 'Contact-form alerts',
description:
'Who receives staff alerts for new website contact-form submissions: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Falls back to Inquiry Contact Email when empty.',
type: 'recipients',
defaultValue: [],
},
@@ -155,6 +171,14 @@ const KNOWN_SETTINGS: Array<{
type: 'recipients',
defaultValue: [],
},
{
key: 'signing_notification_recipients',
label: 'Document signing alerts',
description:
'Who gets emailed each time a party signs an EOI / contract and when a document is fully signed: specific users, roles, everyone with inquiry access, and/or explicit email addresses. Add yourself and sales@ here. Falls back to the Reply-To address when empty.',
type: 'recipients',
defaultValue: [],
},
{
key: 'eoi_signers',
label: 'EOI Signers',

View File

@@ -29,7 +29,7 @@ import {
} from '@/components/ui/alert-dialog';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { apiFetch } from '@/lib/api/client';
import { formatRole } from '@/lib/constants';
import { formatRole, NON_ASSIGNABLE_ROLE_NAMES } from '@/lib/constants';
interface Role {
id: string;
@@ -78,12 +78,20 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
enabled: open,
});
const roles = rolesQuery.data?.data ?? [];
// Hide retired/owner-only system roles from the picker, but always keep the
// role the user being edited already holds so their record stays editable.
const selectableRoles = roles.filter(
(r) => !NON_ASSIGNABLE_ROLE_NAMES.has(r.name) || r.id === user?.role.id,
);
const [firstName, setFirstName] = useState(initialNames.first);
const [lastName, setLastName] = useState(initialNames.last);
const [email, setEmail] = useState(user?.email ?? '');
const [originalEmail] = useState(user?.email ?? '');
const [emailConfirmOpen, setEmailConfirmOpen] = useState(false);
const [password, setPassword] = useState('');
// New users: email them a set-password link by default rather than typing a
// password here. Toggle off to set one manually.
const [sendSetupEmail, setSendSetupEmail] = useState(true);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [phoneValue, setPhoneValue] = useState<PhoneInputValue | null>(
user?.phone ? { e164: user.phone, country: 'US' } : null,
@@ -141,7 +149,9 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
firstName: firstName || null,
lastName: lastName || null,
email,
password,
// Email mode omits the password entirely; manual mode sends it.
password: sendSetupEmail ? undefined : password,
sendSetupEmail,
displayName,
phone: phoneE164 ?? undefined,
roleId,
@@ -250,6 +260,23 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
</div>
{!isEdit && (
<>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label htmlFor="user-setup-email">Email a set-password link</Label>
<p className="text-xs text-muted-foreground">
The user gets an email to choose their own password. Turn off to set one
here instead.
</p>
</div>
<Switch
id="user-setup-email"
checked={sendSetupEmail}
onCheckedChange={setSendSetupEmail}
/>
</div>
{!sendSetupEmail && (
<div className="space-y-2">
<Label htmlFor="user-password">Password</Label>
<Input
@@ -263,6 +290,8 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
/>
</div>
)}
</>
)}
<div className="space-y-2">
<Label htmlFor="user-phone">Phone</Label>
@@ -281,7 +310,7 @@ function UserFormBody({ open, onOpenChange, user, onSuccess }: UserFormProps) {
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
{selectableRoles.map((r) => (
<SelectItem key={r.id} value={r.id}>
{formatRole(r.name)}
</SelectItem>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -104,7 +104,7 @@ export function FilePreviewDialog({
// useQuery replaces the prior useEffect(fetch+setState) pattern. The
// request is gated on the dialog being open and a fileId being set.
const previewQuery = useQuery<{ data: { url: string } }>({
const previewQuery = useQuery<{ data: { url: string; mimeType?: string } }>({
queryKey: ['file-preview', fileId],
queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`),
enabled: open && !!fileId,
@@ -113,7 +113,13 @@ export function FilePreviewDialog({
const loading = previewQuery.isLoading;
const error = previewQuery.error ? 'Failed to load preview' : null;
const kind = previewKindFor(mimeType, fileName);
// Prefer the caller-supplied mime, but fall back to the server's resolved
// mime (getPreviewUrl returns it). Without this, callers that pass only a
// display name (e.g. the EOI tab passing "EOI - <client>") or files whose
// stored name lacks a `.pdf` extension (migration-backfilled EOIs) fall
// through to the "unknown" surface even though the server knows it's a PDF.
const resolvedMime = mimeType ?? previewQuery.data?.data.mimeType;
const kind = previewKindFor(resolvedMime, fileName);
return (
<Dialog open={open} onOpenChange={onOpenChange}>

View File

@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts';
import { usePermissions } from '@/hooks/use-permissions';
/**
* Merged "Inbox" surface - replaces the previously-separate /alerts and
@@ -29,6 +30,11 @@ export function InboxPageShell() {
const [alertsOpen, setAlertsOpen] = useState(true);
const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount();
// The deal-alert feed (stale interests, overdue signers, …) is gated on
// interests.view — operational roles see it; external residential partners
// don't. Hide the whole section rather than letting its query 403.
const { can } = usePermissions();
const canSeeAlerts = can('interests', 'view');
// localStorage hydration on mount - canonical "read from external
// store" pattern. setState in effect is intentional.
@@ -95,6 +101,7 @@ export function InboxPageShell() {
) : null}
</section>
{canSeeAlerts ? (
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
<SectionHeader
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
@@ -109,6 +116,7 @@ export function InboxPageShell() {
</div>
) : null}
</section>
) : null}
</div>
);
}

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,223 @@
'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) : '');
// Read a payload value that may be a string[] (e.g. residence_types, the
// contact form's interest[]) OR a lone string, and present it comma-joined.
const list = (k: string): string => {
const v = p[k];
if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string').join(', ');
return typeof v === 'string' ? v : '';
};
// 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');
// Preferred method of contact (register form: 'email' | 'phone'). Surfaced so
// reps honour the lead's stated contact request.
const preferredContactRaw = str('method_of_contact').toLowerCase();
const preferredContact =
preferredContactRaw === 'email'
? 'Email'
: preferredContactRaw === 'phone'
? 'Phone call back'
: '';
const residenceTypes = list('residence_types');
// Contact-form "type of interest" (owner/broker/investor/…), stored as an array.
const contactInterest = list('interest');
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="Residence type(s)" value={residenceTypes} />
) : null}
{data?.kind === 'residence_inquiry' ? (
<Row label="Place of residence" value={str('address')} />
) : null}
{data?.kind === 'berth_inquiry' ? <Row label="Berth" value={str('berth')} /> : null}
{data?.kind === 'contact_form' && contactInterest ? (
<Row label="Type of interest" value={contactInterest} />
) : null}
{preferredContact ? <Row label="Preferred contact" value={preferredContact} /> : 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

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

View File

@@ -868,10 +868,15 @@ function SignedEoiCard({
* the file in a new tab via the alongside View button for full-screen.
*/
function SignedPdfPreview({ fileId }: { fileId: string }) {
const { data, isLoading, isError } = useQuery<{ data: { url: string; filename: string } }>({
queryKey: ['files', fileId, 'download-url'],
// Use the PREVIEW endpoint, not /download: /download presigns with the
// filename so S3 returns `Content-Disposition: attachment`, which makes the
// iframe trigger a file download (blank preview) instead of rendering. The
// preview endpoint presigns WITHOUT a filename → inline disposition → the
// browser's native PDF viewer renders it in the card.
const { data, isLoading, isError } = useQuery<{ data: { url: string; mimeType: string } }>({
queryKey: ['files', fileId, 'preview-url'],
queryFn: () =>
apiFetch<{ data: { url: string; filename: string } }>(`/api/v1/files/${fileId}/download`),
apiFetch<{ data: { url: string; mimeType: string } }>(`/api/v1/files/${fileId}/preview`),
// Presigned URL TTLs vary per backend - refresh well before they
// expire so a long-open card doesn't suddenly 403. 4 minutes is
// comfortably below the 5-minute MinIO default.

View File

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

View File

@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
addedBy: string | null;
addedAt: string;
notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null;
area: string | null;
status: string;
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
});
}
// CM-2 Part B: set/clear the deal-specific price override for one berth.
function useSetBerthPrice(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { berthId: string; price: number | null }) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
method: 'PUT',
body: { price: args.price },
}),
onSuccess: (_data, args) => {
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (e: Error) => toastError(e),
});
}
// ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps {
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
const setBerthPrice = useSetBerthPrice(interestId);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed';
const commitPrice = () => {
const raw = priceDraft.replace(/[,\s]/g, '');
const next = raw === '' ? null : Number(raw);
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
if (next === prev) return;
setBerthPrice.mutate({ berthId: row.berthId, price: next });
};
return (
<div
className={cn(
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
</div>
</TooltipProvider>
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
this interest only; flows into the EOI/document {{berth.price}} token. */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Deal price</p>
<p className="text-xs text-muted-foreground">
Overrides the berth&apos;s list price for this deal only. Leave blank to use the list
price.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
placeholder="List price"
value={priceDraft}
disabled={isPending || setBerthPrice.isPending}
onChange={(e) => setPriceDraft(e.target.value)}
onBlur={commitPrice}
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
/>
{row.priceOverrideCurrency ? (
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
) : null}
</div>
</div>
{showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't

View File

@@ -1,8 +1,10 @@
'use client';
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
import { Sidebar } from '@/components/layout/sidebar';
import { Topbar } from '@/components/layout/topbar';
import { NavigationHistoryTracker } from '@/components/layout/navigation-history-tracker';
@@ -112,6 +114,30 @@ export function AppShell({
const currentPortId = useUIStore((s) => s.currentPortId);
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
// Residential lockdown: a residential-only user (residential access, no
// marina `clients.view`) must never see marina pages — including the marina
// dashboard. The API already 403s their data; this guard blocks the *routes*,
// redirecting any non-residential path to their residential home. Personal
// surfaces (settings, inbox) stay reachable.
const pathname = usePathname();
const router = useRouter();
const { can } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
useEffect(() => {
if (!residentialOnly || !pathname) return;
const [portSeg, ...rest] = pathname.split('/').filter(Boolean);
const sub = rest.join('/');
const allowed =
sub === '' ||
sub.startsWith('residential') ||
sub.startsWith('settings') ||
sub.startsWith('inbox');
if (!allowed && portSeg) {
router.replace(`/${portSeg}/residential/clients`);
}
}, [residentialOnly, pathname, router]);
useEffect(() => {
const mqMobile = window.matchMedia(MOBILE_QUERY);
const mqTablet = window.matchMedia(TABLET_QUERY);

View File

@@ -37,7 +37,12 @@ import { cn } from '@/lib/utils';
import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from '@/components/notifications/notification-item';
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 {
data: Array<{
@@ -66,6 +71,7 @@ export function Inbox() {
const systemCritical = alertCount?.bySeverity.critical ?? 0;
const systemAlerts = alertList?.data ?? [];
const systemTop = systemAlerts.slice(0, 8);
const dismissAll = useDismissAll();
// ── Personal (notifications) ──
const { unreadCount: personalUnread } = useNotifications();
@@ -230,6 +236,17 @@ export function Inbox() {
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Active alerts
</h4>
<div className="flex items-center gap-3">
{systemAlerts.length > 0 ? (
<button
type="button"
onClick={() => dismissAll.mutate({})}
disabled={dismissAll.isPending}
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)}
@@ -238,6 +255,7 @@ export function Inbox() {
View all
</Link>
</div>
</div>
<Separator />
<ScrollArea className="max-h-[400px]">
{alertsLoading ? (

View File

@@ -2,9 +2,10 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Anchor, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { Anchor, ClipboardList, LayoutDashboard, Menu, Search, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { usePermissions } from '@/hooks/use-permissions';
type TabSpec = {
label: string;
@@ -12,16 +13,21 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/
};
// Left-of-center: Dashboard, Clients. Right-of-center: Berths, More.
// Search occupies the center slot. Documents demoted to the MoreSheet -
// reps reach docs less often than berths during a walking inventory check,
// and pinned-to-client documents are accessed via the client detail anyway.
const TABS_LEFT: TabSpec[] = [
// Marina users: Dashboard, Clients | Berths. Search center, More right.
const MARINA_TABS_LEFT: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Clients', icon: Users, segment: 'clients' },
];
const MARINA_TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
const TABS_RIGHT: TabSpec[] = [{ label: 'Berths', icon: Anchor, segment: 'berths' }];
// Residential-only users (e.g. residential partners) never have marina access,
// so the bottom tabs mirror their residential-only sidebar instead of showing
// Clients/Berths they 403 on (matches the AppShell route lockdown).
const RESIDENTIAL_TABS_LEFT: TabSpec[] = [
{ label: 'Clients', icon: Users, segment: 'residential/clients' },
{ label: 'Interests', icon: ClipboardList, segment: 'residential/interests' },
];
const RESIDENTIAL_TABS_RIGHT: TabSpec[] = [];
interface MobileBottomTabsProps {
onMoreClick: () => void;
@@ -31,6 +37,11 @@ interface MobileBottomTabsProps {
export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTabsProps) {
const pathname = usePathname();
const portSlug = pathname.split('/').filter(Boolean)[0] ?? 'port-nimara';
const { can, isSuperAdmin } = usePermissions();
const residentialOnly =
!isSuperAdmin && can('residential_clients', 'view') && !can('clients', 'view');
const tabsLeft = residentialOnly ? RESIDENTIAL_TABS_LEFT : MARINA_TABS_LEFT;
const tabsRight = residentialOnly ? RESIDENTIAL_TABS_RIGHT : MARINA_TABS_RIGHT;
function isActive(segment: string): boolean {
return pathname.startsWith(`/${portSlug}/${segment}`);
@@ -46,7 +57,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
'flex items-end',
)}
>
{TABS_LEFT.map((tab) => (
{tabsLeft.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}
@@ -60,7 +71,7 @@ export function MobileBottomTabs({ onMoreClick, onSearchClick }: MobileBottomTab
<span className="relative font-medium">Search</span>
</button>
{TABS_RIGHT.map((tab) => (
{tabsRight.map((tab) => (
<NavTab key={tab.segment} tab={tab} portSlug={portSlug} active={isActive(tab.segment)} />
))}

View File

@@ -9,9 +9,9 @@ import { useMobileChrome } from './mobile-layout-provider';
/**
* Fixed mobile topbar (56px + safe-area top inset). Marina-editorial premium:
* deep-navy gradient surface with white type, the brand "PN" mark on the
* left when there's no back affordance, and a soft glow shadow underneath
* for depth instead of a hard divider line.
* deep-navy gradient surface with white type, a back arrow on the left when
* there's a back affordance (otherwise a balancing spacer), and a soft glow
* shadow underneath for depth instead of a hard divider line.
*
* Slots: title (auto-truncating), back arrow, primary action - all driven by
* `useMobileChrome()` from the active page. When no page has set a title the
@@ -47,17 +47,6 @@ export function MobileTopbar() {
portTitle ||
'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 (
<header
className={cn(
@@ -71,15 +60,10 @@ export function MobileTopbar() {
{backTarget ? (
<BackButton variant="mobile" />
) : (
<div
aria-label={portTitle || 'Home'}
className={cn(
'size-9 shrink-0 rounded-lg flex items-center justify-center',
'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>
// No back affordance on top-level pages. Render an empty spacer the
// same width as the right-hand action slot so the centered title
// stays optically centered (the brand "PN" mark was removed here).
<div className="size-11 shrink-0" aria-hidden />
)}
<h1

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ interface ResidentialInterest {
source: string | null;
notes: string | null;
preferences: string | null;
residenceType: string | null;
assignedTo: string | null;
client: { id: string; fullName: string } | null;
}

View File

@@ -7,7 +7,9 @@ import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { apiFetch } from '@/lib/api/client';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { SOURCES } from '@/lib/constants';
import { RESIDENCE_TYPES } from '@/lib/validators/residential';
interface ResidentialInterest {
id: string;
@@ -16,6 +18,7 @@ interface ResidentialInterest {
source: string | null;
notes: string | null;
preferences: string | null;
residenceType: string | null;
assignedTo: string | null;
}
@@ -27,6 +30,7 @@ interface Args {
}
const SOURCE_OPTIONS = SOURCES.map((s) => ({ value: s.value, label: s.label }));
const RESIDENCE_TYPE_OPTIONS = RESIDENCE_TYPES.map((t) => ({ value: t, label: t }));
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
@@ -95,6 +99,8 @@ function OverviewTab({
stageOptions: Array<{ value: string; label: string }>;
}) {
const update = useInterestPatch(interestId);
// CM-5: residential assignment row hidden when the per-port toggle is off.
const assignmentEnabled = useFeatureFlag('assignment_enabled', false);
const save = (field: string) => async (next: string | null) => {
await update.mutateAsync({ [field]: next });
};
@@ -105,6 +111,7 @@ function OverviewTab({
}>({
queryKey: ['residential-assignable-users'],
queryFn: () => apiFetch('/api/v1/residential/assignable-users'),
enabled: assignmentEnabled,
});
const assigneeOptions = (assignableUsers?.data ?? []).map((u) => ({
value: u.id,
@@ -132,6 +139,7 @@ function OverviewTab({
onSave={save('source')}
/>
</Row>
{assignmentEnabled ? (
<Row label="Assigned to">
<InlineEditableField
variant="select"
@@ -141,10 +149,20 @@ function OverviewTab({
placeholder="Unassigned"
/>
</Row>
) : null}
</div>
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Details</h3>
<Row label="Residence type">
<InlineEditableField
variant="select"
options={RESIDENCE_TYPE_OPTIONS}
value={interest.residenceType}
onSave={save('residenceType')}
placeholder="Not set"
/>
</Row>
<Row label="Preferences">
<InlineEditableField
variant="textarea"

View File

@@ -320,9 +320,11 @@ interface ScanShellProps {
* imagery. */
logoUrl?: string | null;
portName?: string | null;
/** CM-6: when true, skip ALL parsing - open an empty form for manual entry. */
manualEntry?: boolean;
}
export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
export function ScanShell({ logoUrl, portName, manualEntry = false }: ScanShellProps = {}) {
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const fileRef = useRef<HTMLInputElement>(null);
@@ -351,6 +353,26 @@ export function ScanShell({ logoUrl, portName }: ScanShellProps = {}) {
if (imagePreview) URL.revokeObjectURL(imagePreview);
setImagePreview(URL.createObjectURL(file));
setCurrentFile(file);
// CM-6: manual-entry mode - the port admin disabled scanning. Skip
// Tesseract AND the server call entirely; go straight to an empty form.
if (manualEntry) {
setState({
kind: 'verify',
parsed: {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
},
source: 'manual',
reason: 'manual-mode',
});
return;
}
setState({ kind: 'processing', engine: 'tesseract' });
// Always run Tesseract first - it's free, on-device, and gives us a

View File

@@ -0,0 +1,249 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Phone, UserCheck, UserPlus } from 'lucide-react';
import { toast } from 'sonner';
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 { usePermissions } from '@/hooks/use-permissions';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
type ProxyEntityType = 'client' | 'interest' | 'yacht';
interface Proxy {
id: string;
name: string;
email: string | null;
phone: string | null;
relationship: string | null;
notes: string | null;
}
const RESOURCE: Record<ProxyEntityType, 'clients' | 'interests' | 'yachts'> = {
client: 'clients',
interest: 'interests',
yacht: 'yachts',
};
/**
* CM-9: point-of-contact ("proxy") panel for a client / interest / yacht detail
* page. Reads + edits the per-entity proxy via the entity's sub-resource route.
*/
export function ProxyCard({
entityType,
entityId,
}: {
entityType: ProxyEntityType;
entityId: string;
}) {
const { can } = usePermissions();
const canManage = can(RESOURCE[entityType], 'edit');
const qc = useQueryClient();
const base = `/api/v1/${RESOURCE[entityType]}/${entityId}/proxy`;
const queryKey = ['proxy', entityType, entityId];
const { data } = useQuery<{ data: Proxy | null }>({
queryKey,
queryFn: () => apiFetch(base),
});
const proxy = data?.data ?? null;
const [open, setOpen] = useState(false);
const remove = useMutation({
mutationFn: () => apiFetch(base, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Point of contact removed');
qc.invalidateQueries({ queryKey });
},
onError: (err) => toastError(err),
});
return (
<div className="rounded-xl border border-border bg-card p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<UserCheck className="h-4 w-4 text-muted-foreground" aria-hidden />
Point of contact
</h3>
{canManage ? (
<Button variant="ghost" size="sm" className="h-7" onClick={() => setOpen(true)}>
{proxy ? (
'Edit'
) : (
<>
<UserPlus className="me-1 h-3.5 w-3.5" aria-hidden />
Add
</>
)}
</Button>
) : null}
</div>
{proxy ? (
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">
{proxy.name}
{proxy.relationship ? (
<span className="ms-2 text-xs font-normal text-muted-foreground">
{proxy.relationship}
</span>
) : null}
</p>
{proxy.email ? (
<a
href={`mailto:${proxy.email}`}
className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"
>
<Mail className="h-3.5 w-3.5" aria-hidden />
{proxy.email}
</a>
) : null}
{proxy.phone ? (
<p className="inline-flex items-center gap-1.5 text-muted-foreground">
<Phone className="h-3.5 w-3.5" aria-hidden />
{proxy.phone}
</p>
) : null}
{proxy.notes ? <p className="text-xs text-muted-foreground">{proxy.notes}</p> : null}
{canManage ? (
<button
type="button"
onClick={() => remove.mutate()}
disabled={remove.isPending}
className="pt-1 text-xs text-destructive hover:underline disabled:opacity-50"
>
Remove
</button>
) : null}
</div>
) : (
<p className="text-sm text-muted-foreground">
No proxy set comms go to the {entityType} directly.
</p>
)}
{open ? (
<ProxyDialog
open={open}
onOpenChange={setOpen}
base={base}
existing={proxy}
entityType={entityType}
onSaved={() => qc.invalidateQueries({ queryKey })}
/>
) : null}
</div>
);
}
function ProxyDialog({
open,
onOpenChange,
base,
existing,
entityType,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
base: string;
existing: Proxy | null;
entityType: ProxyEntityType;
onSaved: () => void;
}) {
const [name, setName] = useState(existing?.name ?? '');
const [email, setEmail] = useState(existing?.email ?? '');
const [phone, setPhone] = useState(existing?.phone ?? '');
const [relationship, setRelationship] = useState(existing?.relationship ?? '');
const [notes, setNotes] = useState(existing?.notes ?? '');
// State seeds from `existing` at mount; the dialog is remounted on each open
// (the parent renders it conditionally), so no reseed effect is needed.
const save = useMutation({
mutationFn: () =>
apiFetch(base, {
method: 'PUT',
body: { name: name.trim(), email, phone, relationship, notes },
}),
onSuccess: () => {
toast.success('Point of contact saved');
onSaved();
onOpenChange(false);
},
onError: (err) => toastError(err),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Point of contact</DialogTitle>
<DialogDescription>
A person who acts as the point of contact for this {entityType}. Used to address
outbound comms.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="proxy-name">Name</Label>
<Input
id="proxy-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="proxy-email">Email</Label>
<Input
id="proxy-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-phone">Phone</Label>
<Input id="proxy-phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-rel">Relationship (optional)</Label>
<Input
id="proxy-rel"
placeholder="e.g. broker, spouse, assistant, legal"
value={relationship}
onChange={(e) => setRelationship(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-notes">Notes (optional)</Label>
<Input id="proxy-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => save.mutate()} disabled={!name.trim() || save.isPending}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
<span aria-hidden className="block h-9 w-9 shrink-0" />
</div>
{/* Owner subtitle */}
{/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
name truncates within the card instead of overflowing ~11px on
the narrowest mobile widths (R2). */}
{yacht.currentOwnerName ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{yacht.currentOwnerName}</span>
</p>

View File

@@ -12,6 +12,7 @@ import { TenancyCreateDialog } from '@/components/tenancies/tenancy-create-dialo
import { InlineEditableField } from '@/components/shared/inline-editable-field';
import { FieldHistoryProvider, FieldHistoryIcon } from '@/components/shared/field-history';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { ProxyCard } from '@/components/shared/proxy-card';
import { NotesList } from '@/components/shared/notes-list';
import { EntityActivityFeed } from '@/components/shared/entity-activity-feed';
import { TenancyList, type TenancyRow } from '@/components/tenancies/tenancy-list';
@@ -176,6 +177,10 @@ function OverviewTab({
return (
<FieldHistoryProvider scope={{ type: 'yacht', id: yachtId }}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* CM-9: per-vessel point-of-contact (overrides interest + client). */}
<div className="md:col-span-2">
<ProxyCard entityType="yacht" entityId={yachtId} />
</div>
{/* Identity */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Identity</h3>

View File

@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
* and the admin checklist summary. Cached for 60s so all three surfaces
* share a single fetch on first paint.
*
* Pass `enabled=false` to skip the network call (e.g. when the current
* user isn't a super_admin and the surface won't render anyway).
* Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
* callers must opt in with `enabled: true` once they've confirmed the user is
* a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin`
* during permission hydration) from firing the privileged request for
* non-admins.
*/
export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
return useQuery<OnboardingStatusPayload>({
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
(r) => r.data,
),
staleTime: 60_000,
enabled: opts.enabled ?? true,
enabled: opts.enabled === true,
retry: false,
});
}

View File

@@ -0,0 +1,56 @@
/**
* CM-9: shared GET/PUT/DELETE handlers for the per-entity proxy sub-resource
* (`/api/v1/{clients|interests|yachts}/[id]/proxy`). Each entity's route.ts
* binds these with its own permission resource so we reuse existing
* clients/interests/yachts gating instead of a new permission.
*/
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 { clearProxy, getProxy, setProxy } from '@/lib/services/proxies.service';
import { setProxySchema, type ProxyEntityType } from '@/lib/validators/proxies';
export function makeProxyHandlers(entityType: ProxyEntityType) {
const getHandler: RouteHandler = async (req, ctx, params) => {
try {
const proxy = await getProxy(ctx.portId, entityType, params.id!);
return NextResponse.json({ data: proxy });
} catch (error) {
return errorResponse(error);
}
};
const putHandler: RouteHandler = async (req, ctx, params) => {
try {
const body = await parseBody(req, setProxySchema);
const proxy = await setProxy(ctx.portId, entityType, params.id!, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: proxy });
} catch (error) {
return errorResponse(error);
}
};
const deleteHandler: RouteHandler = async (req, ctx, params) => {
try {
await clearProxy(ctx.portId, entityType, params.id!, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};
return { getHandler, putHandler, deleteHandler };
}

View File

@@ -0,0 +1,62 @@
import type { BrandingShell } from '@/lib/email/shell';
import { consumePendingWelcome } from './pending-welcome';
interface AuthBranding {
appName?: string | null;
logoUrl?: string | null;
backgroundUrl?: string | null;
}
/**
* Builds the email body for better-auth's `sendResetPassword` callback,
* choosing between two framings of the same set-password link:
*
* - a unique **welcome** email when the recipient was flagged by the admin
* "create user" flow (a brand-new user has nothing to reset), or
* - the standard **password-reset** email for genuine self-service resets.
*
* Pure aside from rendering — no SMTP, no DB — so the welcome-vs-reset routing
* is directly unit-testable.
*/
export async function buildAccountPasswordEmail(opts: {
email: string;
name?: string | null;
url: string;
appName: string;
authBranding: AuthBranding | null;
}): Promise<{ subject: string; html: string; text: string }> {
const emailBranding: BrandingShell | null = opts.authBranding
? {
logoUrl: opts.authBranding.logoUrl ?? null,
backgroundUrl: opts.authBranding.backgroundUrl ?? null,
primaryColor: null,
emailHeaderHtml: null,
emailFooterHtml: null,
}
: null;
if (consumePendingWelcome(opts.email)) {
const { crmWelcomeEmail } = await import('@/lib/email/templates/crm-welcome');
return crmWelcomeEmail(
{ link: opts.url, recipientName: opts.name ?? undefined, appName: opts.appName },
{ branding: emailBranding },
);
}
const { renderShell, safeUrl } = await import('@/lib/email/shell');
const subject = `Reset your ${opts.appName} password`;
const safeName = (opts.name || 'there').replace(/[<>&]/g, '');
const body = `
<p style="margin-bottom:16px;">Hi ${safeName},</p>
<p style="margin-bottom:16px;">You requested a password reset for your ${opts.appName} account.</p>
<p style="margin-bottom:16px;">
<a href="${safeUrl(opts.url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
- the link expires in 1 hour.
</p>
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
`;
const html = renderShell({ title: subject, body, branding: emailBranding });
const text = `Reset your password: ${opts.url}`;
return { subject, html, text };
}

View File

@@ -77,42 +77,28 @@ function buildAuth() {
// through the shared SMTP infra so EMAIL_REDIRECT_TO honours it
// in dev.
sendResetPassword: async ({ user, url }) => {
const [{ sendEmail }, { renderShell, safeUrl }, { resolveAuthShellBranding }] =
const [{ sendEmail }, { resolveAuthShellBranding }, { buildAccountPasswordEmail }] =
await Promise.all([
import('@/lib/email'),
import('@/lib/email/shell'),
import('@/lib/email/auth-shell-branding'),
import('@/lib/auth/account-setup-email'),
]);
const branding = await resolveAuthShellBranding();
const appName = branding?.appName ?? 'CRM';
const subject = `Reset your ${appName} password`;
const safeName = (user.name || 'there').replace(/[<>&]/g, '');
const body = `
<p style="margin-bottom:16px;">Hi ${safeName},</p>
<p style="margin-bottom:16px;">You requested a password reset for your ${appName} account.</p>
<p style="margin-bottom:16px;">
<a href="${safeUrl(url)}" style="color:#2563eb;font-weight:600;">Click here to set a new password</a>
- the link expires in 1 hour.
</p>
<p style="color:#64748b;">If you didn't request this, you can safely ignore this email.</p>
`;
const html = renderShell({
title: subject,
body,
branding: branding
? {
logoUrl: branding.logoUrl,
backgroundUrl: branding.backgroundUrl,
primaryColor: null,
emailHeaderHtml: null,
emailFooterHtml: null,
}
: null,
// Admin-created users ride the same reset-token machinery but should
// receive a welcome email, not a "you requested a reset" one — the
// create-user service marks them just before triggering this. The
// builder picks welcome-vs-reset and renders accordingly.
const mail = await buildAccountPasswordEmail({
email: user.email,
name: user.name,
url,
appName,
authBranding: branding,
});
const text = `Reset your password: ${url}`;
await sendEmail(user.email, subject, html, undefined, text);
await sendEmail(user.email, mail.subject, mail.html, undefined, mail.text);
},
},

View File

@@ -0,0 +1,29 @@
/**
* Bridges the admin "create user" flow to better-auth's single
* `sendResetPassword` callback.
*
* A brand-new admin-created user is provisioned with a throwaway password and
* then sent a set-password link via better-auth's password-reset machinery — but
* the *email* should read as a welcome, not a "you requested a reset". better-auth
* exposes only one `sendResetPassword` callback (no per-call context), so the
* create-user service marks the recipient here immediately before triggering the
* reset; the callback consumes the mark and renders the welcome email instead.
*
* Mark and consume happen in the same process within a single synchronous
* request flow (`createUser` awaits `requestPasswordReset`, which awaits the
* callback), so this module-level set is safe — it is never read across requests.
* Keyed by lowercased email; distinct recipients never collide.
*/
const pendingWelcome = new Set<string>();
export function markPendingWelcome(email: string): void {
pendingWelcome.add(email.toLowerCase());
}
/** Returns true (and clears the mark) when this email was flagged as a welcome. */
export function consumePendingWelcome(email: string): boolean {
const key = email.toLowerCase();
const had = pendingWelcome.has(key);
pendingWelcome.delete(key);
return had;
}

View File

@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
* (audit finding L23).
*/
export const PERMISSION_CATALOG = {
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
interests: [
'view',
'create',
@@ -69,6 +69,8 @@ export const PERMISSION_CATALOG = {
],
residential_clients: ['view', 'create', 'edit', 'delete'],
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
inquiries: ['view', 'manage'],
client_groups: ['view', 'manage'],
} as const satisfies {
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
};

View File

@@ -332,13 +332,27 @@ export function formatSource(source: string | null | undefined): string | null {
export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin',
director: 'Director',
sales_manager: 'Sales Manager',
// Single sales role for the deployment — `sales_manager` is the full-access
// sales map, surfaced to users simply as "Sales". `sales_agent` is retired
// from selection (see NON_ASSIGNABLE_ROLE_NAMES) but keeps its label for any
// legacy assignment still rendered.
sales_manager: 'Sales',
sales_agent: 'Sales Agent',
finance_manager: 'Finance Manager',
viewer: 'Viewer',
residential_partner: 'Residential Partner',
};
/**
* System roles that must not appear as choices when assigning a role to a
* user. `super_admin` is platform-owner-only (minted via the invitation
* flow's isSuperAdmin gate, never the role dropdown); `sales_agent` is
* superseded by the single "Sales" role. The create/edit user form still
* surfaces a user's *current* role even if it's listed here, so existing
* assignments stay editable.
*/
export const NON_ASSIGNABLE_ROLE_NAMES = new Set(['super_admin', 'sales_agent']);
/** Returns the human label for a stored role name. Falls back to a
* Title-Case rendering for legacy / custom roles. */
export function formatRole(role: string | null | undefined): string {

View File

@@ -0,0 +1,25 @@
-- 0092_inquiries_permission.sql
-- ----------------------------------------------------------------------------
-- New `inquiries` permission resource (view/manage) backing the top-level
-- Inquiries workbench (previously the inbox lived under /admin and was gated on
-- admin.view_audit_log, which sales roles don't have).
--
-- Existing role rows are backfilled so the resource defaults to whatever the
-- role's `clients` access is: view ⟵ clients.view, manage ⟵ clients.create.
-- This lights up the right roles (anyone who can see/create clients) without a
-- manual per-role edit, and defaults to deny for read-only roles.
--
-- New-key only and idempotent via the `? 'inquiries'` guard, so re-running is a
-- no-op. Per-user / port-role override tables are intentionally left untouched:
-- the deep-merge resolver fills missing leaves from the base role (same
-- reasoning as 0041).
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'inquiries', jsonb_build_object(
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'inquiries');

View File

@@ -0,0 +1,31 @@
-- 0093_website_submissions_inquiry_cols.sql
-- ----------------------------------------------------------------------------
-- Inquiries workbench: tracking + display columns on website_submissions.
-- converted_client_id / converted_interest_id - set when an operator converts
-- an inquiry into CRM entities (FK to clients/interests).
-- contact_name / contact_email - extracted from the JSONB payload at capture
-- time so the list view can search/sort/display via real columns.
--
-- Idempotent: ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS + a
-- COALESCE backfill that only fills nulls. Safe to re-run.
ALTER TABLE website_submissions
ADD COLUMN IF NOT EXISTS converted_client_id text REFERENCES clients(id),
ADD COLUMN IF NOT EXISTS converted_interest_id text REFERENCES interests(id),
ADD COLUMN IF NOT EXISTS contact_name text,
ADD COLUMN IF NOT EXISTS contact_email text;
CREATE INDEX IF NOT EXISTS idx_ws_contact_email
ON website_submissions (port_id, contact_email);
-- Backfill display columns from existing payloads (only where still null).
UPDATE website_submissions
SET contact_email = COALESCE(contact_email, NULLIF(payload->>'email', '')),
contact_name = COALESCE(
contact_name,
NULLIF(TRIM(CONCAT_WS(' ', payload->>'first_name', payload->>'last_name')), ''),
NULLIF(payload->>'name', ''),
NULLIF(payload->>'fullName', ''),
NULLIF(payload->>'full_name', '')
)
WHERE contact_email IS NULL OR contact_name IS NULL;

View File

@@ -0,0 +1,52 @@
-- 0094_client_groups.sql
-- ----------------------------------------------------------------------------
-- CM-1: first-class client groups (mailing/segment lists) + the membership
-- join, plus the new `client_groups` permission resource (view/manage).
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + a guarded role backfill.
-- Safe to re-run.
-- ─── 1. client_groups (per-port named group) ────────────────────────────────
CREATE TABLE IF NOT EXISTS client_groups (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
color text NOT NULL DEFAULT '#6B7280',
mailchimp_tag text,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_client_groups_port ON client_groups(port_id);
-- Per-port, case-insensitive name uniqueness among non-archived groups.
CREATE UNIQUE INDEX IF NOT EXISTS idx_client_groups_port_name
ON client_groups(port_id, lower(name))
WHERE archived_at IS NULL;
-- ─── 2. client_group_members (M2M join; carries port_id for tenant isolation) ─
CREATE TABLE IF NOT EXISTS client_group_members (
group_id text NOT NULL REFERENCES client_groups(id) ON DELETE CASCADE,
client_id text NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, client_id)
);
CREATE INDEX IF NOT EXISTS idx_cgm_client ON client_group_members(client_id);
CREATE INDEX IF NOT EXISTS idx_cgm_port ON client_group_members(port_id);
-- ─── 3. `client_groups` permission resource (view/manage) ────────────────────
-- New-key only + idempotent via the `? 'client_groups'` guard. Defaults to the
-- role's clients access (view ⟵ clients.view, manage ⟵ clients.create) so the
-- right roles light up without a manual per-role edit.
UPDATE roles
SET permissions = permissions || jsonb_build_object(
'client_groups', jsonb_build_object(
'view', COALESCE((permissions->'clients'->>'view')::boolean, false),
'manage', COALESCE((permissions->'clients'->>'create')::boolean, false)
)
)
WHERE permissions IS NOT NULL
AND NOT (permissions ? 'client_groups');

View File

@@ -0,0 +1,24 @@
-- 0095_proxies.sql
-- ----------------------------------------------------------------------------
-- CM-9: per-entity point-of-contact ("proxy") attachable to a client, interest,
-- or yacht. At most one per entity; outbound comms resolve the most specific
-- via yacht → interest → client. entity_id is polymorphic (no FK; validated in
-- the service against the right table). Idempotent — safe to re-run.
CREATE TABLE IF NOT EXISTS proxies (
id text PRIMARY KEY DEFAULT gen_random_uuid()::text,
port_id text NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
entity_type text NOT NULL,
entity_id text NOT NULL,
name text NOT NULL,
email text,
phone text,
relationship text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uniq_proxies_entity ON proxies(port_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_proxies_entity ON proxies(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_proxies_port ON proxies(port_id);

View File

@@ -0,0 +1,7 @@
-- CM-2 Part B: per-interest, per-berth deal-price override.
-- Null = use the berth's canonical list price (berths.price). When set, this
-- supersedes the list price for THIS interest's generated documents
-- (resolved in eoi-context via resolveBerthPriceForInterest).
ALTER TABLE interest_berths
ADD COLUMN IF NOT EXISTS price_override numeric,
ADD COLUMN IF NOT EXISTS price_override_currency text;

View File

@@ -0,0 +1,14 @@
-- Director becomes a senior-title twin of the single "Sales" role: identical
-- capabilities, no admin/settings access (admin stays Super-Admin-only).
--
-- The bootstrap seed inserts system roles with ON CONFLICT DO NOTHING, so
-- editing DIRECTOR_PERMISSIONS in seed-permissions.ts only affects fresh seeds.
-- Existing deployments need this data update to bring the stored `director`
-- row in line. Idempotent: re-running simply re-copies the sales map.
UPDATE roles AS d
SET permissions = sm.permissions,
description = 'Senior sales title. Full sales access, no admin/settings (Super-Admin only).',
updated_at = now()
FROM roles AS sm
WHERE d.name = 'director'
AND sm.name = 'sales_manager';

View File

@@ -0,0 +1,23 @@
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
-- GDPR data export). Previously the export routes were gated by
-- admin.manage_settings, which sales roles lack. This grants it to the
-- sales-capable system roles by default and makes it an explicit (off) toggle
-- everywhere else, so admins can withhold it per-user (which hides the button).
--
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
-- alone won't reach them — this backfills the key. Idempotent.
-- Sales-capable system roles get it ON by default.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
updated_at = now()
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
AND permissions ? 'clients';
-- Every other role that has a clients block but not the key yet defaults to OFF,
-- so the permission surfaces as an explicit toggle in the matrix.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
updated_at = now()
WHERE permissions ? 'clients'
AND NOT (permissions -> 'clients' ? 'gdpr_export');

View File

@@ -0,0 +1,5 @@
-- Residential interests: structured residence unit type the lead is pursuing
-- (e.g. "Two Bedroom Marina Villa"). Mirrors the multi-select on the website's
-- register-interest form. Nullable; additive — safe to apply online.
ALTER TABLE residential_interests
ADD COLUMN IF NOT EXISTS residence_type text;

View File

@@ -0,0 +1,73 @@
/**
* Client groups (CM-1) - first-class mailing/segment groups for clients.
*
* A `client_groups` row is a named, per-port group (e.g. a mailing list).
* `client_group_members` is the M2M join to `clients`. Membership carries its
* own `port_id` for defense-in-depth tenant isolation (same doctrine as the
* document-folders aggregated projection - port_id at every join).
*
* Optional Mailchimp mapping lives on the group row: `mailchimpTag` is the
* tag/segment name pushed to the port's single Mailchimp audience. Null until
* an admin wires Mailchimp up (the integration is inert without creds).
*/
import { sql } from 'drizzle-orm';
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { clients } from './clients';
import { ports } from './ports';
export const clientGroups = pgTable(
'client_groups',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
/** Chip color in the CRM UI. */
color: text('color').notNull().default('#6B7280'),
/** CM-1 Mailchimp: the tag/segment name this group maps to in the port's
* single Mailchimp audience. Null = not synced. */
mailchimpTag: text('mailchimp_tag'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index('idx_client_groups_port').on(table.portId),
// Per-port, case-insensitive name uniqueness among non-archived groups.
uniqueIndex('idx_client_groups_port_name')
.on(table.portId, sql`lower(${table.name})`)
.where(sql`${table.archivedAt} IS NULL`),
],
);
export const clientGroupMembers = pgTable(
'client_group_members',
{
groupId: text('group_id')
.notNull()
.references(() => clientGroups.id, { onDelete: 'cascade' }),
clientId: text('client_id')
.notNull()
.references(() => clients.id, { onDelete: 'cascade' }),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
primaryKey({ columns: [table.groupId, table.clientId] }),
index('idx_cgm_client').on(table.clientId),
index('idx_cgm_port').on(table.portId),
],
);
export type ClientGroup = typeof clientGroups.$inferSelect;
export type NewClientGroup = typeof clientGroups.$inferInsert;
export type ClientGroupMember = typeof clientGroupMembers.$inferSelect;
export type NewClientGroupMember = typeof clientGroupMembers.$inferInsert;

View File

@@ -7,6 +7,12 @@ export * from './users';
// Clients
export * from './clients';
// Client groups (CM-1 - mailing/segment groups)
export * from './client-groups';
// Proxies / points-of-contact (CM-9 - polymorphic across client/interest/yacht)
export * from './proxies';
// Companies
export * from './companies';

View File

@@ -99,6 +99,7 @@ export type AlertSeverity = 'info' | 'warning' | 'critical';
export const ALERT_RULES = [
'reservation.no_agreement',
'interest.stale',
'interest.no_activity',
'document.signer_overdue',
'berth.under_offer_stalled',
'expense.duplicate',

View File

@@ -165,6 +165,10 @@ export const interestBerths = pgTable(
addedBy: text('added_by'),
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
notes: text('notes'),
// CM-2 Part B: deal-specific price for THIS (interest, berth). Null = use
// the berth's canonical list price. Does not touch berths.price.
priceOverride: numeric('price_override'),
priceOverrideCurrency: text('price_override_currency'),
},
(table) => [
uniqueIndex('idx_ib_interest_berth').on(table.interestId, table.berthId),

View File

@@ -0,0 +1,48 @@
/**
* Proxies / points-of-contact (CM-9).
*
* A `proxy` is a designated contact person who acts on behalf of an entity.
* Polymorphic: attachable to a `client` (default), an `interest` (per-deal
* override), or a `yacht` (per-vessel override). At most one proxy per entity
* (unique index). Outbound comms resolve the most specific proxy via the chain
* yacht → interest → client (see resolveEffectiveProxy in proxies.service).
*
* `entity_id` is polymorphic (no FK) — validated against the right table in the
* service, same pattern as polymorphic yacht ownership / notes.
*/
import { index, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { ports } from './ports';
export const proxies = pgTable(
'proxies',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
/** 'client' | 'interest' | 'yacht' */
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
name: text('name').notNull(),
email: text('email'),
phone: text('phone'),
/** Free-form relationship label, e.g. broker / spouse / assistant / legal. */
relationship: text('relationship'),
notes: text('notes'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
// At most one proxy per entity.
uniqueIndex('uniq_proxies_entity').on(table.portId, table.entityType, table.entityId),
index('idx_proxies_entity').on(table.entityType, table.entityId),
index('idx_proxies_port').on(table.portId),
],
);
export type Proxy = typeof proxies.$inferSelect;
export type NewProxy = typeof proxies.$inferInsert;

View File

@@ -97,6 +97,13 @@ export const residentialInterests = pgTable(
* heavily. Schema can grow into structured columns later if needed.
*/
preferences: text('preferences'),
/**
* Structured residence unit type the lead is pursuing (e.g. "Two Bedroom
* Marina Villa"). Mirrors the multi-select on the website's register-interest
* form; on a structured interest it captures the single unit type being
* worked. Nullable - older rows + manual entries may leave it unset.
*/
residenceType: text('residence_type'),
/**
* better-auth user id of the residential team member working this lead.
*/

View File

@@ -11,6 +11,9 @@ export type RolePermissions = {
delete: boolean;
merge: boolean;
export: boolean;
/** Trigger + download a GDPR data export for a client. Toggleable so it
* can be hidden from a user (e.g. a sales rep) when withheld. */
gdpr_export: boolean;
};
interests: {
view: boolean;
@@ -162,6 +165,14 @@ export type RolePermissions = {
delete: boolean;
change_stage: boolean;
};
inquiries: {
view: boolean;
manage: boolean;
};
client_groups: {
view: boolean;
manage: boolean;
};
};
/**

View File

@@ -51,6 +51,18 @@ export const websiteSubmissions = pgTable(
* same form submission. Useful for reconciling: pick any submission
* here, look up the matching NocoDB row, confirm both halves agree. */
legacyNocodbId: text('legacy_nocodb_id'),
/** Contact name + email extracted from `payload` at capture time so the
* inquiry list can search/sort/display via real columns (payload stays
* JSONB and isn't searched directly). Populated by the capture endpoint
* and backfilled in migration 0093. */
contactName: text('contact_name'),
contactEmail: text('contact_email'),
/** Set when an operator converts this inquiry into CRM entities. FK enforced
* at the DB level (migration 0093); typed as plain text here to avoid a
* circular schema import — `clients`/`interests` already reference
* `website_submissions`. */
convertedClientId: text('converted_client_id'),
convertedInterestId: text('converted_interest_id'),
/** Capture-time metadata for debugging. */
sourceIp: text('source_ip'),
userAgent: text('user_agent'),

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