28 Commits

Author SHA1 Message Date
Matt Ciaccio
c612bbdfd9 fix(migration): legacy bare-mooring lookup + port-nimara berth backfill
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m12s
Build & Push Docker Images / build-and-push (push) Has been skipped
Two issues surfaced when applying the migration to dev:

1. Mooring number format mismatch
   The legacy NocoDB Interests table writes bare mooring strings
   ("D32", "B16", "A4"), but the new berths table (mirroring the
   NocoDB Berths snapshot) uses zero-padded dashed form ("D-32",
   "B-16", "A-04"). The interest→berth lookup missed every reference.

   migration-apply.ts now tries the literal value first, then falls
   back to a normalized form via `normalizeLegacyMooring(raw)`:
     "D32" -> "D-32"
     "A4"  -> "A-04"
     "E18" -> "E-18"
   Multi-mooring strings ("A3, D30") are left as-is so they surface in
   the warnings list for human review rather than silently picking one.

2. port-nimara only had the 12 hand-rolled seed berths, not the 117-
   berth NocoDB snapshot
   The mobile-foundation seed only places those 12 in port-nimara; the
   117-berth snapshot was added later but only seeded into Marina
   Azzurra (the secondary test port). Migrated interests reference
   moorings well beyond A-01..D-03, so most lookups failed.

   New scripts/load-berths-to-port-nimara.ts: idempotently loads any
   missing snapshot berths into port-nimara without disturbing the
   existing 12 (skips moorings that already exist). Run once;
   subsequent runs no-op.

Result of full migration run on dev:
  237 clients inserted (out of 245 total — 8 from prior seed)
  406 contacts, 52 addresses, 38 yachts, 252 interests
  27 interest→berth links resolved (only 13 source rows had a Berth
  field set in NocoDB to begin with — most legacy interests are early
  inquiries with no berth assignment)
  1 unresolved warning: source=277 has multi-mooring "A3, D30"

Verified in UI:
  /port-nimara/clients shows real names (John-michael Seelye, Reza
  Amjad, Etiennette Clamouze, …)
  /port-nimara/clients/<id> renders contacts (gmail.com addresses,
  E.164 phones), tab counts (Interests N, Yachts N), pipeline summary
  Dashboard: 245 clients, 266 active interests, $46.5M pipeline value
  Pipeline funnel chart now shows real distribution (180 Open, 45
  EOI Signed, dropoff through stages)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:05:11 +02:00
Matt Ciaccio
872c75f1a1 fix(safety): plug 3 EMAIL_REDIRECT_TO leaks + 10 unit tests + live smoke
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
A pre-import audit caught three places where outbound comms could escape
even with EMAIL_REDIRECT_TO set. Plugged each, added unit tests so the
behavior can't silently regress, and shipped a live smoke script the
operator can run before any production data import.

Leak 1: email-compose.service.ts (per-account user composer)
  Built its own nodemailer transporter and called sendMail() directly,
  bypassing the centralized sendEmail()'s redirect. Now mirrors the same
  redirect: when EMAIL_REDIRECT_TO is set, "to" is rewritten, "cc" is
  dropped, and the subject is prefixed with "[redirected from <orig>]".

Leak 2: documenso-client.sendDocument()
  Tells Documenso to actually email the document. Recipient emails were
  rerouted at create-time (in pass-3) but a document created BEFORE the
  redirect was turned on could still trigger a real-client email. Now
  short-circuited when the redirect is set — returns the existing doc
  shape so downstream code doesn't see an unexpected null.

Leak 3: documenso-client.sendReminder()
  Same shape as sendDocument: emails a stored recipient address that may
  predate the redirect. Now short-circuits with a warn-level log.

Tests (tests/unit/comms-safety.test.ts):
  - createDocument rewrites recipients
  - generateDocumentFromTemplate rewrites both v1.13 formValues.*Email
    keys AND v2.x recipients[] arrays
  - sendDocument is short-circuited (no /send call)
  - sendReminder is short-circuited (no /remind call)
  - createDocument passes through unchanged when redirect unset
  - sendEmail rewrites to + subject for single recipient
  - sendEmail handles array of recipients (joined into subject prefix)
  - sendEmail passes through unchanged when redirect unset
  - Webhook worker reads process.env.EMAIL_REDIRECT_TO at dispatch time
    (no module-level caching that could miss a runtime flip)

Live smoke (scripts/smoke-test-redirect.ts):
  Monkey-patches nodemailer.createTransport, calls the real sendEmail()
  with a fake real-client address, verifies the captured outbound has
  the right "to" + subject. Run: `pnpm tsx scripts/smoke-test-redirect.ts`.
  Exits non-zero if the redirect failed for any reason — drop-in for a
  pre-deploy check.

Verification:
  pnpm exec tsc --noEmit       — 0 errors
  pnpm exec vitest run         — 936/936 (was 926, +10 new safety tests)
  pnpm tsx scripts/smoke-test-redirect.ts — PASS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:55:53 +02:00
Matt Ciaccio
c45aac551d feat(dedup): wire --apply path for NocoDB migration
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 3m41s
Completes the migration script's apply phase, which was stubbed at
the P3 ship to defer until after the runtime surfaces (P2) and the
comms safety net were in place. Both prerequisites just landed on
main, so this unblocks the actual data import.

src/lib/dedup/migration-apply.ts (new):
  Idempotent apply driver. Walks the MigrationPlan, inserting clients,
  contacts, addresses, yacht stubs, and interests, threading every
  insert through the migration_source_links ledger so re-runs against
  the same data are safe. Per-entity transactions (not one giant
  transaction) so partial-failure resumption is just "run again."

  Per-entity behavior:
    - clients: idempotent on (source_system, source_id, target_type=client)
      across the entire dedup cluster — if any source row already maps
      to a client, reuse that record.
    - contacts: bulk insert, primary email + primary phone independent.
    - addresses: bulk insert, port_id required (schema enforces it),
      first address marked primary when multiple.
    - yachts: minimal stub when the legacy interest had a yachtName,
      currentOwnerType=client + currentOwnerId=migrated client. Linked
      via migration_source_links target_type=yacht.
    - interests: looks up berthId via mooring number, yachtId via the
      stub above. Carries Documenso ID forward when present.

  surnameToken from PlannedClient is dropped on insert (it's a dedup
  blocking-index artifact; runtime dedup re-derives from fullName).

scripts/migrate-from-nocodb.ts:
  - Removes the "not yet implemented" guard for --apply.
  - Adds EMAIL_REDIRECT_TO precondition gate: --apply errors out unless
    the env var is set, OR --unsafe-skip-redirect-check is also passed
    (production cutover only). Refers to docs/operations/outbound-comms-safety.md.
  - Re-fetches NocoDB at apply time (rather than reading a saved report
    dir) so the data is always fresh. Re-running is safe via the
    idempotency ledger.
  - Resolves target port via --port-slug (or first port if omitted).
  - Generates a UUID applyId tagged on every link, which pairs with a
    future --rollback flag.
  - Apply summary prints inserted/skipped counts per entity type plus
    the first 20 warnings.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.
The actual end-to-end run requires NOCODB_URL + NOCODB_TOKEN in .env
which aren't configured in this checkout; that's the operator's next
step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 19:53:04 +02:00
Matt Ciaccio
9ad1df85d2 fix(residential): mobile card list alongside the desktop table
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m12s
Build & Push Docker Images / build-and-push (push) Failing after 5m42s
Both the residential-clients and residential-interests pages rendered
plain HTML <table>s with 5–6 columns directly. At 390px viewport the
header columns clipped at the right edge — "Sour..." for the clients
page, no header for the interests page either.

Adds a parallel mobile card list:
  - <table> stays inside `hidden lg:block` (unchanged at lg+)
  - new card list inside `lg:hidden` mirrors the row data:
    - Clients: name + status pill on top, then email · phone ·
      residence · source as a wrap-friendly meta row.
    - Interests: stage label as headline, updated-at on the right,
      preferences (line-clamp-2) and notes (line-clamp-1) below,
      source small at the bottom.
  - Each card is a Link to the detail page (matching the row click
    target on desktop).
  - Empty + loading states render as a centered card on mobile.

This is the same `hidden lg:block` / `lg:hidden` pattern used for the
main /clients and /interests pages. Doesn't refactor to the full
DataView primitive (would mean rebuilding the residential data layer
on TanStack Table) — keeps the change tightly scoped to the visible
output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:58 +02:00
Matt Ciaccio
8e4d2fc5b4 feat(safety): EMAIL_REDIRECT_TO now also pauses Documenso + webhooks
Closes a gap exposed by the comms safety audit: the existing
EMAIL_REDIRECT_TO env var only redirected outbound SMTP via the
sendEmail() bottleneck. Two channels still leaked when set:

  1. Documenso e-signature recipients — Documenso's own server emails
     them on our behalf, so SMTP redirect doesn't help. We were sending
     real client emails to the Documenso REST API, which would then
     deliver to the real client.

  2. Outbound webhooks — fire from the BullMQ worker to user-configured
     URLs. SSRF guard blocks internal hosts but doesn't pause production
     endpoints.

Documenso (src/lib/services/documenso-client.ts):
  - createDocument: rewrite every recipient.email to EMAIL_REDIRECT_TO
    and prefix the recipient.name with the original email so the doc
    is traceable.
  - generateDocumentFromTemplate: same treatment for both v1.13
    formValues.*Email keys and v2.x recipients[]. The redirect happens
    BEFORE the API call, so even Documenso's own retry logic can't
    reach the original recipient.
  - Both paths log when they redirect so it's visible in dev.

Webhooks (src/lib/queue/workers/webhooks.ts):
  - When EMAIL_REDIRECT_TO is set, short-circuit the dispatch and write
    a `dead_letter` row with reason "Skipped: EMAIL_REDIRECT_TO is set,
    outbound comms paused." so the attempt is still visible in the
    deliveries listing.

Doc:
  docs/operations/outbound-comms-safety.md catalogs every outbound
  comms channel (email, Documenso, webhooks, WhatsApp/phone deep-links,
  SMS-not-implemented) and explains how each one respects the env flag.
  Includes a verification checklist to run before any production data
  import + cutover steps for going live.

Single env var EMAIL_REDIRECT_TO now reliably pauses ALL automated
outbound comms. Unset for production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:41 +02:00
Matt Ciaccio
78f2f46d41 fix(admin): stack settings rows vertically on phone widths
Inquiry Settings + Business Rules cards used a flex-row layout that
crushed the label column into a narrow vertical stripe at 390px ("Inquiry
/ Contact / Email" wrapping one word per line) while the input took the
right side.

Stack label + helper text above the input on phone widths; restore the
side-by-side row from sm up. Same pattern as the other detail-edit rows
that were fixed in pass-2/pass-3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:20 +02:00
Matt Ciaccio
3a9419fe10 chore(scripts): backfill client_contacts.value_e164 from value
One-shot script that walks every phone / whatsapp contact with `value`
set but `value_e164` null and runs the raw value through libphonenumber-js
to produce the canonical E.164 form. Matches the existing dedup
phone-parser shape (script-safe wrapper that loads metadata as raw JSON
to dodge the Node 25 + tsx interop bug).

Two output buckets:
  - parsed cleanly: e164 + country both resolved (33/36 in dev).
  - parsed e164 only: e164 came back but country didn't (3/36 — the
    UK +44 7700 900xxx fictional/reserved range that libphonenumber
    refuses to assign a country to but still returns a canonical e164
    for). Still safe to write — the e164 form is the canonical one.

Run dry-first, --apply to write:
  pnpm tsx scripts/backfill-phone-e164.ts
  pnpm tsx scripts/backfill-phone-e164.ts --apply

Applied to dev DB this session: 36 rows backfilled, 0 still missing.
Will need to be re-run after any future seed reload that introduces
unparsed phones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:24:08 +02:00
Matt Ciaccio
b703684285 fix(ux): pass-3 — yacht/company headers, reminder filters wrap, client tab counts
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m14s
Build & Push Docker Images / build-and-push (push) Failing after 4m51s
Five small fixes from the third audit pass on previously-unchecked surfaces:

Yacht detail header (mobile):
  - Stack the action cluster (Edit / Transfer / Archive) below the title
    block on phone widths. Previously the three buttons crowded the right
    side enough to truncate the status pill to "A..." and force the owner
    name to wrap to two lines. Same fix that landed for berth / client /
    company headers.

Company detail header (mobile):
  - Same mobile stacking fix; legal-name + Tax-ID metadata no longer
    wraps awkwardly.

Company detail Incorporation Date (all viewports):
  - Strip the time portion of the ISO timestamp before passing to the
    inline editor. Previously rendered the raw "2019-03-14T00:00:00.000Z"
    Postgres-serialized form. Now reads "2019-03-14" and round-trips
    through the YYYY-MM-DD inline editor cleanly.

Reminders list filter row:
  - Allow flex-wrap on the My/All tabs + status filter + priority filter
    cluster. At 390px, the priority filter dropdown was being pushed off
    the right edge of the screen.

Client detail tab counts:
  - Add interestCount + noteCount to getClientById response, surface as
    badges on the Interests + Notes tabs. Brings them into parity with
    Yachts/Companies/Reservations/Addresses which already showed counts;
    Files + Activity are still stubs and don't get a count yet.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.

Out of scope (deferred):
  - Residential clients / interests pages still render plain HTML tables
    on phone widths (header columns clip at the right edge). Needs the
    DataView card-on-mobile treatment that the main /clients and
    /interests pages already have. Substantial separate work.
  - Phone contacts in the legacy seed have value set but valueE164 NULL,
    so InlinePhoneField shows "—" even though metadata is technically
    populated. Fix is a one-time backfill via libphonenumber-js, not a
    UI change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:09:27 +02:00
Matt Ciaccio
a792d9a182 fix(ux): pass-2 audit fixes — admin grouping, Duplicates entry, header tooltips
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m11s
Build & Push Docker Images / build-and-push (push) Failing after 5m45s
Three small but high-leverage fixes from the second audit pass on main:

Admin index (src/app/(dashboard)/[portSlug]/admin/page.tsx):
  - Grouped 21 sections into 7 categories: Access, Configuration, Content,
    Data Quality, Operations, Tenancy, Integrations. Each group has a
    one-line description so first-time admins can orient themselves
    without reading every card.
  - Added the missing Duplicates entry (links to /admin/duplicates from
    the dedup-migration work) under Data Quality.

More sheet (mobile bottom-drawer nav):
  - "Email" -> "Inbox". The page that opens is an email-inbox surface
    (Inbox + Accounts tabs), not a generic email composer. The previous
    label was ambiguous.

Interest detail header (Won / Lost outcome buttons):
  - Added title="Mark as won" / "Close as lost" so the icon-only buttons
    on mobile have a tooltip on long-press / desktop hover.
  - Tightened mobile padding (px-2 vs px-2.5) so the full-text desktop
    labels still fit on sm+ without re-introducing a regression where a
    visible mobile "Won"/"Lost" inline label crowded the right cluster
    enough to push Email/Call/WhatsApp action chips into a vertical
    stack.

Verification: 0 tsc errors, 926/926 vitest passing, lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:32 +02:00
Matt Ciaccio
d7ec2a8507 Merge docs/dedup-migration-design: client dedup + NocoDB migration design doc
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m18s
Build & Push Docker Images / build-and-push (push) Failing after 3m57s
2026-05-03 16:24:30 +02:00
Matt Ciaccio
cb83b09b2d Merge feat/dedup-migration: client dedup library + NocoDB migration script + admin queue
# Conflicts:
#	.gitignore
#	src/lib/db/migrations/meta/_journal.json
2026-05-03 16:24:13 +02:00
Matt Ciaccio
bb105f5365 Merge feat/mobile-ux-polish: berth/header/tab/contacts mobile fixes
# Conflicts:
#	src/components/clients/contacts-editor.tsx
2026-05-03 16:20:12 +02:00
Matt Ciaccio
caafae15dd Merge feat/berth-schema-parity: NocoDB field parity, 117-berth seed, ports pruned to Port Nimara + Amador 2026-05-03 16:18:43 +02:00
Matt Ciaccio
46c7389930 Merge feat/mobile-foundation: 212 commits of mobile foundation, sales UX, audit fixes 2026-05-03 16:18:10 +02:00
Matt Ciaccio
80fc5932be chore: ignore tooling caches, scratch screenshots, sister website project
Three categories added to .gitignore:

  Tool caches / runtime state:
    .claude/                — Claude Code session state + lock files
                              (scheduled_tasks.lock untracked here)
    .serena/                — Serena MCP project cache
    ruvector.db             — RuVector AgentDB binary

  Scratch screenshots:
    /*.jpg                  — debug captures dropped at repo root
    /.audit-screenshots/    — UX audit run output (regenerable)

  Sister project:
    /website/               — separate Nuxt marketing site, kept on disk
                              for reference but not tracked in this repo

The single tracked file in .claude/ (scheduled_tasks.lock) is removed
from the index here; future dev sessions won't bring it back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:17:13 +02:00
Matt Ciaccio
b26b87b2fa chore(dev): react-grab viewport sync helper for in-page debug toolbar
Mounts a dev-only client component that syncs the react-grab debug
toolbar's pinned edge / collapsed state across viewport changes (so
the toolbar doesn't drift off-screen when resizing or rotating).

Render is gated by NODE_ENV === 'development' in src/app/layout.tsx;
production builds tree-shake the import out via process.env replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:47 +02:00
Matt Ciaccio
88f76b6b04 feat(mobile): rework bottom nav (Dashboard/Berths/Clients/Documents/More)
Old order  : Dashboard / Clients / Yachts  / Berths    / More
New order  : Dashboard / Berths  / Clients / Documents / More

Reasoning (also captured as in-file comments above each tab list):
  - Yachts is asset-record traffic — rarely browsed standalone, almost
    always reached from inside an interest or client. Pulled out of the
    bottom row, kept available in the More sheet.
  - Documents (signature tracking / EOI queue) earns a slot at the
    bottom because reps chase signers as a daily activity.
  - Interests is intentionally NOT in the bottom row: having both
    Clients and Interests as peer tabs created a Clients-vs-Interests
    confusion for sales reps. The new per-client Interests tab + the
    bottom-sheet drawer (see ClientInterestsTab) cover the day-to-day
    deal review without needing a dedicated bottom-nav peer.
  - Clients moves to the center: it's the primary mental anchor for
    "find this person", with everything else (yachts, companies,
    interests) reached as a tab on the client detail page.

More-sheet reorder mirrors the new priority: Interests / Yachts /
Companies first (most-likely overflow targets), then financial
(Invoices, Expenses), then Email / Alerts / Reports / Reminders /
Settings / Admin. Documents removed from the More sheet (now in
the bottom row).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:37 +02:00
Matt Ciaccio
a32f41b91d fix(dashboard): scope h-full to xl + tighter mobile sizing on KPIs
The Alerts rail and Reminders rail were using h-full unconditionally, which
worked at xl: where the dashboard grid pairs them with a sibling chart
column, but produced weirdly stretched empty cards in the single-column
mobile stack (no fixed-height context to fill).

  alert-rail / my-reminders-rail: h-full -> xl:h-full

KPI tiles + skeleton rendered the same desktop padding (p-5) and font sizes
on phone, leaving the value cramped against a wide white frame. Tighter
mobile defaults that scale up at sm:

  KPITile      p-3 sm:p-5, label text-[10px] sm:text-xs,
               value mt-1 text-lg sm:mt-2 sm:text-2xl, value truncates
  KpiTileSkeleton: matching p-3 sm:p-5 + smaller skeleton bars on mobile

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:20 +02:00
Matt Ciaccio
cf1c8b66db feat(client): phone-edit row dilation + mobile contacts layout
InlinePhoneField now lays the country picker + number on top, with Save +
Cancel buttons on a second line — the previous single-line cluster was
cramped at every viewport size and broke entirely below ~480px.

A new onEditingChange callback notifies the parent when the field enters
edit mode, so contact rows can react. ContactsEditor uses it to "dilate"
the row visually: lift out of the muted baseline with a soft primary
ring + slightly brighter surface + bumped padding. Single visual signal
replaces the need for any "now editing" label, and the dilation also
hides the noisy chip cluster (label / star / trash) that would otherwise
fight the editor for space.

Mobile improvements applied at the same time:
  - Each row stacks value editor on top, action cluster below at <sm
  - Action cluster ("Add tag" + Make-primary star + trash) uses
    justify-end on the new row so it doesn't collide with the picker
  - Trash icon stays opacity-0/group-hover on desktop but is always
    visible on touch (no hover state on touch) — sm:opacity-0 +
    sm:group-hover:opacity-100 instead of the prior unconditional fade
  - NewContactForm wraps onto multiple lines below sm (basis-full on
    the value field) so the channel picker, value, label, and buttons
    each get usable width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:15:07 +02:00
Matt Ciaccio
596476280d feat(ui): inline-edit dropdowns auto-open + auto-exit on dismiss
When a user clicks an inline-edit affordance for country / timezone /
subdivision, the field flipped to its combobox trigger but the popover
didn't open — they had to click again. And if they dismissed the popover
without picking, the field stayed in edit mode showing a "Select country…"
trigger they couldn't get out of.

Combobox primitives (country / timezone / subdivision) now accept:
  - defaultOpen — open on first render
  - onOpenChange — fired on every open/close transition

InlineCountryField / InlineTimezoneField / and the country + subdivision
fields inside addresses-editor pass defaultOpen=true and use onOpenChange
to auto-exit edit mode when the popover closes without a selection. A
pickedRef gate prevents the close-handler from racing the commit() exit
when the user does pick a value.

Bonus: addresses-editor now renders a flag emoji next to the country name
in the read-only state (regional-indicator pair from the ISO code).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:51 +02:00
Matt Ciaccio
e9359fc431 feat(client): interests tab + pipeline summary panel + list-row counts
Promotes interests from a stub tab to a first-class surface on the client
detail page, and surfaces pipeline activity in two more places:

UI:
  - New ClientInterestsTab (475 lines) — table of every active interest
    for the client with stage-stepper visualization, lead category, source,
    last-activity timestamp, and a drawer-on-tap row preview.
  - New OverviewTab pipeline-summary panel above the existing 2-column
    layout, rendering ClientPipelineSummary (already on this branch) in
    its panel variant. Reps see the live pipeline at a glance without
    leaving Overview.
  - Removes "Preferred Language" inline field from the Overview tab and
    the create form — unused, and the field added noise without driving
    any downstream behavior.
  - Tab order: Overview / Interests / Yachts / Companies / ... (Interests
    moves up from the back of the list, where it was a stub anyway).

Data:
  - listClients now returns interestCount + latestInterest{stage, mooring}
    per row, joined from interests + berths in two parallel queries.
    ClientRow type updated to surface them; Client list views can now
    render "3 interests · last on D-02 (EOI Signed)" without a per-row
    fetch.
  - Contact rows in client detail now expose valueE164 + valueCountry to
    the UI (already returned by the API; just wasn't typed through the
    detail-page contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:37 +02:00
Matt Ciaccio
4767caec01 feat(client): redesign detail header with action chips + condensed meta
Replaces the inline "Source · email · phone" text strip with three primary
action chips and a smaller meta line:

  Mail / Call / WhatsApp action buttons surface the most-used outbound
  contacts on a single tap. WhatsApp deep-link strips the leading + from
  the canonical E.164 number (or falls back to digit-only of the value).

  Meta line now reads "Country · Added MMM d, yyyy" using nationalityIso
  resolved through getCountryName(); date-fns formats createdAt.

  Portal Invite + GDPR Export buttons remain available but only render
  on sm+; on mobile they're reachable through the More sheet.

  Archive / Restore is now a small icon button in the top-right corner
  rather than a labeled button competing with the primary action chips.
  Destructive intent stays out of the main action flow; hover swaps to
  destructive color for archive (and stays neutral for restore).

The previous source/preferred-contact-method/preferred-language/timezone
fields no longer render in the header — they live on the Overview tab via
the inline editor pattern (see client-tabs.tsx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:19 +02:00
Matt Ciaccio
49d92234dd fix(test): align stage names with consolidated pipeline enum
Followup to 886119c (refactor(sales): consolidate pipeline stages) — the
runtime enum was renamed but a few test fixtures and PDF report templates
still referenced the legacy names, leaving them broken at the type level
(36 tsc errors before this fix).

Renames in this commit:
  visited        -> in_communication (alerts test) / removed (PDF reports)
  signed_eoi_nda -> eoi_signed
  contract       -> contract_signed (interests test) / contract_sent (factory)

Affected files: pipeline-report, revenue-report, makeCreateInterestInput
factory, alerts-engine, pipeline-transitions, interest-scoring.

Verification: tsc clean, 858/858 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:14:04 +02:00
Matt Ciaccio
cad55e3565 fix(mobile): clipping, dropdown-tabs and stale phone metadata
Five mobile-UX issues caught in the 2026-05-03 audit, fixed in one pass:

1. SpecRow on berth detail clipped at right edge on phone widths.
   "Length 49.21 ft / 15 r" (the "m" cut off). Mobile-first stack:
   label on top, value full-width below; flex row only from sm up.

2. ResponsiveTabs collapsed to a Select on phone widths, which read like
   a generic dropdown and obscured the existence of peer tabs. Replaced
   with a horizontally-scrollable strip that auto-scrolls the active
   trigger into view (so the user sees neighbors and gets a discovery
   cue that more exists beyond the edge). Removes the phone-only Select
   and unifies the tab UI across viewport sizes.

3. Documents page tab strip ("All / EOI queue / Awaiting them / ...")
   overflowed the 390px viewport because the wrapper was a fixed flex
   row. Same horizontal-scroll fix as (2); inherits because Documents
   uses ResponsiveTabs.

4. Berth detail header: "Change Status" + "Edit" buttons crowded the
   area subtitle on mobile, causing "North Pier" to wrap to two lines
   ("North" / "Pier"). Stacked vertically on phone widths; from sm up
   the buttons sit beside the title block as before.

5. Empty contact rows on client detail rendered a stale "Add tag · star"
   metadata strip even when the contact value was unset, which cluttered
   the row and offered no useful action. The metadata block now only
   shows when contact.value is non-empty; the trash icon stays visible
   so users can clean up the empty placeholder.

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Defers:
- Mobile More sheet last-row alignment / "Email" label specificity
- Admin index grouping (Access / System / Configuration / Content)
- Interest detail header icon labels (trophy/X discoverability)
- Pipeline funnel x-axis label abbreviations
- Reminders rail width allocation on dashboard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:03:56 +02:00
Matt Ciaccio
21868ee5fc feat(berths,seed): polish detail display + prune ports to Port Nimara + Amador
Berth detail (src/components/berths/berth-tabs.tsx):
- Numeric display polish, exposed by the new NocoDB-sourced seed:
  - Power capacity now renders with kW unit (e.g. "330 kW")
  - Voltage now renders with V unit (e.g. "480 V")
  - All metric/imperial values rounded to <= 2 decimals
    (was: "62.999112 m" -> now: "62.99 m")
  - Nominal Boat Size shows full ft + m pair (was: ft only)

Seed ports (src/lib/db/seed.ts):
- Drop Marina Azzurra and Harbor Royale; install now seeds only:
  - Port Nimara  (the real install)
  - Port Amador  (secondary, for multi-tenant isolation tests / Panama
                  scaffolding)
- Existing dev DBs are not touched; this only affects fresh `pnpm db:seed`
  runs. Users wanting to migrate should drop existing rows in the obsolete
  ports manually before re-seeding.

Verification:
- lint clean, tsc unchanged from baseline (36 pre-existing errors), 858/858
  vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:59:36 +02:00
Matt Ciaccio
c7ab816c99 feat(seed): replace 12 hand-rolled berths with 117-row NocoDB snapshot
The old seed only had 12 berths with made-up area names ("North Pier",
"Central Basin", etc.) and placeholder dimensions. Devs now get the real
117 berths exported from the legacy NocoDB Berths table — every editable
column populated with real production values.

What's in the snapshot (src/lib/db/seed-data/berths.json):
- 117 berths total (61 available / 45 under_offer / 11 sold)
- Areas A through E (matches NocoDB single-select)
- All numeric fields filled: length / width / draft (ft + m), water depth,
  nominal boat size, power capacity (kW), voltage (V)
- All NocoDB single-selects filled where present: side pontoon,
  mooring type, cleat/bollard type+capacity, access
- Bow facing, status_override_mode, berth_approved carried forward as-is
- Status normalized to lowercase snake_case ("Under Offer" -> "under_offer")
- Mooring numbers reformatted A1 -> A-01 to keep the existing "Letter-NN"
  convention used elsewhere in the codebase

Pre-sorted to preserve seed semantics:
  idx 0..4   -> 5 available  (small)   -- "open" / "details_sent" interests
  idx 5..9   -> 5 under_offer (medium) -- "eoi_signed" / "deposit" / "contract"
  idx 10..11 -> 2 sold (large)         -- "completed" interests
This means existing interest/reservation seeds that index berthRows[0..11]
keep their semantic alignment without code changes.

End-to-end verified by clearing Marina Azzurra and re-seeding:
  Port "Marina Azzurra" -- 117 berths, 8 clients, 3 companies, 12 yachts,
                           15 interests, 8 reservations

Future devs running `pnpm db:seed` on a fresh DB will now get realistic
berth data automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:41:12 +02:00
Matt Ciaccio
e40b6c3d99 feat(berths): full NocoDB field parity, numeric types, sales edit access
Aligns the berths schema with the 117 production rows in NocoDB and exposes
every field for editing via the BerthForm sheet.

Schema (migration 0020):
- power_capacity / voltage / nominal_boat_size / nominal_boat_size_m: text -> numeric
  (NocoDB stores plain numbers; text was wrong shape and broke filter/sort)
- ADD status_override_mode text (1/117 legacy rows have a value; carried
  forward for parity but not yet wired into the UI)
- USING NULLIF(TRIM(...), '')::numeric so legacy whitespace and empty
  strings convert cleanly

Validator + service:
- updateBerthSchema / createBerthSchema use z.coerce.number() for the
  four numeric fields
- berths.service stringifies numeric values for Drizzle's numeric type

Form (src/components/berths/berth-form.tsx):
- adds: nominal boat size (ft/m), water depth (ft/m) + "is minimum" flag,
  side pontoon, cleat type/capacity, bollard type/capacity, bow facing
- converts to typed selects (with NocoDB option lists in src/lib/constants):
  area, side pontoon, mooring type, cleat type/capacity, bollard type/capacity,
  access
- power capacity / voltage become numeric inputs (with kW / V hints)

Permissions (seed.ts + dev DB):
- sales_manager and sales_agent: berths.edit false -> true
  ("sales will sometimes have to update these and I cannot be the only one")
- super_admin / director already had it; viewer stays read-only
- dev DB updated in-place via UPDATE roles ... jsonb_set

Verification:
- pnpm exec vitest run: 858/858 passing
- pnpm exec tsc --noEmit: same 36 errors as baseline (all pre-existing
  on feat/mobile-foundation, none introduced)
- lint clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:30:32 +02:00
Matt Ciaccio
36b92eb827 docs(spec): client deduplication and NocoDB migration design
Captures the audit findings from a 2026-05-03 read-only NocoDB review
plus the algorithm and migration plan for porting the legacy data
into the new client / interest / contacts / addresses model.

Highlights:
- 252 NocoDB Interests rows ≈ ~190–200 unique humans (~20–25% dup
  rate). Six duplicate patterns documented from real data, including
  "same person, multiple yachts" — exactly the case the new
  client/interest split is designed to handle.
- Reuses the battle-tested `client-portal/server/utils/duplicate-
  detection.ts` algorithm (blocking + weighted rules) with additions:
  metaphone for non-English surnames, compounded confidence when
  multiple rules match, negative evidence for split-signal cases.
- Three runtime surfaces (at-create suggestion, interest-level
  same-berth guard, background scoring + admin review queue) plus a
  one-shot migration script with --dry-run / --apply / --rollback.
- Configurable thresholds via per-port system_settings so the merge
  policy can be tuned (defaults to "always confirm" — never
  auto-merges out of the box).
- Reversible: every merge writes a clientMergeLog row with the
  loser's full pre-state JSON, enabling 7-day undo without engineering.

Implementation decomposes into three plans (P1 library / P2 runtime /
P3 migration) sequenced after the mobile branch lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:10:08 +02:00
65 changed files with 17934 additions and 752 deletions

View File

@@ -1 +0,0 @@
{"sessionId":"fd05cbd7-d695-4a70-9223-4b25f3369829","pid":88534,"acquiredAt":1776866083076}

12
.gitignore vendored
View File

@@ -28,10 +28,22 @@ docker-compose.override.yml
# Ad-hoc screenshots / scratch artifacts at repo root # Ad-hoc screenshots / scratch artifacts at repo root
/*.png /*.png
/*.jpg
# Legacy Nuxt portal — kept on disk for reference, not tracked here # Legacy Nuxt portal — kept on disk for reference, not tracked here
/client-portal/ /client-portal/
# Sister marketing site — separate Nuxt project, not part of CRM tracking
/website/
# Mobile audit screenshots — generated locally, regenerable # Mobile audit screenshots — generated locally, regenerable
/.audit/ /.audit/
/.audit-screenshots/
# Migration script output (CSV reports, transcripts)
.migration/ .migration/
# Tool caches / runtime state
/.claude/
/.serena/
/ruvector.db

View File

@@ -0,0 +1,123 @@
# Outbound communications safety net
**Last reviewed:** 2026-05-03
**Owner:** matt@portnimara.com
This doc enumerates every channel through which the CRM can produce
outbound communication (email, document signing, webhooks) and describes
how each channel respects the `EMAIL_REDIRECT_TO` env var. The goal: a
single environment flip pauses **all** outbound traffic, so a production
data import, dedup migration dry-run, or staging environment can run
against real data without anyone getting paged or spammed.
> **Single env switch:** when `EMAIL_REDIRECT_TO` is set to an address,
> all outbound communication is rerouted there or short-circuited. Unset
> it in production.
---
## Channels
### 1. Direct email (`sendEmail`)
**Path:** `src/lib/email/index.ts``sendEmail()` → nodemailer SMTP transport.
**Safety:** YES — covered.
When `EMAIL_REDIRECT_TO` is set, `sendEmail()` rewrites the `to` header
to the redirect address and prefixes the subject with
`[redirected from <orig>]`. The original recipient is logged.
**Call sites** (all flow through `sendEmail`, so all are covered):
- `src/lib/services/portal-auth.service.ts` — portal activation + reset
- `src/lib/services/crm-invite.service.ts` — CRM user invitations
- `src/lib/services/document-templates.ts` — template-generated PDFs sent
as attachments (the PDF body is generated locally; the email itself
goes through SMTP)
- `src/lib/services/email-compose.service.ts` — ad-hoc emails composed
in the in-app UI
- `src/lib/services/gdpr-export.service.ts` — GDPR export delivery
### 2. Documenso e-signature recipients
**Path:** `src/lib/services/documenso-client.ts``createDocument()` /
`generateDocumentFromTemplate()` → Documenso REST API.
**Safety:** YES — covered as of 2026-05-03.
Documenso's own server sends the signing-request email on our behalf.
We can't intercept that at the SMTP layer because it's external. The
fix is at the REST-call boundary: when `EMAIL_REDIRECT_TO` is set,
`createDocument` rewrites every recipient's email to the redirect
address and prefixes the recipient name with `(was: <orig email>)` so
the doc is still traceable to its intended recipient.
`generateDocumentFromTemplate` does the same for both shapes the
template-generate endpoint accepts (v1.13 `formValues.*Email` keys and
v2.x `recipients` array).
The redirect happens **before** the API call, so even if Documenso has
its own retry logic the original email never leaves our process.
### 3. Webhooks (outbound to user-configured URLs)
**Path:** `src/lib/queue/workers/webhooks.ts` → BullMQ job → `fetch(webhook.url, ...)`.
**Safety:** YES — covered as of 2026-05-03.
When `EMAIL_REDIRECT_TO` is set, the webhook worker short-circuits
before the HTTP call. The delivery row is marked `dead_letter` with a
human-readable reason so it's still visible in the deliveries listing.
The SSRF guard remains in place independently.
### 4. WhatsApp / phone deep-links
**Path:** `<a href="https://wa.me/...">` and `<a href="tel:...">` in
client / interest detail headers.
**Safety:** N/A — user-initiated only.
These are deep links the user explicitly clicks. No automated dispatch.
A deep link click opens the user's WhatsApp / phone app, which is the
intended interaction. No safety net needed.
### 5. SMS
Not implemented. The `interests.preferredContactMethod` enum includes
`'sms'` as a value but no sending path exists. If/when SMS is added (e.g.
via Twilio), the new send function should respect `EMAIL_REDIRECT_TO`
the same way `sendEmail` does — log the original number, drop the
message, or reroute to a configurable `SMS_REDIRECT_TO` env.
---
## Verification checklist before importing real data
- [ ] `.env` has `EMAIL_REDIRECT_TO=<my-address>` set.
- [ ] Restart dev server (or worker) so the new env is picked up — env
vars are read at import time in some paths.
- [ ] Send a test email via `pnpm tsx scripts/dev-trigger-portal-invite.ts`
or similar. Confirm subject is prefixed with `[redirected from ...]`.
- [ ] Trigger an EOI send through the UI (any client). Confirm Documenso
shows the redirect address as recipient (not the real client email).
- [ ] If any webhooks are configured, trigger an event that fires one and
confirm the delivery is recorded as `dead_letter` with the
"EMAIL_REDIRECT_TO is set" reason.
- [ ] Run the NocoDB migration `--dry-run` to count clients/interests; the
`--apply` step is what creates real records but emails/webhooks are
still gated by the redirect env.
## Production cutover
When ready to go live:
1. Run a final dry-run of the data migration with `EMAIL_REDIRECT_TO` set
to a sandbox address.
2. Verify the snapshot looks right (counts, client coverage).
3. Unset `EMAIL_REDIRECT_TO` in the production env.
4. Restart the app + worker.
5. Run the migration with `--apply`. From this point forward, real
recipients will receive real comms.
If you ever need to re-pause outbound (e.g. handling a security incident,
re-importing on top of existing data), set `EMAIL_REDIRECT_TO` again.

View File

@@ -0,0 +1,564 @@
# Client Deduplication and NocoDB Migration Design
**Status**: Design draft 2026-05-03 — pending approval.
**Plan decomposition**: Three implementation plans stack from this design — (P1) normalization + dedup core library; (P2) admin settings + at-create + interest-level guards (runtime); (P3) NocoDB migration script + review queue UI. P1 unblocks P2 and P3.
**Branch base**: stacks on `feat/mobile-foundation` once it merges to `main`.
**Out of scope**: live merge of two clients across ports (cross-tenant), automated AI-judged matches, profile-photo / face-match dedup, web-of-trust referrer relationships.
---
## 1. Background
### 1.1 Why this exists
The legacy CRM lives in a NocoDB base whose `Interests` table conflates _the human_ with _the deal_. A row contains `Full Name`, `Email Address`, `Phone Number`, `Address`, `Place of Residence` _and_ the sales-pipeline state for one specific berth. A single human pursuing two berths becomes two rows with semi-duplicated personal data. A 2026-05-03 read-only audit confirmed:
- **252 Interests rows** in NocoDB, against an estimated ~190200 unique humans (~2025% duplication rate).
- **35 Residential Interests rows** in a parallel residential pathway with the same conflation.
- **64 Website Interest Submissions + 47 Website Contact Form Submissions + 1 EOI Supplemental Form** as inbound capture surfaces.
- **No Clients table.** The conflated structure is structural, not accidental.
The new CRM (`src/lib/db/schema/clients.ts`) splits this into `clients` (people) ↔ `interests` (deals), with `clientContacts` (multi-channel), `clientAddresses` (multi-address), and a pre-existing `clientMergeLog` table that anticipates merge with undo. The design has been ready; what's missing is (a) a normalization + matching library, (b) the at-create / at-import surfaces that use it, and (c) the migration of the existing 252+35 records.
### 1.2 Real duplicate patterns observed in the live data
Sampled 200 of the 252 NocoDB Interests rows. Confirmed duplicate clusters fall into six patterns:
| Pattern | Example rows | Signature |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| **A. Pure double-submit** | Deepak Ramchandani #624/#625; John Lynch #716/#725 | All fields identical; created same day |
| **B. Phone format variance** | Howard Wiarda #236/#536 (`574-274-0548` vs `+15742740548`); Christophe Zasso #701/#702 (`0651381036` vs `0033651381036`) | Same email, normalize-equal phone |
| **C. Name capitalization** | Nicolas Ruiz #681/#682/#683; Jean-Charles Miege/MIEGE #37/#163; John Farmer/FARMER #35/#161 | Same email or empty; surname case differs |
| **D. Name shortening** | Chris vs Christopher Allen #700/#534; Emma c vs Emma Cauchefer #661/#673 | Same email + phone; given-name truncated |
| **E. Resubmit with typo** | Christopher Camazou #649/#650 (phone last 4 digits typo); Gianfranco Di Constanzo/Costanzo #585/#336 (surname typo, **different yacht** — should be ONE client + TWO interests) | Score-on-everything-else high, one field has small-edit-distance noise |
| **F. Hard cases** | Etiennette Clamouze #188/#717 (same name, different country phone + email); Bruno Joyerot #18 with email belonging to Bruce Hearn #19 (couple sharing contact) | Cannot resolve without a human |
This dataset will be the fixture for the dedup library's tests — every pattern above must be either auto-detected or flagged for review, and the false-positive bar must be high enough that Pattern F doesn't get force-merged.
### 1.3 Dirty data inventory
The migration normalizer must survive these real values from production:
**Phone fields**: `+1-264-235-8840\r` (with carriage return), `'+1.214.603.4235` (apostrophe + dots), `0677580750/0690511494` (two numbers in one field), `00447956657022` (00 prefix), `+447000000000` (placeholder all-zeros), `+4901637039672` (impossible — stripped 0 + country prefix), various unprefixed local formats, dashed US numbers without country code.
**Email fields**: mixed case rampant (`Arthur@laser-align.com` vs `arthur@laser-align.com`); ALL-CAPS local parts; trailing whitespace.
**Name fields**: ALL-CAPS surnames mixed with title-case given names; embedded `\n` and `\r`; double spaces; lowercase-only entries; slash-with-company variants (`Daniel Wainstein / 7 Knots, LLC`, `Bruno Joyerot / SAS TIKI`); placeholder `Mr DADER`, `TBC`.
**Place of Residence (free text)**: `Saint barthelemy`, `St Barth`, `Saint-Barthélemy` (same place, three forms); `anguilla`, `United States `, `USA`, `Kansas City` (city without country), `Sag Harbor Y` (typo).
### 1.4 Existing battle-tested algorithm
`client-portal/server/utils/duplicate-detection.ts` already implements blocking + weighted-rules dedup against this same NocoDB. It runs in production today. We **port it forward** (don't reinvent), then add: soundex/metaphone for surname matching, compounded-confidence when multiple rules match, and negative evidence (same email + different country phone reduces confidence).
### 1.5 Why the website is no longer the source of new dirty data
The website forms (`website/components/pn/specific/website/{berths-item,register,form}/form.vue`) use `<v-phone-input>` with a country picker (`prefer-countries: ['US', 'GB', 'DE', 'FR']`) and `[(value) => !!value || 'Phone number is required']` validation. Output is E.164-shaped. The 252 dirty rows are legacy — pre-form-redesign submissions, sales-rep manual entries, and external CSV imports. Future inbound is clean.
---
## 2. Approach
Three artifacts, layered:
1. **A pure-logic normalization + matching library** at `src/lib/dedup/`. JSX-free, vitest-native (proven pattern: `realtime-invalidation-core.ts`). Tested against the dirty-data fixture corpus drawn from §1.2.
2. **Three runtime surfaces** that use the library: at-create suggestion in client/interest forms; interest-level same-berth guard; admin review queue powered by a nightly background scoring job.
3. **A one-shot migration script** that pulls NocoDB → normalizes → dedupes → writes new schema → produces a CSV report with auto-merge log + flagged-for-review pile.
**Configurability via admin settings** (`system_settings` per port) so the team can tune sensitivity without code changes. Defaults err on the safe side — a flagged review is cheaper than a wrongly-merged record.
**Reversibility**: every merge writes a `client_merge_log` row containing the loser's full pre-state JSON. A 7-day undo window lets a wrong merge be reversed without engineering involvement. After 7 days the snapshot is purged for GDPR; merges become permanent.
---
## 3. Normalization library
Lives at `src/lib/dedup/normalize.ts`. Pure functions, no DB, vitest-tested. Used by the dedup algorithm AND by all create-paths so what gets stored is already normalized.
### 3.1 `normalizeName(raw: string)`
```ts
export function normalizeName(raw: string): {
display: string; // human-readable, kept for UI
normalized: string; // for matching
surnameToken?: string; // for surname-based blocking
};
```
- Trim leading/trailing whitespace
- Replace `\r`, `\n`, tabs with single space
- Collapse consecutive whitespace to single space
- Smart title-case: keep particles (`van`, `de`, `del`, `O'`, `di`, `le`, `da`) lowercase except as first token
- `display` preserves user's intent (slash-with-company stays intact)
- `normalized` is `display.toLowerCase()` for comparison
- `surnameToken` is the last non-particle token for blocking
### 3.2 `normalizeEmail(raw: string)`
```ts
export function normalizeEmail(raw: string): string | null;
```
- Trim + lowercase
- Validate via `zod.email()` schema
- Returns `null` for empty / invalid (caller decides what to do)
- **Does NOT strip plus-aliases** (`user+tag@domain.com`) — both intentional (real distinct addresses) and malicious-prevention apply. Compare by full localpart.
### 3.3 `normalizePhone(raw: string, defaultCountry: string)`
```ts
export function normalizePhone(
raw: string,
defaultCountry: string,
): {
e164: string | null; // canonical, e.g. '+15742740548'
country: string | null; // ISO-3166-1 alpha-2
display: string | null; // user-facing pretty
flagged?: 'multi_number' | 'placeholder' | 'unparseable';
} | null;
```
Pipeline:
1. Strip `\r`, `\n`, tabs, single quotes, dots, dashes, parens, spaces
2. If contains `/` or `;` or `,` → flag `multi_number`, take first segment
3. If matches `+\d{2}0+$` (e.g., `+447000000000`) → flag `placeholder`, return null
4. If starts with `00` → replace with `+`
5. If starts with `+` → parse as E.164
6. Else if `defaultCountry` provided → parse against that country
7. Else return null (caller's problem)
Backed by `libphonenumber-js` (already in deps via `tests/integration/factories.ts` usage if not, will add). The hostile cases above all need explicit handling — naïve regex won't survive.
### 3.4 `resolveCountry(text: string)`
```ts
export function resolveCountry(text: string): {
iso: string | null; // ISO-3166-1 alpha-2
confidence: 'exact' | 'fuzzy' | 'city' | null;
};
```
Reuses `src/lib/i18n/countries.ts`. Pipeline:
1. Lowercase + strip diacritics
2. Exact match against country names (any locale we ship)
3. Fuzzy match (Levenshtein ≤ 2 against canonical English names)
4. City fallback — small in-package mapping for high-frequency cities seen in legacy data (`Sag Harbor → US`, `Kansas City → US`, `St Barth → BL`, etc.). Order: exact → city → fuzzy.
The mapping is opinionated and small (~30 entries covering the actual values seen in the 252-row dataset). Anything that fails to resolve returns `null` and lands in the migration's flagged pile.
---
## 4. Dedup algorithm
Lives at `src/lib/dedup/find-matches.ts`. Pure function. Vitest-tested against the §1.2 cluster fixtures.
### 4.1 Public API
```ts
export interface MatchCandidate {
id: string;
fullName: string | null;
emails: string[]; // already normalized
phonesE164: string[]; // already normalized E.164
countryIso: string | null;
}
export interface MatchResult {
candidate: MatchCandidate;
score: number; // 0100
reasons: string[]; // human-readable, e.g. ["email match", "phone match"]
confidence: 'high' | 'medium' | 'low';
}
export function findClientMatches(
input: MatchCandidate,
pool: MatchCandidate[],
thresholds: DedupThresholds,
): MatchResult[];
```
### 4.2 Scoring rules (compound)
Each rule produces a score addition. **Compounding**: when two strong rules match (e.g., email AND phone), the result is ~95+ rather than max(50, 50). Negative evidence subtracts.
| Rule | Score | Notes |
| --------------------------------------------------------------- | ----- | ------------------------------------------------------ |
| Exact email match (case-insensitive, normalized) | +60 | One match suffices |
| Exact phone E.164 match (≥ 8 significant digits) | +50 | Excludes placeholder all-zeros |
| Exact normalized full-name match | +20 | Many "John Smith"s exist |
| Surname soundex match + given-name fuzzy match (Lev ≤ 1) | +15 | Catches `Constanzo/Costanzo`, `Christophe/Christopher` |
| Same address (normalized fuzzy ≥ 0.8) | +10 | Bonus signal |
| **Negative**: Same email but different country code on phone | 15 | Suggests spouse / coworker / shared inbox |
| **Negative**: Same name but DIFFERENT email AND DIFFERENT phone | 20 | Two distinct people with the same name |
### 4.3 Confidence tiers (post-compound)
- **score ≥ 90 — `high`** — email AND phone match, or email + name + address. Block-create suggest "Use existing." Auto-link on public-form submit by default.
- **score 5089 — `medium`** — single strong signal (email or phone alone), or email + same-name + different country (Etiennette case). Soft-warn but allow.
- **score < 50 — `low`** — weak signals only. Don't surface in UI; only relevant in background-job review queue.
### 4.4 Blocking strategy
For O(n) scan over a pool of N existing clients, build three lookup maps once per scan:
- `byEmail: Map<string, MatchCandidate[]>` — keyed by normalized email
- `byPhoneE164: Map<string, MatchCandidate[]>` — keyed by E.164
- `bySurnameToken: Map<string, MatchCandidate[]>` — keyed by `normalizeName(...).surnameToken`
For an incoming `MatchCandidate`, the candidate set to compare is the union of pool entries reachable through any of its emails/phones/surname-token. Typically 05 candidates per query, regardless of N.
### 4.5 Performance budget
For migration: 252 rows compared pairwise once. ~30k comparisons after blocking — a few seconds.
For runtime at-create: incoming candidate against existing pool of N clients per port. Expected pool size at maturity: 1k10k. With blocking: <10 comparisons, <1ms target. No DB query needed beyond the initial pool fetch (which itself uses the indexed columns).
For background nightly job: full pairwise within port, blocked. 10k clients → ~50k pairwise checks per port → <30s. Fine for a nightly cron.
---
## 5. Configurable thresholds (admin settings)
New rows in `system_settings` per port. Default values err safe (more confirmation, less auto-action).
| Key | Default | Effect |
| ------------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `dedup_block_create_threshold` | `90` | Score above which the client-create form interrupts: "Use existing client?" |
| `dedup_soft_warn_threshold` | `50` | Score above which a soft-warn panel surfaces below the form |
| `dedup_review_queue_threshold` | `40` | Background job lands pairs ≥ this score in `/admin/duplicates` |
| `dedup_public_form_auto_link` | `true` | When a public-form submission scores ≥ block-threshold against existing client, attach the new interest to that client without prompting. **Safe**: no merge, just attaching a deal. |
| `dedup_auto_merge_threshold` | `null` (disabled) | If non-null, merges happen automatically at this threshold without human confirmation. Recommend leaving null until the team is comfortable; `95` is a reasonable cautious value. |
| `dedup_undo_window_days` | `7` | How long the loser's pre-state JSON is retained for merge-undo. After this, the snapshot is purged (GDPR) and merges are permanent. |
Each setting is a row in `system_settings`. UI surface in `/[portSlug]/admin/dedup` (a new admin page) with an "Advanced" toggle to expose the thresholds and brief explanations.
If the sales team complains the safer mode is too click-heavy, an admin flips `dedup_auto_merge_threshold` to `95` without any code change.
---
## 6. Merge service contract
### 6.1 Data flow
`mergeClients(winnerId, loserId, fieldChoices, ctx)` does, in a single transaction:
1. **Snapshot loser** — full row + all attached `clientContacts`, `clientAddresses`, `clientNotes`, `clientTags`, plus a count of dependent rows about to be moved (interests, yacht-memberships, etc.). Stored as `mergeDetails` JSONB in `clientMergeLog`.
2. **Reattach** — every row pointing at `loserId` updates to point at `winnerId`:
- `interests.clientId`
- `clientContacts.clientId` — with conflict handling: if winner already has the same email, keep winner's; flag the duplicate for the user
- `clientAddresses.clientId` — same conflict handling
- `clientNotes.clientId` — preserve `authorId` + `createdAt` (never overwrite)
- `clientTags.clientId`
- `clientYachtMembership.clientId` (or whatever the table is called)
- `auditLogs.entityId` — annotate, don't move (audit truth)
3. **Apply fieldChoices** — for each field where the user picked the loser's value, copy that into the winner row.
4. **Soft-archive loser**`loser.archivedAt = now()`, `loser.mergedIntoClientId = winnerId`. Row stays in DB so the merge is reversible.
5. **Write `clientMergeLog`**`{ winnerId, loserId, mergedBy, mergedAt, mergeDetails: <snapshot>, fieldChoices }`.
6. **Audit log** — top-level `auditLogs` row: `{ action: 'merge', entityType: 'client', entityId: winnerId, metadata: { loserId, score, reasons } }`.
### 6.2 Schema additions (migration)
`clients` table gets a new column:
```ts
mergedIntoClientId: text('merged_into_client_id').references(() => clients.id),
```
The existing `clientMergeLog` table is reused. Add a partial index for the undo-window query:
```sql
CREATE INDEX idx_cml_recent ON client_merge_log (port_id, created_at DESC) WHERE created_at > NOW() - INTERVAL '7 days';
```
A daily maintenance job (using the existing `maintenance-cleanup.test.ts` infrastructure) purges `mergeDetails` JSONB older than `dedup_undo_window_days` setting.
### 6.3 Undo
`unmergeClients(mergeLogId, ctx)`:
1. Within the undo window, look up the snapshot
2. Restore loser: clear `archivedAt`, `mergedIntoClientId`
3. Restore loser's contacts/addresses/notes/tags from snapshot
4. Detach reattached rows: `interests` etc. that were touching `winnerId` and originally belonged to loser go back. The snapshot stores the original `(rowType, rowId)` list explicitly so this is deterministic.
5. Mark log row `undoneAt = now()`, `undoneBy = userId`
After 7 days the snapshot is gone and unmerge returns `410 Gone`.
### 6.4 Concurrency
Both merge and unmerge wrap in a single transaction with `SELECT … FOR UPDATE` on `clients.id` of both winner and loser. A second merge attempt against the same loser sees `mergedIntoClientId` already set and refuses (clear error: "Already merged into …").
---
## 7. Runtime surfaces
### 7.1 Layer 1 — At-create suggestion
In `ClientForm` (and the public `register` form once that hits the new system):
- Debounced 300ms after email or phone field changes
- Calls `findClientMatches` against current port's clients
- Renders top-1 match if score ≥ `dedup_soft_warn_threshold`:
```
┌─────────────────────────────────────┐
│ This looks like an existing client │
│ ML Marcus Laurent │
│ marcus@… +33 6 12 34 56 78 │
│ 2 interests · last 9d ago │
│ [ Use this client ] [ Create new ] │
└─────────────────────────────────────┘
```
- "Use this client" → form switches to "create new interest under existing client" mode (preserves whatever other fields the user typed)
- "Create new" → audit-log `dedup_override` with the candidate's id and reasons (so we have data on false positives)
### 7.2 Layer 2 — Interest-level same-berth guard
Cheap one-liner in `createInterest` service:
- Check `(clientId, berthId)` against existing non-archived interests
- If hit, throw `BerthDuplicateError` with the existing interest details
- UI catches and prompts: "Update existing or create separate?"
This is NOT the same as client-level dedup. Same client legitimately can pursue the same berth a second time after it falls through. But the prompt-before-create catches the accidental double-submit case.
### 7.3 Layer 3 — Background scoring + review queue
- A nightly cron (using existing BullMQ infrastructure — search for `scheduled-tasks` in repo) runs `findClientMatches` over each port's full client pool
- Pairs scoring ≥ `dedup_review_queue_threshold` land in a `client_merge_candidates` table:
```ts
export const clientMergeCandidates = pgTable('client_merge_candidates', {
id: text('id').primaryKey()...,
portId: text('port_id').notNull()...,
clientAId: text('client_a_id').notNull()...,
clientBId: text('client_b_id').notNull()...,
score: integer('score').notNull(),
reasons: jsonb('reasons').notNull(),
status: text('status').notNull().default('pending'), // pending | dismissed | merged
createdAt: timestamp('created_at')...,
resolvedAt: timestamp('resolved_at'),
resolvedBy: text('resolved_by'),
})
```
- `/[portSlug]/admin/duplicates` lists pending candidates sorted by score desc, with `[Review →]` opening a side-by-side merge dialog
- Dismissing a candidate marks it `status=dismissed` so the job doesn't re-surface the same pair tomorrow (a future score increase re-creates it).
---
## 8. NocoDB → new system field mapping
This is the explicit mapping the migration script applies. One NocoDB Interest row produces multiple new rows.
### 8.1 Top-level transform
```
NocoDB Interests row
─→ 01 client (deduped against existing pool)
─→ 01 client_address
─→ 02 client_contacts (email, phone)
─→ exactly 1 interest
─→ 01 yacht (when Yacht Name present and not "TBC"/"Na"/empty placeholders)
─→ 01 document (when documensoID present)
```
### 8.2 Field map
| NocoDB field | Target | Transform |
| ----------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| `Full Name` | `clients.fullName` | `normalizeName().display` |
| `Email Address` | `clientContacts(channel='email', value=...)` | `normalizeEmail()` |
| `Phone Number` | `clientContacts(channel='phone', valueE164=..., valueCountry=...)` | `normalizePhone(raw, defaultCountry)` |
| `Address` | `clientAddresses.streetAddress` (LongText preserved) | trim |
| `Place of Residence` | `clientAddresses.countryIso` AND `clients.nationalityIso` | `resolveCountry()` |
| `Contact Method Preferred` | `clients.preferredContactMethod` | lowercase, mapped: Email→email, Phone→phone |
| `Source` | `clients.source` | mapped: portal→website, Form→website, External→manual; null → manual |
| `Date Added` | `interests.createdAt` (fallback to NocoDB `Created At` then now) | parse: try `DD-MM-YYYY`, then `YYYY-MM-DD`, then ISO |
| `Sales Process Level` | `interests.pipelineStage` | see §8.3 |
| `Lead Category` | `interests.leadCategory` | General→general_interest, Friends and Family→general_interest with tag |
| `Berth` (FK) | `interests.berthId` | resolve via `Berths` table by `Mooring Number` |
| `Berth Size Desired` | `interests.notes` (appended) | preserve |
| `Yacht Name`, `Length`, `Width`, `Depth` | `yachts.name`, `lengthM`, `widthM`, `draughtM` | skip if name in {`TBC`, `Na`, ``, null}; ft→m via `\* 0.3048` |
| `EOI Status` | `interests.eoiStatus` | Awaiting Further Details→pending; Waiting for Signatures→sent; Signed→signed |
| `Deposit 10% Status` | `interests.depositStatus` | Pending→pending; Received→received |
| `Contract Status` | `interests.contractStatus` | Pending→pending; 40% Received→partial; Complete→complete |
| `EOI Time Sent` | `interests.dateEoiSent` | parse |
| `clientSignTime` / `developerSignTime` / `all_signed_notified_at` | `interests.dateEoiSigned` (use latest) | parse |
| `Time LOI Sent` | `interests.dateContractSent` | parse |
| `Internal Notes` + `Extra Comments` | `clientNotes` (one row, system author) | concatenate with section markers |
| `documensoID` | `documents.documensoId` (when present, type='eoi') | preserve |
| `Signature Link Client/CC/Developer`, `EmbeddedSignature*` | `documents.signers[]` | one row per non-null signer |
| `reminder_enabled`, `last_reminder_sent`, etc. | `interests.reminderEnabled`, `interests.reminderLastFired` | parse, default true |
### 8.3 Sales-stage mapping (8 → 9)
| NocoDB | New (PIPELINE_STAGES) |
| ------------------------------- | ------------------------------------------------------------------------ |
| General Qualified Interest | `open` |
| Specific Qualified Interest | `details_sent` |
| EOI and NDA Sent | `eoi_sent` |
| Signed EOI and NDA | `eoi_signed` |
| Made Reservation | `deposit_10pct` |
| Contract Negotiation | `contract_sent` |
| Contract Negotiations Finalized | `contract_sent` (with audit-note: legacy "negotiations finalized") |
| Contract Signed | `contract_signed` (or `completed` when deposit + contract both complete) |
### 8.4 Other tables
- **Residential Interests** (35 rows) — same shape as Interests but maps to `residentialClients` + `residentialInterests`. Smaller and cleaner. Same dedup runs within this pool independently.
- **Website - Interest Submissions** (64 rows) — these are **inbound capture, not yet a client**. Treat as if each row is a fresh public-form submission today: run dedup against the migrated client pool. Auto-link if `dedup_public_form_auto_link` setting allows.
- **Website - Contact Form Submissions** (47 rows) — sparse data (just name + email + interest type). Skip migration; export as CSV for manual triage. Not the source of truth for any deal.
- **Website - Berth EOI Details Supplements** (1 row) — single record, preserved as a one-off attached to the matching Interest.
- **Newsletter Sending** (69 rows) — out of scope; that's a marketing surface, not CRM.
- **Interests Backup, Interests copy** — historical artifacts. Skipped by default. A `--include-backups` flag attaches them as audit-note entries on the corresponding live Interest if the user wants the history.
---
## 9. Migration script
Located at `scripts/migrate-from-nocodb.ts`. Idempotent: safe to re-run. Three main flags:
```
$ pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug X]
Pulls everything, transforms, runs dedup, writes CSV report to .migration/<timestamp>/. No DB writes.
$ pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<timestamp>/
Reads the report, performs the writes the dry-run promised. Refuses if the source data has changed since the report was generated (hash mismatch).
$ pnpm tsx scripts/migrate-from-nocodb.ts --rollback --apply-id <id>
Reads the apply log, undoes the writes (only valid within the undo window).
```
Reuses the `client-portal/server/utils/nocodb.ts` adapter for the NocoDB API client (no need to rebuild). Writes to the new system via Drizzle (re-using the existing services like `createClient`, `createInterest`, etc., so all the same validation runs).
### 9.1 Dry-run report format
`.migration/<timestamp>/report.csv`:
```csv
op,reason,nocodb_row_id,target_table,target_value,confidence,manual_review_required
create_client,new,624,clients.fullName,Deepak Ramchandani,N/A,false
create_contact,new,624,clientContacts.email,dannyrams8888@gmail.com,N/A,false
create_contact,new,624,clientContacts.phone,+17215868888,N/A,false
create_interest,new,624,interests.berthId,a1b2c3...,N/A,false
auto_link,score=98 (email+phone),625,clients.id,<existing client UUID from row 624>,high,false
flag_for_review,score=72 (same name diff country),188,client.id,<existing client UUID from row 717>,medium,true
country_unresolved,fallback to AI (port country),198,clientAddresses.countryIso,AI,low,true
phone_unparseable,placeholder all-zeros,641,clientContacts.phone,<skipped>,N/A,true
```
Plus `.migration/<timestamp>/summary.md`:
```
# Migration Dry-Run — 2026-05-03 14:23 UTC
NocoDB: 252 Interests + 35 Residences + 64 Website Submissions
Outcome: 198 clients, 287 interests (incl. residences), 91 yachts, 412 contacts
Auto-linked (high confidence, no human action needed):
- Nicolas Ruiz: rows 681,682,683 → 1 client + 3 interests
- John Lynch: rows 716,725 → 1 client + 2 interests
- Deepak Ramchandani: rows 624,625 → 1 client + 2 interests
- [12 more]
Flagged for manual review (medium confidence):
- Etiennette Clamouze (rows 188,717): same name, different country phone + email
- Bruno Joyerot #18 + Bruce Hearn #19: shared household contact
- [4 more]
Country resolution failed for 7 rows. All defaulted to port country (AI). Review:
- Row 239: "Sag Harbor Y" → AI (likely US)
- [6 more]
Phone parsing failed for 3 rows. All flagged, no contact created:
- Row 178: empty
- Row 641: placeholder "+447000000000"
- Row 175: empty
Run `--apply` to commit these changes.
```
### 9.2 Apply phase
`--apply` reads the report, re-fetches the source rows (via NocoDB MCP / API), recomputes the hash, fails fast if NocoDB changed since dry-run. Then performs the writes within a single PostgreSQL transaction per port (commit at end). On any error mid-transaction, full rollback.
After successful apply, an `apply_id` is generated and an audit-log row written. The `apply_id` is the handle used for `--rollback`.
### 9.3 Idempotency
The script tracks NocoDB row IDs in a `migration_source_links` table:
```ts
export const migrationSourceLinks = pgTable('migration_source_links', {
id: text('id').primaryKey()...,
sourceSystem: text('source_system').notNull(), // 'nocodb_interests' | 'nocodb_residences' | …
sourceId: text('source_id').notNull(), // NocoDB row id as string
targetEntityType: text('target_entity_type').notNull(), // client | interest | yacht | …
targetEntityId: text('target_entity_id').notNull(),
appliedAt: timestamp('applied_at')...,
appliedBy: text('applied_by'),
}, (table) => [
uniqueIndex('idx_msl_source').on(table.sourceSystem, table.sourceId, table.targetEntityType),
]);
```
Re-running `--apply` against the same report skips rows already in this table. Useful for partial-failure resumption.
---
## 10. Test plan
### 10.1 Library-level (vitest unit)
- `tests/unit/dedup/normalize.test.ts` — every dirty-data pattern from §1.3 has a fixture asserting the expected normalized output.
- `tests/unit/dedup/find-matches.test.ts` — every duplicate cluster from §1.2 has a fixture asserting score + confidence tier. Hard cases (Pattern F) assert "medium" not "high" — false-positive guard.
### 10.2 Service-level (vitest integration)
- `tests/integration/dedup/client-merge.test.ts` — merge service exercised: full reattach, clientMergeLog written, undo within window restores, undo after window returns 410, concurrent merge of same loser fails the second.
- `tests/integration/dedup/at-create-suggestion.test.ts` — `findClientMatches` against a seeded pool returns expected matches + reasons.
### 10.3 Migration script (vitest integration with NocoDB mock)
- `tests/integration/dedup/migration-dry-run.test.ts` — feed the script a fixture NocoDB dump (the 252 rows, frozen as a JSON snapshot in fixtures), assert the resulting CSV matches a golden file. Catch any future regression in the transform pipeline.
- `tests/integration/dedup/migration-apply.test.ts` — apply the dry-run output to a clean test DB, assert all expected rows exist, assert idempotency (re-apply is a no-op).
### 10.4 E2E (Playwright)
- `tests/e2e/smoke/30-dedup-create.spec.ts` — type into ClientForm with an email matching seeded client; assert suggestion card appears; click "Use this client"; assert form switches to interest-create mode.
- `tests/e2e/smoke/31-admin-duplicates.spec.ts` — admin views review queue, opens a candidate, side-by-side merge UI works, merge succeeds, undo within window works.
---
## 11. Rollback plan
Three layers of safety, ordered by reversibility:
1. **Per-merge undo** — admin clicks Undo on a wrongly-merged pair, system rolls back from `clientMergeLog` snapshot. 7-day window. No engineering needed.
2. **Migration `--rollback` flag** — entire migration apply is reversed via the `apply_id` and `migration_source_links` table. Useful in the first 24h after `--apply`. Engineering-supervised.
3. **DB restore from backup** — the existing `docs/ops/backup-runbook.md` covers this. Last resort if both above are blocked.
Pre-migration, take a hot backup of the new DB (`pg_dump`). Pre-merge in production (before any human-facing surface ships), the `dedup_auto_merge_threshold` defaults to `null` so no automatic merges happen — every merge is human-confirmed.
---
## 12. Open items
- **Soundex vs metaphone** — Soundex is simpler but English-leaning. Metaphone handles non-English surnames better (the dataset has French, German, Italian, Slavic names). Default to metaphone via the `natural` package; revisit if it adds significant install size.
- **Cross-port dedup** — not in scope. Each port's clients are deduped within that port. A future "shared address book" feature would need its own design.
- **Profile photo / face match** — out of scope.
- **AI-assisted match resolution** — out of scope. The Layer-3 review queue is human-only.
---
## Implementation sequence
P1 (this design's library) → P2 (runtime surfaces) → P3 (migration). Each is a separate plan / PR.
**P1 deliverables**: `src/lib/dedup/{normalize,find-matches}.ts` + tests. No UI changes. No DB changes (except indexed lookups added to existing `clientContacts`). ~1.5 days.
**P2 deliverables**: at-create suggestion in `ClientForm` + interest-level guard in `createInterest` service + admin settings UI for thresholds + `clientMergeCandidates` table + nightly job + admin review queue page + merge service + side-by-side merge UI. ~57 days.
**P3 deliverables**: `scripts/migrate-from-nocodb.ts` + `migration_source_links` table + dry-run + apply + rollback. CSV report format frozen against fixture. ~3 days, including fixture creation from the live NocoDB snapshot.
Total: ~1012 engineering days from approval. Can be split across three PRs landing independently — each is testable in isolation and the runtime surfaces (P2) work even without P3 being run.

View File

@@ -0,0 +1,144 @@
/**
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
* contacts where it's null or empty.
*
* The legacy seed (and pre-normalization production data) stored phone
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
* is what every UI surface and dedup matcher reads. This script runs the
* raw `value` through libphonenumber-js (via the script-safe wrapper to
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
* form back.
*
* Usage:
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
*
* The dry-run report prints, for each unparseable row, the contact id +
* raw value so you can hand-clean before re-running.
*/
import 'dotenv/config';
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clientContacts } from '@/lib/db/schema/clients';
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
import type { CountryCode } from '@/lib/i18n/countries';
const APPLY = process.argv.includes('--apply');
interface PhoneRow {
id: string;
channel: string;
value: string | null;
valueCountry: string | null;
}
async function main() {
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
console.log('');
// Find candidate rows: phone or whatsapp contacts with a `value` set but
// `value_e164` null/empty.
const rows: PhoneRow[] = await db
.select({
id: clientContacts.id,
channel: clientContacts.channel,
value: clientContacts.value,
valueCountry: clientContacts.valueCountry,
})
.from(clientContacts)
.where(
and(
inArray(clientContacts.channel, ['phone', 'whatsapp']),
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
),
);
console.log(` found ${rows.length} candidate rows`);
let parsedFull = 0;
let parsedE164Only = 0;
let unparseable = 0;
const updates: Array<{
id: string;
valueE164: string;
valueCountry: CountryCode | null;
}> = [];
const fails: Array<{ id: string; value: string; reason: string }> = [];
for (const row of rows) {
if (!row.value) continue;
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
if (parsed1.e164 && parsed1.country) {
// Both e164 + country resolved — best case.
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
parsedFull++;
} else if (parsed1.e164) {
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
// fictional/reserved range — libphonenumber returns the e164 form
// but refuses to assign a country). Still safe to write — the e164
// is canonical. Country stays null.
updates.push({
id: row.id,
valueE164: parsed1.e164,
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
});
parsedE164Only++;
} else {
fails.push({
id: row.id,
value: row.value,
reason: row.value.trim().startsWith('+')
? 'has + prefix but parse failed'
: 'no leading + and no country hint',
});
unparseable++;
}
}
console.log('');
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
console.log(' ✗ unparseable ', unparseable);
console.log('');
if (fails.length > 0) {
console.log('Failures (first 10):');
for (const f of fails.slice(0, 10)) {
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
}
console.log('');
}
if (!APPLY) {
console.log('Dry-run only. Re-run with --apply to write the updates.');
return;
}
if (updates.length === 0) {
console.log('No updates to write.');
return;
}
console.log(`Writing ${updates.length} updates...`);
for (const u of updates) {
await db
.update(clientContacts)
.set({
valueE164: u.valueE164,
valueCountry: u.valueCountry,
})
.where(eq(clientContacts.id, u.id));
}
console.log(` ✓ wrote ${updates.length} rows`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
/**
* One-shot: load the 117-berth NocoDB snapshot into the port-nimara
* port, skipping any moorings that already exist.
*
* The original seed only seeded 12 hand-rolled berths into port-nimara
* (A-01..D-03), but the migration's interest rows reference moorings
* across A-01..E-18. This loads the full set so interest→berth links
* resolve cleanly on the next migration run.
*/
import 'dotenv/config';
import { eq, and, sql, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { berths } from '@/lib/db/schema/berths';
import berthSnapshot from '@/lib/db/seed-data/berths.json';
interface SnapshotBerth {
mooringNumber: string;
area: string;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
lengthM: number | null;
widthM: number | null;
draftM: number | null;
widthIsMinimum: boolean;
nominalBoatSize: number | null;
nominalBoatSizeM: number | null;
waterDepth: number | null;
waterDepthM: number | null;
waterDepthIsMinimum: boolean;
sidePontoon: string | null;
powerCapacity: number | null;
voltage: number | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: number | null;
bowFacing: string | null;
berthApproved: boolean;
statusOverrideMode: string | null;
}
async function main() {
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, 'port-nimara'))
.limit(1);
if (!port) throw new Error('port-nimara not found');
const snapshot = berthSnapshot as unknown as SnapshotBerth[];
// Existing moorings — skip these.
const existingRows = await db
.select({ mooringNumber: berths.mooringNumber })
.from(berths)
.where(eq(berths.portId, port.id));
const existingMoorings = new Set(existingRows.map((r) => r.mooringNumber));
const toInsert = snapshot.filter((b) => !existingMoorings.has(b.mooringNumber));
console.log(
`Snapshot: ${snapshot.length} berths, existing in port-nimara: ${existingRows.length}, to insert: ${toInsert.length}`,
);
if (toInsert.length === 0) {
console.log('Nothing to do.');
return;
}
const inserted = await db
.insert(berths)
.values(
toInsert.map((b) => ({
portId: port.id,
mooringNumber: b.mooringNumber,
area: b.area,
status: b.status,
lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
widthFt: b.widthFt != null ? String(b.widthFt) : null,
draftFt: b.draftFt != null ? String(b.draftFt) : null,
lengthM: b.lengthM != null ? String(b.lengthM) : null,
widthM: b.widthM != null ? String(b.widthM) : null,
draftM: b.draftM != null ? String(b.draftM) : null,
widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
voltage: b.voltage != null ? String(b.voltage) : null,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.price != null ? String(b.price) : null,
priceCurrency: 'USD',
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
tenureType: 'permanent' as const,
})),
)
.returning({ id: berths.id, mooringNumber: berths.mooringNumber });
console.log(`Inserted ${inserted.length} berths.`);
// Suppress unused-import warning if eslint is strict.
void and;
void sql;
void inArray;
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -7,21 +7,30 @@
* Pulls the live NocoDB base, runs the transform + dedup pipeline, * Pulls the live NocoDB base, runs the transform + dedup pipeline,
* writes a report to .migration/<timestamp>/. NO database writes. * writes a report to .migration/<timestamp>/. NO database writes.
* *
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug harbor-royale * pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
* Same, but tags the planned writes with the named port (matters for * Same, but tags the planned writes with the named port (matters for
* the apply phase — every client/interest belongs to one port). * the apply phase — every client/interest belongs to one port).
* *
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/ * pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
* [Not yet implemented — apply phase comes in a follow-up PR.] * Re-fetches NocoDB, re-transforms, then writes the planned rows
* into the target port via the idempotent `migration_source_links`
* ledger. Re-runs are safe — already-imported source IDs are skipped.
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
* `--unsafe-skip-redirect-check` is also passed.
* *
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9. * Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
*/ */
import 'dotenv/config'; import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { applyPlan } from '@/lib/dedup/migration-apply';
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source'; import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
import { transformSnapshot } from '@/lib/dedup/migration-transform'; import { transformSnapshot } from '@/lib/dedup/migration-transform';
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report'; import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
@@ -31,6 +40,7 @@ interface CliArgs {
apply: boolean; apply: boolean;
portSlug: string | null; portSlug: string | null;
reportDir: string | null; reportDir: string | null;
unsafeSkipRedirectCheck: boolean;
} }
function parseArgs(argv: string[]): CliArgs { function parseArgs(argv: string[]): CliArgs {
@@ -39,6 +49,7 @@ function parseArgs(argv: string[]): CliArgs {
apply: false, apply: false,
portSlug: null, portSlug: null,
reportDir: null, reportDir: null,
unsafeSkipRedirectCheck: false,
}; };
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!; const a = argv[i]!;
@@ -46,6 +57,7 @@ function parseArgs(argv: string[]): CliArgs {
else if (a === '--apply') args.apply = true; else if (a === '--apply') args.apply = true;
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null; else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--report') args.reportDir = argv[++i] ?? null; else if (a === '--report') args.reportDir = argv[++i] ?? null;
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
else if (a === '-h' || a === '--help') { else if (a === '-h' || a === '--help') {
printHelp(); printHelp();
process.exit(0); process.exit(0);
@@ -64,20 +76,50 @@ function printHelp(): void {
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/. Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
No database writes. No database writes.
pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/ pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
Apply phase. (Not yet implemented.) Re-fetches NocoDB, re-transforms, writes via migration_source_links
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
(unless --unsafe-skip-redirect-check is also passed).
Flags: Flags:
--dry-run Read NocoDB, write report only. --dry-run Read NocoDB, write report only.
--apply Actually write to the new DB. (Not yet supported.) --apply Actually write rows to the DB.
--port-slug <slug> Port slug to attach to all imported entities. --port-slug <slug> Port slug to attach to all imported
Defaults to the first available port if omitted. entities. Defaults to the first
--report <dir> Path to a previously-generated report dir available port if omitted.
(only used by --apply). --report <dir> Path to a previously-generated report
dir (only used by --apply).
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
check. Only use in production cutover.
-h, --help Show this help. -h, --help Show this help.
`); `);
} }
/**
* Resolve the target port: use the slug if provided, otherwise the first
* port found. Errors out cleanly if the slug doesn't match any port.
*/
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
if (slug) {
const [p] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slug))
.limit(1);
if (!p) {
console.error(`No port found with slug "${slug}".`);
process.exit(1);
}
return { id: p.id, slug: p.slug };
}
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
if (!first) {
console.error('No ports exist in the target DB. Seed at least one port before applying.');
process.exit(1);
}
return { id: first.id, slug: first.slug };
}
async function main(): Promise<void> { async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
@@ -87,13 +129,21 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
if (args.apply) { // Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
console.error('--apply is not yet implemented in this version. P3 ships dry-run first.'); // operator explicitly opts out (production cutover).
console.error('See docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.2.'); if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
console.error(
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
);
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
console.error(
'If you are running the production cutover and have read that doc, add ' +
'--unsafe-skip-redirect-check to override.',
);
process.exit(2); process.exit(2);
} }
// ── Dry-run path ─────────────────────────────────────────────────────────── // ── Fetch + transform (shared by dry-run and apply) ──────────────────────
console.log('[migrate] Loading NocoDB config…'); console.log('[migrate] Loading NocoDB config…');
const config = loadNocoDbConfig(); const config = loadNocoDbConfig();
@@ -110,8 +160,7 @@ async function main(): Promise<void> {
console.log('[migrate] Running transform + dedup pipeline…'); console.log('[migrate] Running transform + dedup pipeline…');
const plan = transformSnapshot(snapshot); const plan = transformSnapshot(snapshot);
// Resolve output paths relative to the worktree root (the script itself // Resolve output paths relative to the worktree root.
// lives in scripts/; we want the .migration dir at the repo root).
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
const generatedAt = new Date().toISOString(); const generatedAt = new Date().toISOString();
@@ -120,7 +169,7 @@ async function main(): Promise<void> {
console.log(`[migrate] Writing report to ${paths.rootDir}`); console.log(`[migrate] Writing report to ${paths.rootDir}`);
await writeReport(paths, plan, generatedAt); await writeReport(paths, plan, generatedAt);
// ── Console summary ────────────────────────────────────────────────────── // ── Plan summary ─────────────────────────────────────────────────────────
const s = plan.stats; const s = plan.stats;
console.log(''); console.log('');
console.log('=== Migration Plan Summary ==='); console.log('=== Migration Plan Summary ===');
@@ -135,6 +184,50 @@ async function main(): Promise<void> {
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`); console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
console.log(''); console.log('');
console.log(` Full report: ${paths.summaryPath}`); console.log(` Full report: ${paths.summaryPath}`);
if (args.dryRun) {
console.log('');
console.log('Dry-run complete. Re-run with --apply to write rows.');
return;
}
// ── Apply path ───────────────────────────────────────────────────────────
const port = await resolvePort(args.portSlug);
const applyId = randomUUID();
console.log('');
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
console.log(`[migrate] Apply id: ${applyId}`);
console.log('[migrate] Inserting…');
const applyStart = Date.now();
const result = await applyPlan(plan, { port, applyId });
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
console.log('');
console.log('=== Apply Result ===');
console.log(` Time: ${applyElapsed}s`);
console.log(
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
);
console.log(` Contacts: ${result.contactsInserted} inserted`);
console.log(` Addresses: ${result.addressesInserted} inserted`);
console.log(` Yachts: ${result.yachtsInserted} inserted`);
console.log(
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
);
if (result.warnings.length > 0) {
console.log('');
console.log('Warnings:');
for (const w of result.warnings.slice(0, 20)) {
console.log(` - ${w}`);
}
if (result.warnings.length > 20) {
console.log(`${result.warnings.length - 20} more`);
}
}
console.log(''); console.log('');
} }

View File

@@ -0,0 +1,108 @@
/**
* Live smoke test for EMAIL_REDIRECT_TO.
*
* Actually calls `sendEmail()` (the centralized helper used by every
* outbound email path in the app) with a fake real-client address. The
* SMTP transporter is monkey-patched to capture the message instead of
* actually delivering it, so this is safe to run anywhere.
*
* Prints the captured `to` + `subject` so the operator can see with their
* own eyes that the redirect happened. Exits non-zero if the redirect
* failed for any reason.
*
* Usage:
* pnpm tsx scripts/smoke-test-redirect.ts
*/
import 'dotenv/config';
async function main() {
const expectedRedirect = process.env.EMAIL_REDIRECT_TO;
if (!expectedRedirect) {
console.error('FAIL: EMAIL_REDIRECT_TO is not set in env. Set it before running this test.');
process.exit(1);
}
console.log(`[smoke] EMAIL_REDIRECT_TO = ${expectedRedirect}`);
console.log('');
// Monkey-patch nodemailer's createTransport so we capture the call
// without actually delivering. This is the same pattern the unit
// tests use, but at the live import-time level so we're testing the
// exact code path that runs in production.
const nodemailer = await import('nodemailer');
const captured: Array<{ to: unknown; subject: unknown; from: unknown }> = [];
const originalCreateTransport = nodemailer.default.createTransport;
// @ts-expect-error monkey-patch
nodemailer.default.createTransport = () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendMail: async (msg: any) => {
captured.push({ to: msg.to, subject: msg.subject, from: msg.from });
return { messageId: '<smoke@test>', accepted: [msg.to], rejected: [] };
},
});
// Now import sendEmail (gets the patched transporter).
const { sendEmail } = await import('@/lib/email');
const realClientEmail = 'real-client-DO-NOT-EMAIL@example.test';
const realSubject = 'Important: Your contract is ready';
console.log('[smoke] calling sendEmail(...) with:');
console.log(` to: ${realClientEmail}`);
console.log(` subject: "${realSubject}"`);
console.log('');
await sendEmail(realClientEmail, realSubject, '<p>Body unused for this smoke.</p>');
// Restore the original transport (be a good citizen).
// @ts-expect-error monkey-patch
nodemailer.default.createTransport = originalCreateTransport;
console.log('[smoke] captured outbound message:');
console.log(` to: ${captured[0]?.to}`);
console.log(` subject: "${captured[0]?.subject}"`);
console.log(` from: ${captured[0]?.from}`);
console.log('');
// Assertions
let pass = true;
if (captured.length !== 1) {
console.error(`FAIL: expected exactly 1 sendMail call, got ${captured.length}`);
pass = false;
}
if (captured[0]?.to !== expectedRedirect) {
console.error(
`FAIL: outbound "to" was "${captured[0]?.to}", expected the redirect address "${expectedRedirect}"`,
);
pass = false;
}
if (
typeof captured[0]?.subject !== 'string' ||
!captured[0].subject.startsWith(`[redirected from ${realClientEmail}]`)
) {
console.error(
`FAIL: subject did not get the [redirected from <orig>] prefix. Got: "${captured[0]?.subject}"`,
);
pass = false;
}
if (pass) {
console.log('PASS: EMAIL_REDIRECT_TO is intercepting outbound email correctly.');
console.log(
' The "to" header matches the redirect, and the original recipient is preserved in the subject.',
);
process.exit(0);
} else {
console.error('');
console.error('Smoke test FAILED. Do not import production data until this is fixed.');
process.exit(1);
}
}
main().catch((err) => {
console.error('FATAL:', err);
process.exit(1);
});

View File

@@ -16,6 +16,7 @@ import {
Tag, Tag,
Upload, Upload,
Users, Users,
UsersRound,
Webhook, Webhook,
} from 'lucide-react'; } from 'lucide-react';
@@ -29,7 +30,17 @@ interface AdminSection {
icon: typeof Settings; icon: typeof Settings;
} }
const SECTIONS: AdminSection[] = [ interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [
{
title: 'Access',
description: 'Who can sign in and what they can do once they do.',
sections: [
{ {
href: 'users', href: 'users',
label: 'Users', label: 'Users',
@@ -48,12 +59,12 @@ const SECTIONS: AdminSection[] = [
description: 'Default permission sets and per-port role overrides.', description: 'Default permission sets and per-port role overrides.',
icon: Shield, icon: Shield,
}, },
{ ],
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
}, },
{
title: 'Configuration',
description: 'Branding, integrations, and per-port settings.',
sections: [
{ {
href: 'email', href: 'email',
label: 'Email Settings', label: 'Email Settings',
@@ -90,6 +101,12 @@ const SECTIONS: AdminSection[] = [
description: 'Outgoing webhook subscriptions, secrets, and delivery log.', description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook, icon: Webhook,
}, },
],
},
{
title: 'Content',
description: 'Forms, templates, and labels that users see.',
sections: [
{ {
href: 'forms', href: 'forms',
label: 'Forms', label: 'Forms',
@@ -114,6 +131,36 @@ const SECTIONS: AdminSection[] = [
description: 'Tenant-defined fields for clients, yachts, and reservations.', description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key, icon: Key,
}, },
],
},
{
title: 'Data Quality',
description: 'Cleanup, imports, and the audit trail.',
sections: [
{
href: 'duplicates',
label: 'Duplicates',
description: 'Review queue of suspected duplicate clients flagged by the dedup engine.',
icon: UsersRound,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'audit',
label: 'Audit Log',
description: 'Searchable log of every authenticated mutation in the system.',
icon: ScrollText,
},
],
},
{
title: 'Operations',
description: 'Health checks and disaster recovery.',
sections: [
{ {
href: 'reports', href: 'reports',
label: 'Reports', label: 'Reports',
@@ -126,18 +173,18 @@ const SECTIONS: AdminSection[] = [
description: 'BullMQ queue health, throughput, and retry diagnostics.', description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database, icon: Database,
}, },
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{ {
href: 'backup', href: 'backup',
label: 'Backup & Restore', label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.', description: 'Database snapshots and on-demand exports.',
icon: HardDrive, icon: HardDrive,
}, },
],
},
{
title: 'Tenancy',
description: 'Multi-port and multi-install scaffolding.',
sections: [
{ {
href: 'ports', href: 'ports',
label: 'Ports', label: 'Ports',
@@ -150,12 +197,20 @@ const SECTIONS: AdminSection[] = [
description: 'Initial-setup wizard for fresh ports.', description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard, icon: LayoutDashboard,
}, },
],
},
{
title: 'Integrations',
description: 'Third-party providers wired into the app.',
sections: [
{ {
href: 'ocr', href: 'ocr',
label: 'Receipt OCR', label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.', description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText, icon: ScrollText,
}, },
],
},
]; ];
export default async function AdminLandingPage({ export default async function AdminLandingPage({
@@ -165,13 +220,21 @@ export default async function AdminLandingPage({
}) { }) {
const { portSlug } = await params; const { portSlug } = await params;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<PageHeader <PageHeader
title="Administration" title="Administration"
description="Per-port configuration and system administration. Each card below opens a dedicated settings page." description="Per-port configuration and system administration. Each card below opens a dedicated settings page."
/> />
{GROUPS.map((group) => (
<section key={group.title} className="space-y-3">
<div>
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</h2>
<p className="text-xs text-muted-foreground/80">{group.description}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{SECTIONS.map((s) => { {group.sections.map((s) => {
const Icon = s.icon; const Icon = s.icon;
return ( return (
<Link <Link
@@ -195,6 +258,8 @@ export default async function AdminLandingPage({
); );
})} })}
</div> </div>
</section>
))}
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { headers } from 'next/headers';
import { Inter, JetBrains_Mono } from 'next/font/google'; import { Inter, JetBrains_Mono } from 'next/font/google';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { classifyFormFactor } from '@/lib/form-factor'; import { classifyFormFactor } from '@/lib/form-factor';
import { ReactGrabViewportSync } from '@/components/dev/react-grab-viewport-sync';
import './globals.css'; import './globals.css';
const inter = Inter({ const inter = Inter({
@@ -66,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
> >
{children} {children}
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
{process.env.NODE_ENV === 'development' && <ReactGrabViewportSync />}
</body> </body>
</html> </html>
); );

View File

@@ -241,7 +241,14 @@ export function SettingsManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => ( {KNOWN_SETTINGS.filter((s) => s.type === 'string').map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-4"> <div
key={setting.key}
// Stack label/description above the input on phone widths.
// The previous flex row crushed the label column into a
// narrow vertical stripe ("Inquiry / Contact / Email" wrapping
// one word per line) while the input took the rest.
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="flex-1"> <div className="flex-1">
<Label>{setting.label}</Label> <Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p> <p className="text-xs text-muted-foreground">{setting.description}</p>
@@ -249,7 +256,7 @@ export function SettingsManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="text" type="text"
className="w-64" className="w-full sm:w-64"
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')} value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
onChange={(e) => onChange={(e) =>
setValues((prev) => ({ setValues((prev) => ({
@@ -283,7 +290,10 @@ export function SettingsManager() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => ( {KNOWN_SETTINGS.filter((s) => s.type === 'number').map((setting) => (
<div key={setting.key} className="flex items-center justify-between gap-4"> <div
key={setting.key}
className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="flex-1"> <div className="flex-1">
<Label>{setting.label}</Label> <Label>{setting.label}</Label>
<p className="text-xs text-muted-foreground">{setting.description}</p> <p className="text-xs text-muted-foreground">{setting.description}</p>
@@ -291,7 +301,7 @@ export function SettingsManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
type="number" type="number"
className="w-24" className="w-full sm:w-24"
value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')} value={String(getEffectiveValue(setting.key, setting.defaultValue) ?? '')}
onChange={(e) => onChange={(e) =>
setValues((prev) => ({ setValues((prev) => ({

View File

@@ -22,7 +22,11 @@ export function AlertRail() {
<section <section
data-testid="alert-rail" data-testid="alert-rail"
aria-label="Active alerts" aria-label="Active alerts"
className="flex h-full flex-col gap-3" // `h-full` is intentional only at xl: where the parent dashboard grid
// gives this rail a sibling column whose height it should match. On
// mobile (single-column stack) there's no fixed-height context, so
// forcing 100% height makes the section overflow / look stretched.
className="flex flex-col gap-3 xl:h-full"
> >
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold tracking-tight">Alerts</h2> <h2 className="text-sm font-semibold tracking-tight">Alerts</h2>

View File

@@ -44,6 +44,17 @@ type BerthDetailData = {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | null; widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
bowFacing: string | null;
price: string | null; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;
@@ -167,7 +178,10 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-4"> {/* Stacks vertically on phone widths so the action buttons don't
squeeze the area subtitle into a two-line wrap. From sm up the
title/area block sits side-by-side with the action buttons. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground"> <h1 className="hidden sm:block text-2xl font-bold text-foreground">
@@ -182,7 +196,7 @@ export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
{berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>} {berth.area && <p className="text-muted-foreground mt-1">{berth.area}</p>}
</div> </div>
<div className="flex flex-wrap items-center gap-2 shrink-0"> <div className="flex flex-wrap items-center gap-2 sm:shrink-0">
<PermissionGate resource="berths" action="edit"> <PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}> <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" /> <RefreshCw className="mr-1.5 h-4 w-4" />

View File

@@ -16,18 +16,22 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { TagPicker } from '@/components/shared/tag-picker'; import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths'; import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
import {
BERTH_AREAS,
BERTH_SIDE_PONTOON_OPTIONS,
BERTH_MOORING_TYPES,
BERTH_CLEAT_TYPES,
BERTH_CLEAT_CAPACITIES,
BERTH_BOLLARD_TYPES,
BERTH_BOLLARD_CAPACITIES,
BERTH_ACCESS_OPTIONS,
} from '@/lib/constants';
interface BerthFormProps { interface BerthFormProps {
berth: { berth: {
@@ -42,16 +46,27 @@ interface BerthFormProps {
draftFt: string | null; draftFt: string | null;
draftM: string | null; draftM: string | null;
widthIsMinimum: boolean | null; widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
bowFacing: string | null;
price: string | null; price: string | null;
priceCurrency: string; priceCurrency: string;
tenureType: string; tenureType: string;
tenureYears: number | null; tenureYears: number | null;
tenureStartDate: string | null; tenureStartDate: string | null;
tenureEndDate: string | null; tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null; berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>; tags: Array<{ id: string; name: string; color: string }>;
}; };
@@ -59,10 +74,42 @@ interface BerthFormProps {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
} }
/** Optional select that allows clearing back to "no value". */
function SelectOrEmpty({
value,
onChange,
options,
placeholder = 'Select…',
}: {
value: string | undefined;
onChange: (next: string | undefined) => void;
options: readonly string[];
placeholder?: string;
}) {
const NONE = '__none';
return (
<Select value={value ?? NONE} onValueChange={(v) => onChange(v === NONE ? undefined : v)}>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}></SelectItem>
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) { export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id)); const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
const numOrUndef = (v: string | null) => (v != null && v !== '' ? Number(v) : undefined);
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -73,23 +120,34 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
resolver: zodResolver(updateBerthSchema), resolver: zodResolver(updateBerthSchema),
defaultValues: { defaultValues: {
area: berth.area ?? undefined, area: berth.area ?? undefined,
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined, lengthFt: numOrUndef(berth.lengthFt),
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined, lengthM: numOrUndef(berth.lengthM),
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined, widthFt: numOrUndef(berth.widthFt),
widthM: berth.widthM ? Number(berth.widthM) : undefined, widthM: numOrUndef(berth.widthM),
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined, draftFt: numOrUndef(berth.draftFt),
draftM: berth.draftM ? Number(berth.draftM) : undefined, draftM: numOrUndef(berth.draftM),
widthIsMinimum: berth.widthIsMinimum ?? false, widthIsMinimum: berth.widthIsMinimum ?? false,
price: berth.price ? Number(berth.price) : undefined, nominalBoatSize: numOrUndef(berth.nominalBoatSize),
nominalBoatSizeM: numOrUndef(berth.nominalBoatSizeM),
waterDepth: numOrUndef(berth.waterDepth),
waterDepthM: numOrUndef(berth.waterDepthM),
waterDepthIsMinimum: berth.waterDepthIsMinimum ?? false,
sidePontoon: berth.sidePontoon ?? undefined,
powerCapacity: numOrUndef(berth.powerCapacity),
voltage: numOrUndef(berth.voltage),
mooringType: berth.mooringType ?? undefined,
cleatType: berth.cleatType ?? undefined,
cleatCapacity: berth.cleatCapacity ?? undefined,
bollardType: berth.bollardType ?? undefined,
bollardCapacity: berth.bollardCapacity ?? undefined,
access: berth.access ?? undefined,
bowFacing: berth.bowFacing ?? undefined,
price: numOrUndef(berth.price),
priceCurrency: berth.priceCurrency, priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType as 'permanent' | 'fixed_term', tenureType: berth.tenureType as 'permanent' | 'fixed_term',
tenureYears: berth.tenureYears ?? undefined, tenureYears: berth.tenureYears ?? undefined,
tenureStartDate: berth.tenureStartDate ?? undefined, tenureStartDate: berth.tenureStartDate ?? undefined,
tenureEndDate: berth.tenureEndDate ?? undefined, tenureEndDate: berth.tenureEndDate ?? undefined,
powerCapacity: berth.powerCapacity ?? undefined,
voltage: berth.voltage ?? undefined,
mooringType: berth.mooringType ?? undefined,
access: berth.access ?? undefined,
berthApproved: berth.berthApproved ?? false, berthApproved: berth.berthApproved ?? false,
}, },
}); });
@@ -120,6 +178,14 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
} }
const tenureType = watch('tenureType'); const tenureType = watch('tenureType');
const area = watch('area');
const sidePontoon = watch('sidePontoon');
const mooringType = watch('mooringType');
const cleatType = watch('cleatType');
const cleatCapacity = watch('cleatCapacity');
const bollardType = watch('bollardType');
const bollardCapacity = watch('bollardCapacity');
const access = watch('access');
return ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
@@ -136,18 +202,18 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="area">Area</Label> <Label>Area</Label>
<Input id="area" {...register('area')} placeholder="e.g. Marina A" /> <SelectOrEmpty
value={area}
onChange={(v) => setValue('area', v)}
options={BERTH_AREAS}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="mooringType">Mooring Type</Label> <Label htmlFor="bowFacing">Bow Facing</Label>
<Input id="mooringType" {...register('mooringType')} /> <Input id="bowFacing" {...register('bowFacing')} placeholder="e.g. East" />
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="access">Access</Label>
<Input id="access" {...register('access')} />
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="berthApproved" id="berthApproved"
@@ -168,29 +234,46 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (ft)</Label> <Label>Length (ft)</Label>
<Input type="number" step="0.1" {...register('lengthFt')} /> <Input type="number" step="0.01" {...register('lengthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Length (m)</Label> <Label>Length (m)</Label>
<Input type="number" step="0.1" {...register('lengthM')} /> <Input type="number" step="0.01" {...register('lengthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (ft)</Label> <Label>Width (ft)</Label>
<Input type="number" step="0.1" {...register('widthFt')} /> <Input type="number" step="0.01" {...register('widthFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Width (m)</Label> <Label>Width (m)</Label>
<Input type="number" step="0.1" {...register('widthM')} /> <Input type="number" step="0.01" {...register('widthM')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (ft)</Label> <Label>Draft (ft)</Label>
<Input type="number" step="0.1" {...register('draftFt')} /> <Input type="number" step="0.01" {...register('draftFt')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Draft (m)</Label> <Label>Draft (m)</Label>
<Input type="number" step="0.1" {...register('draftM')} /> <Input type="number" step="0.01" {...register('draftM')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (ft)</Label>
<Input type="number" step="1" {...register('nominalBoatSize')} />
</div>
<div className="space-y-2">
<Label>Nominal Boat Size (m)</Label>
<Input type="number" step="0.01" {...register('nominalBoatSizeM')} />
</div>
<div className="space-y-2">
<Label>Water Depth (ft)</Label>
<Input type="number" step="0.01" {...register('waterDepth')} />
</div>
<div className="space-y-2">
<Label>Water Depth (m)</Label>
<Input type="number" step="0.01" {...register('waterDepthM')} />
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="widthIsMinimum" id="widthIsMinimum"
@@ -199,6 +282,107 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
/> />
<Label htmlFor="widthIsMinimum">Width is minimum</Label> <Label htmlFor="widthIsMinimum">Width is minimum</Label>
</div> </div>
<div className="flex items-center gap-2">
<Switch
id="waterDepthIsMinimum"
checked={watch('waterDepthIsMinimum') ?? false}
onCheckedChange={(v) => setValue('waterDepthIsMinimum', v)}
/>
<Label htmlFor="waterDepthIsMinimum">Water depth is minimum</Label>
</div>
</div>
</div>
<Separator />
{/* Mooring & Hardware */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Mooring &amp; Hardware
</h3>
<div className="space-y-2">
<Label>Side Pontoon</Label>
<SelectOrEmpty
value={sidePontoon}
onChange={(v) => setValue('sidePontoon', v)}
options={BERTH_SIDE_PONTOON_OPTIONS}
/>
</div>
<div className="space-y-2">
<Label>Mooring Type</Label>
<SelectOrEmpty
value={mooringType}
onChange={(v) => setValue('mooringType', v)}
options={BERTH_MOORING_TYPES}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Cleat Type</Label>
<SelectOrEmpty
value={cleatType}
onChange={(v) => setValue('cleatType', v)}
options={BERTH_CLEAT_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Cleat Capacity</Label>
<SelectOrEmpty
value={cleatCapacity}
onChange={(v) => setValue('cleatCapacity', v)}
options={BERTH_CLEAT_CAPACITIES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Type</Label>
<SelectOrEmpty
value={bollardType}
onChange={(v) => setValue('bollardType', v)}
options={BERTH_BOLLARD_TYPES}
/>
</div>
<div className="space-y-2">
<Label>Bollard Capacity</Label>
<SelectOrEmpty
value={bollardCapacity}
onChange={(v) => setValue('bollardCapacity', v)}
options={BERTH_BOLLARD_CAPACITIES}
/>
</div>
</div>
</div>
<Separator />
{/* Power */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Power
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity (kW)</Label>
<Input type="number" step="1" {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage (V at 60Hz)</Label>
<Input type="number" step="1" {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Access */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Access
</h3>
<SelectOrEmpty
value={access}
onChange={(v) => setValue('access', v)}
options={BERTH_ACCESS_OPTIONS}
/>
</div> </div>
<Separator /> <Separator />
@@ -262,25 +446,6 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
<Separator /> <Separator />
{/* Infrastructure */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Infrastructure
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity</Label>
<Input {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage</Label>
<Input {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Tags */} {/* Tags */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">

View File

@@ -48,22 +48,57 @@ type BerthData = {
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) { function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null; if (!value && value !== 0 && value !== false) return null;
// Mobile-first: stack vertically with label on top so long values
// (e.g. "206.69 ft / 62.99 m") never clip at the right edge.
// From `sm` (>=640px) up: switch to the original two-column layout.
return ( return (
<div className="flex justify-between py-2 text-sm"> <div className="flex flex-col gap-0.5 py-2 text-sm sm:flex-row sm:items-baseline sm:justify-between sm:gap-3">
<span className="text-muted-foreground">{label}</span> <span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right max-w-[60%]">{value}</span> <span className="font-medium sm:max-w-[60%] sm:text-right">{value}</span>
</div> </div>
); );
} }
function OverviewTab({ berth }: { berth: BerthData }) { function OverviewTab({ berth }: { berth: BerthData }) {
// Round to at most 2 decimals; trim trailing zeros so "5.00" -> "5".
const fmt = (v: string | null, fractionDigits = 2): string | null => {
if (v == null || v === '') return null;
const n = Number(v);
if (Number.isNaN(n)) return v;
return n.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: fractionDigits,
});
};
const formatDim = (ft: string | null, m: string | null) => { const formatDim = (ft: string | null, m: string | null) => {
const parts = []; const parts = [];
if (ft) parts.push(`${ft} ft`); const ftFmt = fmt(ft);
if (m) parts.push(`${m} m`); const mFmt = fmt(m);
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null; return parts.length > 0 ? parts.join(' / ') : null;
}; };
const formatNominalBoatSize = (ft: string | null, m: string | null): string | null => {
const ftFmt = fmt(ft, 0);
const mFmt = fmt(m);
const parts: string[] = [];
if (ftFmt) parts.push(`${ftFmt} ft`);
if (mFmt) parts.push(`${mFmt} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const formatPower = (kw: string | null) => {
const v = fmt(kw, 0);
return v ? `${v} kW` : null;
};
const formatVoltage = (v: string | null) => {
const fv = fmt(v, 0);
return fv ? `${fv} V` : null;
};
const price = berth.price const price = berth.price
? new Intl.NumberFormat('en-US', { ? new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
@@ -97,7 +132,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} /> <SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow <SpecRow
label="Nominal Boat Size" label="Nominal Boat Size"
value={berth.nominalBoatSize || berth.nominalBoatSizeM} value={formatNominalBoatSize(berth.nominalBoatSize, berth.nominalBoatSizeM)}
/> />
<SpecRow <SpecRow
label="Water Depth" label="Water Depth"
@@ -122,8 +157,8 @@ function OverviewTab({ berth }: { berth: BerthData }) {
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle> <CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 divide-y"> <CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={berth.powerCapacity} /> <SpecRow label="Power Capacity" value={formatPower(berth.powerCapacity)} />
<SpecRow label="Voltage" value={berth.voltage} /> <SpecRow label="Voltage" value={formatVoltage(berth.voltage)} />
<SpecRow label="Cleat Type" value={berth.cleatType} /> <SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} /> <SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} /> <SpecRow label="Bollard Type" value={berth.bollardType} />

View File

@@ -25,6 +25,8 @@ export interface ClientRow {
createdAt: string; createdAt: string;
yachtCount?: number; yachtCount?: number;
companyCount?: number; companyCount?: number;
interestCount?: number;
latestInterest?: { stage: string; mooringNumber: string | null } | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>; contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
} }

View File

@@ -2,7 +2,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Archive, RotateCcw, Mail, Phone } from 'lucide-react'; import { Archive, Mail, MessageCircle, Phone, RotateCcw } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -12,31 +13,28 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PortalInviteButton } from '@/components/clients/portal-invite-button'; import { PortalInviteButton } from '@/components/clients/portal-invite-button';
import { GdprExportButton } from '@/components/clients/gdpr-export-button'; import { GdprExportButton } from '@/components/clients/gdpr-export-button';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import { getCountryName } from '@/lib/i18n/countries';
interface ClientDetailHeaderProps { interface ClientDetailHeaderProps {
client: { client: {
id: string; id: string;
fullName: string; fullName: string;
nationality?: string | null; nationalityIso?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
archivedAt?: string | null; archivedAt?: string | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>; createdAt?: string;
contacts?: Array<{
channel: string;
value: string;
valueE164?: string | null;
isPrimary: boolean;
label?: string | null;
}>;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
clientPortalEnabled?: boolean; clientPortalEnabled?: boolean;
}; };
} }
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
@@ -62,19 +60,34 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
}); });
const primaryEmail = const primaryEmail =
client.contacts?.find((c) => c.channel === 'email' && c.isPrimary) ?? client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)?.value ??
client.contacts?.find((c) => c.channel === 'email'); client.contacts?.find((c) => c.channel === 'email')?.value;
const primaryPhone = const primaryPhoneContact =
client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ?? client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary) ??
client.contacts?.find((c) => c.channel === 'phone'); 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
? `Added ${format(new Date(client.createdAt), 'MMM d, yyyy')}`
: null;
const meta = [country, addedLabel].filter(Boolean) as string[];
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate"> <h1 className="truncate text-lg font-bold text-foreground sm:text-2xl">
{client.fullName} {client.fullName}
</h1> </h1>
{isArchived && ( {isArchived && (
@@ -84,31 +97,71 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground"> {meta.length > 0 ? (
{client.source && ( <p className="text-xs text-muted-foreground sm:text-sm">{meta.join(' · ')}</p>
<span> ) : null}
Source:{' '}
<span className="text-foreground"> <div className="flex flex-wrap items-center gap-1.5 pt-1">
{SOURCE_LABELS[client.source] ?? client.source} {primaryEmail ? (
</span> <Button
</span> asChild
)} variant="outline"
{primaryEmail && ( size="sm"
<span className="flex items-center gap-1"> className="h-8 gap-1.5 px-2.5 [&_svg]:size-3.5"
<Mail className="h-3.5 w-3.5" /> >
{primaryEmail.value} <a href={`mailto:${primaryEmail}`} aria-label={`Email ${primaryEmail}`}>
</span> <Mail />
)} Email
{primaryPhone && ( </a>
<span className="flex items-center gap-1"> </Button>
<Phone className="h-3.5 w-3.5" /> ) : null}
{primaryPhone.value} {primaryPhone ? (
</span> <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`}
>
<MessageCircle />
WhatsApp
</a>
</Button>
) : null}
{!isArchived && client.clientPortalEnabled !== false ? (
<div className="hidden sm:inline-flex">
<PortalInviteButton
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail}
/>
</div>
) : null}
<div className="hidden sm:inline-flex">
<GdprExportButton clientId={client.id} />
</div>
</div> </div>
{client.tags && client.tags.length > 0 && ( {client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1">
{client.tags.map((tag) => ( {client.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} /> <TagBadge key={tag.id} name={tag.name} color={tag.color} />
))} ))}
@@ -116,34 +169,21 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
)} )}
</div> </div>
{/* Actions */} {/* Top-right: archive/restore as a small icon button — destructive
<div className="flex flex-wrap items-center gap-2"> action sits out of the primary action flow. */}
{!isArchived && client.clientPortalEnabled !== false && ( <button
<PortalInviteButton type="button"
clientId={client.id}
clientName={client.fullName}
defaultEmail={primaryEmail?.value}
/>
)}
<GdprExportButton clientId={client.id} />
<Button
variant={isArchived ? 'outline' : 'outline'}
size="sm"
onClick={() => setArchiveOpen(true)} onClick={() => setArchiveOpen(true)}
> aria-label={isArchived ? 'Restore client' : 'Archive client'}
{isArchived ? ( title={isArchived ? 'Restore client' : 'Archive client'}
<> className={cn(
<RotateCcw className="mr-1.5 h-3.5 w-3.5" /> 'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
Restore 'hover:bg-foreground/5 hover:text-foreground',
</> isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)} )}
</Button> >
</div> {isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
</button>
</div> </div>
</DetailHeaderStrip> </DetailHeaderStrip>

View File

@@ -29,6 +29,8 @@ interface ClientData {
id: string; id: string;
channel: string; channel: string;
value: string; value: string;
valueE164: string | null;
valueCountry: string | null;
label: string | null; label: string | null;
isPrimary: boolean; isPrimary: boolean;
notes: string | null; notes: string | null;

View File

@@ -366,10 +366,6 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1">
<Label>Preferred Language</Label>
<Input {...register('preferredLanguage')} placeholder="English" />
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Timezone</Label> <Label>Timezone</Label>
<TimezoneCombobox <TimezoneCombobox

View File

@@ -0,0 +1,460 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import type { Route } from 'next';
import { useQuery } from '@tanstack/react-query';
import { format, formatDistanceToNowStrict } from 'date-fns';
import { ArrowRight, CheckCircle2, ChevronRight, Circle, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/shared/drawer';
import { apiFetch } from '@/lib/api/client';
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
import {
StageStepper,
useClientInterests,
type ClientInterestRow,
} from '@/components/clients/client-pipeline-summary';
import { InterestForm } from '@/components/interests/interest-form';
const LEAD_CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General interest',
specific_qualified: 'Specific qualified',
hot_lead: 'Hot lead',
};
function InterestRowItem({
interest,
onOpen,
}: {
interest: ClientInterestRow;
onOpen: (i: ClientInterestRow) => void;
}) {
const stage = safeStage(interest.pipelineStage);
const berthLabel = interest.berthMooringNumber
? `Berth ${interest.berthMooringNumber}`
: 'General interest';
const yachtLabel = interest.yachtName ?? null;
return (
// Tap opens a bottom-sheet preview drawer rather than navigating to the
// full interest page. The drawer covers ~80% of mobile interactions
// ("what stage is this at, when did we last touch it"). For deeper
// edits the drawer has an "Open full page" CTA.
<button
type="button"
onClick={() => onOpen(interest)}
className={cn(
'group block w-full rounded-xl border border-border bg-card p-4 text-left shadow-sm transition-all',
'hover:border-border/70 hover:shadow-md',
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="truncate text-base font-semibold tracking-tight text-foreground">
{berthLabel}
</h3>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
</div>
{yachtLabel ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</div>
<div className="mt-3">
<StageStepper current={stage} />
</div>
</button>
);
}
function lastActivityFor(interest: ClientInterestRow): string | null {
const candidates = [interest.dateLastContact, interest.updatedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime())
.filter((t) => !Number.isNaN(t));
if (candidates.length === 0) return null;
return `${formatDistanceToNowStrict(new Date(Math.max(...candidates)))} ago`;
}
/** Full interest record returned by `/api/v1/interests/[id]`. Only the fields
* the drawer actually reads are typed here; the API returns more. */
interface InterestDetail {
id: string;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateDepositReceived: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
}
function useInterestDetail(id: string | null) {
return useQuery<{ data: InterestDetail }>({
queryKey: ['interest-detail-drawer', id],
queryFn: () => apiFetch<{ data: InterestDetail }>(`/api/v1/interests/${id}`),
enabled: id !== null,
// Detail rarely changes during a single drawer-open session; stale-time
// keeps re-opens snappy without preventing background refetch.
staleTime: 30_000,
});
}
/** Format a date-only or ISO timestamp as e.g. "Apr 8, 2026". Returns null for
* empty input so callers can render an "empty" state. */
function formatDate(value: string | null | undefined): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return format(d, 'MMM d, yyyy');
}
/** A single milestone row inside the drawer's milestone summary. Filled
* circle when the step is done, hollow when pending. Trailing meta line
* shows the date stamp or a "pending" hint. */
function MilestoneRow({
label,
done,
date,
hint = 'pending',
}: {
label: string;
done: boolean;
date: string | null;
hint?: string;
}) {
return (
<li className="flex items-center gap-2 py-1">
{done ? (
<CheckCircle2 className="size-4 shrink-0 text-emerald-600" aria-hidden />
) : (
<Circle className="size-4 shrink-0 text-muted-foreground/40" aria-hidden />
)}
<span className={cn('flex-1 text-sm', done ? 'text-foreground' : 'text-muted-foreground')}>
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">{date ?? hint}</span>
</li>
);
}
/**
* Bottom-sheet preview of a single interest. Designed for the mobile
* "tap an interest → see what's happening without leaving the client
* page" flow. Shows the pipeline progress, a compact milestone summary
* (EOI / Deposit / Contract), lead context, last contact, and a notes
* teaser. Tap-out / drag-down dismisses; the full edit page is one tap
* away via "Open full page →".
*/
function InterestPreviewDrawer({
interest,
portSlug,
onClose,
}: {
interest: ClientInterestRow | null;
portSlug: string;
onClose: () => void;
}) {
// Pin the most recently selected interest so the drawer stays populated
// during the close-animation tail (Vaul keeps the content mounted ~250ms
// after `open=false`). Conditional setState is safe here — the guard
// ensures it only fires when the prop actually changes to a new row.
const [pinned, setPinned] = useState<ClientInterestRow | null>(interest);
if (interest && interest !== pinned) setPinned(interest);
const showing = pinned;
const detail = useInterestDetail(showing?.id ?? null);
const fullDetail = detail.data?.data ?? null;
const open = interest !== null;
const stage = showing ? safeStage(showing.pipelineStage) : null;
const stageIdx = stage ? PIPELINE_STAGES.indexOf(stage) : -1;
const reached = (target: PipelineStage) =>
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
const berthLabel = showing
? showing.berthMooringNumber
? `Berth ${showing.berthMooringNumber}`
: 'General interest'
: '';
const yachtLabel = showing?.yachtName ?? null;
const activity = showing ? lastActivityFor(showing) : null;
const fullHref = showing ? (`/${portSlug}/interests/${showing.id}` as Route) : ('/' as Route);
const leadLabel = fullDetail?.leadCategory
? (LEAD_CATEGORY_LABELS[fullDetail.leadCategory] ?? fullDetail.leadCategory)
: null;
const sourceLabel = fullDetail?.source
? fullDetail.source.replace(/\b\w/g, (m) => m.toUpperCase())
: null;
const lastContactDate = formatDate(fullDetail?.dateLastContact);
const notesPreview = fullDetail?.notes?.trim() || null;
return (
<Drawer
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DrawerContent className="max-h-[85vh]">
<DrawerHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<DrawerTitle className="truncate">{berthLabel}</DrawerTitle>
{yachtLabel ? (
<p className="mt-0.5 truncate text-sm text-muted-foreground">{yachtLabel}</p>
) : null}
</div>
{stage ? (
<span
className={cn(
'shrink-0 rounded-full px-2.5 py-1 text-xs font-medium',
STAGE_BADGE[stage],
)}
>
{STAGE_LABELS[stage]}
</span>
) : null}
</div>
</DrawerHeader>
<div className="space-y-5 overflow-y-auto px-4 pb-4">
{/* Pipeline-stepper segmented bar — the same primitive used on the
row card, so the at-a-glance progress hint is consistent
across surfaces. */}
{stage ? (
<div>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pipeline progress
</p>
<StageStepper current={stage} />
</div>
) : null}
{/* Milestones — three sections matching the full interest detail
page (EOI / Deposit / Contract). Done-state is derived from
the pipeline stage so seed data without per-step dates still
renders correctly. The full milestone columns + per-step
actions live behind "Open full page". */}
<section>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Milestones
</p>
<div className="space-y-3">
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">EOI</p>
<ul>
<MilestoneRow
label="EOI sent"
done={reached('eoi_sent') || !!fullDetail?.dateEoiSent}
date={formatDate(fullDetail?.dateEoiSent)}
/>
<MilestoneRow
label="EOI signed"
done={reached('eoi_signed') || !!fullDetail?.dateEoiSigned}
date={formatDate(fullDetail?.dateEoiSigned)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Deposit</p>
<ul>
<MilestoneRow
label="Deposit received"
done={reached('deposit_10pct') || !!fullDetail?.dateDepositReceived}
date={formatDate(fullDetail?.dateDepositReceived)}
/>
</ul>
</div>
<div className="rounded-lg border border-border bg-card/50 p-3">
<p className="mb-1 text-sm font-semibold">Contract</p>
<ul>
<MilestoneRow
label="Contract sent"
done={reached('contract_sent') || !!fullDetail?.dateContractSent}
date={formatDate(fullDetail?.dateContractSent)}
/>
<MilestoneRow
label="Contract signed"
done={reached('contract_signed') || !!fullDetail?.dateContractSigned}
date={formatDate(fullDetail?.dateContractSigned)}
/>
</ul>
</div>
</div>
</section>
{/* Compact key/value pairs — lead category, source, last contact,
activity. Each row collapses cleanly when its value is
missing so the drawer scales from sparse seed data to full
records without empty placeholders. */}
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-sm">
{leadLabel ? (
<>
<dt className="text-muted-foreground">Lead</dt>
<dd className="text-right font-medium">{leadLabel}</dd>
</>
) : null}
{sourceLabel ? (
<>
<dt className="text-muted-foreground">Source</dt>
<dd className="text-right font-medium">{sourceLabel}</dd>
</>
) : null}
{lastContactDate ? (
<>
<dt className="text-muted-foreground">Last contact</dt>
<dd className="text-right font-medium">{lastContactDate}</dd>
</>
) : null}
{activity ? (
<>
<dt className="text-muted-foreground">Last activity</dt>
<dd className="text-right font-medium">{activity}</dd>
</>
) : null}
</dl>
{notesPreview ? (
<section>
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Notes
</p>
<p className="line-clamp-3 text-sm text-foreground/90 whitespace-pre-wrap">
{notesPreview}
</p>
</section>
) : null}
<Button asChild className="w-full" size="lg">
<Link href={fullHref}>
Open full page
<ArrowRight className="ml-1.5 size-4" aria-hidden />
</Link>
</Button>
</div>
</DrawerContent>
</Drawer>
);
}
function InterestSkeleton() {
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<Skeleton className="h-4 w-32" />
<Skeleton className="mt-2 h-3 w-24" />
<Skeleton className="mt-3 h-2 w-48" />
</div>
);
}
interface ClientInterestsTabProps {
clientId: string;
}
export function ClientInterestsTab({ clientId }: ClientInterestsTabProps) {
const routeParams = useParams<{ portSlug: string }>();
const portSlug = routeParams?.portSlug ?? '';
const [createOpen, setCreateOpen] = useState(false);
const [previewInterest, setPreviewInterest] = useState<ClientInterestRow | null>(null);
const { data, isLoading, isError } = useClientInterests(clientId);
if (isLoading) {
return (
<div className="space-y-3">
<InterestSkeleton />
<InterestSkeleton />
</div>
);
}
if (isError) {
return <p className="text-sm text-destructive">Could not load interests for this client.</p>;
}
const interests = data?.data ?? [];
if (interests.length === 0) {
return (
<>
<EmptyState
title="No interests yet"
description="When this client expresses interest in a berth, the sales process will appear here."
action={{
label: 'Add interest',
onClick: () => setCreateOpen(true),
}}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</>
);
}
const active = interests.filter((i) => !i.archivedAt);
const archived = interests.filter((i) => i.archivedAt);
return (
<div className="space-y-6">
<div className="flex justify-end">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 size-3.5" />
Add interest
</Button>
</div>
{active.length > 0 ? (
<div className="space-y-3">
{active.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
) : null}
{archived.length > 0 ? (
<div className="space-y-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Archived
</h4>
<div className="space-y-3 opacity-60">
{archived.map((i) => (
<InterestRowItem key={i.id} interest={i} onOpen={setPreviewInterest} />
))}
</div>
</div>
) : null}
<InterestPreviewDrawer
interest={previewInterest}
portSlug={portSlug}
onClose={() => setPreviewInterest(null)}
/>
<InterestForm open={createOpen} onOpenChange={setCreateOpen} defaultClientId={clientId} />
</div>
);
}

View File

@@ -9,6 +9,8 @@ import { InlineTimezoneField } from '@/components/shared/inline-timezone-field';
import { InlineTagEditor } from '@/components/shared/inline-tag-editor'; import { InlineTagEditor } from '@/components/shared/inline-tag-editor';
import { NotesList } from '@/components/shared/notes-list'; import { NotesList } from '@/components/shared/notes-list';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
import { ClientInterestsTab } from '@/components/clients/client-interests-tab';
import { ClientPipelineSummary } from '@/components/clients/client-pipeline-summary';
import { ClientYachtsTab } from '@/components/clients/client-yachts-tab'; import { ClientYachtsTab } from '@/components/clients/client-yachts-tab';
import { ClientCompaniesTab } from '@/components/clients/client-companies-tab'; import { ClientCompaniesTab } from '@/components/clients/client-companies-tab';
import { ClientReservationsTab } from '@/components/clients/client-reservations-tab'; import { ClientReservationsTab } from '@/components/clients/client-reservations-tab';
@@ -114,6 +116,8 @@ interface ClientTabsOptions {
tenureType: string; tenureType: string;
status: string; status: string;
}>; }>;
interestCount?: number;
noteCount?: number;
tags?: Array<{ id: string; name: string; color: string }>; tags?: Array<{ id: string; name: string; color: string }>;
}; };
} }
@@ -131,6 +135,11 @@ function OverviewTab({
}; };
return ( return (
<div className="space-y-6">
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<ClientPipelineSummary clientId={clientId} variant="panel" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */} {/* Personal Info */}
<div className="space-y-1"> <div className="space-y-1">
@@ -148,12 +157,6 @@ function OverviewTab({
data-testid="client-nationality-inline" data-testid="client-nationality-inline"
/> />
</EditableRow> </EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone"> <EditableRow label="Timezone">
<InlineTimezoneField <InlineTimezoneField
value={client.timezone} value={client.timezone}
@@ -209,6 +212,7 @@ function OverviewTab({
/> />
</div> </div>
</div> </div>
</div>
); );
} }
@@ -219,6 +223,12 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
label: 'Overview', label: 'Overview',
content: <OverviewTab clientId={clientId} client={client} />, content: <OverviewTab clientId={clientId} client={client} />,
}, },
{
id: 'interests',
label: 'Interests',
badge: client.interestCount,
content: <ClientInterestsTab clientId={clientId} />,
},
{ {
id: 'yachts', id: 'yachts',
label: 'Yachts', label: 'Yachts',
@@ -251,18 +261,10 @@ export function getClientTabs({ clientId, currentUserId, client }: ClientTabsOpt
/> />
), ),
}, },
{
id: 'interests',
label: 'Interests',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Interests will appear here once created.</p>
</div>
),
},
{ {
id: 'notes', id: 'notes',
label: 'Notes', label: 'Notes',
badge: client.noteCount,
content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />, content: <NotesList entityType="clients" entityId={clientId} currentUserId={currentUserId} />,
}, },
{ {

View File

@@ -155,6 +155,7 @@ function ContactRow({
onRemove: () => void; onRemove: () => void;
}) { }) {
const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal; const Icon = CHANNEL_ICONS[contact.channel] ?? MoreHorizontal;
const [phoneEditing, setPhoneEditing] = useState(false);
async function togglePrimary() { async function togglePrimary() {
try { try {
@@ -174,17 +175,31 @@ function ContactRow({
} }
return ( return (
<div className="group flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"> <div
{/* Left: channel + value */} data-editing={phoneEditing ? 'true' : undefined}
<div className="flex items-center gap-2 flex-1 min-w-0"> className={cn(
'group rounded-lg border text-sm transition-all duration-150',
// Active-edit dilation: lift the row out of the muted baseline with a
// soft primary ring + slightly brighter surface. Single visual signal
// replaces the need for any "now editing" label.
phoneEditing
? 'bg-card border-primary/30 ring-2 ring-primary/15 shadow-sm p-3 gap-3'
: 'bg-muted/30 p-2 gap-2',
// Stack value editor / action cluster on mobile; single row on sm+.
'flex flex-col sm:flex-row sm:items-center',
)}
>
{/* Top / left: channel + value */}
<div className="flex min-w-0 flex-1 items-center gap-2">
<ChannelPicker value={contact.channel} onChange={changeChannel}> <ChannelPicker value={contact.channel} onChange={changeChannel}>
<Icon className="h-3.5 w-3.5 text-muted-foreground" /> <Icon className="h-3.5 w-3.5 text-muted-foreground" />
</ChannelPicker> </ChannelPicker>
<div className="min-w-0"> <div className="min-w-0 flex-1">
{contact.channel === 'phone' || contact.channel === 'whatsapp' ? ( {contact.channel === 'phone' || contact.channel === 'whatsapp' ? (
<InlinePhoneField <InlinePhoneField
e164={contact.valueE164 ?? null} e164={contact.valueE164 ?? null}
country={contact.valueCountry ?? null} country={contact.valueCountry ?? null}
onEditingChange={setPhoneEditing}
onSave={async ({ e164, country }) => { onSave={async ({ e164, country }) => {
if (!e164) { if (!e164) {
toast.error('Phone number is required'); toast.error('Phone number is required');
@@ -208,12 +223,27 @@ function ContactRow({
</div> </div>
</div> </div>
{/* Right: tag + actions */} {/* Bottom / right: tag + actions.
<div className="flex items-center gap-2 shrink-0"> Two layers of hiding compose here:
<div className="w-28 text-xs text-muted-foreground text-right"> (a) phoneEditing — when the phone editor is open, hide the entire
action cluster (tag + star + trash) so the user can focus on
the form without chips fighting for space.
(b) contact.value — when the value is empty (stale import row,
aborted edit), hide just the tag + Make-primary star;
neither makes sense without a value. The trash icon stays
so the user can clean up the empty entry.
On touch (no hover), trash is always rendered; on desktop it
fades in on hover only (sm:opacity-0 + sm:group-hover:opacity-100). */}
{!phoneEditing ? (
<div className="flex shrink-0 items-center justify-end gap-2">
{contact.value ? (
<>
<div className="w-28 text-right text-xs text-muted-foreground">
<InlineEditableField <InlineEditableField
value={ value={
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null contact.label && contact.label.toLowerCase() !== 'primary'
? contact.label
: null
} }
emptyText="Add tag" emptyText="Add tag"
placeholder="work, home…" placeholder="work, home…"
@@ -228,22 +258,25 @@ function ContactRow({
onClick={togglePrimary} onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'} title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn( className={cn(
'p-1 rounded hover:bg-background/60 transition-colors', 'rounded p-1 transition-colors hover:bg-background/60',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50', contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)} )}
> >
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} /> <Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button> </button>
</>
) : null}
<button <button
type="button" type="button"
onClick={onRemove} onClick={onRemove}
title="Remove" title="Remove"
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all" className="rounded p-1 text-muted-foreground/50 transition-all hover:bg-background/60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
) : null}
</div> </div>
); );
} }
@@ -330,7 +363,9 @@ function NewContactForm({
const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim()); const submitDisabled = saving || (isPhoneChannel ? !phoneValue?.e164 : !value.trim());
return ( return (
<div className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"> // Single row on sm+; wraps onto multiple lines below 640px so the channel
// picker, value field, label, and buttons each get their own usable width.
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2 text-sm">
<Select <Select
value={channel} value={channel}
onValueChange={(next) => { onValueChange={(next) => {
@@ -353,7 +388,7 @@ function NewContactForm({
</Select> </Select>
{isPhoneChannel ? ( {isPhoneChannel ? (
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1 basis-full sm:basis-auto">
<PhoneInput <PhoneInput
value={phoneValue} value={phoneValue}
onChange={(v) => setPhoneValue(v)} onChange={(v) => setPhoneValue(v)}
@@ -365,7 +400,7 @@ function NewContactForm({
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
placeholder={channel === 'email' ? 'name@example.com' : 'value'} placeholder={channel === 'email' ? 'name@example.com' : 'value'}
className="h-7 text-sm flex-1 min-w-0" className="h-7 min-w-0 flex-1 basis-full text-sm sm:basis-auto"
autoFocus autoFocus
disabled={saving} disabled={saving}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -382,7 +417,7 @@ function NewContactForm({
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="tag (optional)" placeholder="tag (optional)"
className="h-7 text-xs w-28" className="h-7 w-28 text-xs"
disabled={saving} disabled={saving}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
@@ -393,6 +428,7 @@ function NewContactForm({
}} }}
/> />
<div className="ml-auto flex gap-2">
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}> <Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'} {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
</Button> </Button>
@@ -400,5 +436,6 @@ function NewContactForm({
Cancel Cancel
</Button> </Button>
</div> </div>
</div>
); );
} }

View File

@@ -77,7 +77,9 @@ export function CompanyDetailHeader({ company }: CompanyDetailHeaderProps) {
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-3 flex-wrap"> {/* Stack actions below the title block on phone widths; horizontal
beside it from sm up. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:flex-wrap sm:gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate"> <h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">

View File

@@ -146,7 +146,11 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
</EditableRow> </EditableRow>
<EditableRow label="Incorporation Date"> <EditableRow label="Incorporation Date">
<InlineEditableField <InlineEditableField
value={company.incorporationDate} // The API returns this as an ISO timestamp ("2019-03-14T00:00:00.000Z")
// because Postgres `date` columns are serialized through JSON. Strip
// the time portion so the read-only state shows just YYYY-MM-DD,
// which is also the format the user types when editing.
value={company.incorporationDate ? company.incorporationDate.slice(0, 10) : null}
placeholder="YYYY-MM-DD" placeholder="YYYY-MM-DD"
onSave={save('incorporationDate')} onSave={save('incorporationDate')}
/> />

View File

@@ -28,11 +28,10 @@ function formatPercent(value: number): string {
function KpiTileSkeleton() { function KpiTileSkeleton() {
return ( return (
<div className="relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm"> <div className="relative overflow-hidden rounded-xl border border-border bg-card p-3 shadow-sm sm:p-5">
<div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden /> <div className="absolute inset-x-0 top-0 h-1 bg-muted" aria-hidden />
<Skeleton className="h-3 w-24" /> <Skeleton className="h-3 w-20" />
<Skeleton className="mt-3 h-7 w-32" /> <Skeleton className="mt-2 h-6 w-24 sm:mt-3 sm:h-7" />
<Skeleton className="mt-2 h-3 w-12" />
</div> </div>
); );
} }

View File

@@ -67,8 +67,11 @@ export function MyRemindersRail() {
return `/${portSlug}/reminders`; return `/${portSlug}/reminders`;
} }
// `h-full` only at xl: where the dashboard grid pairs this rail with
// a sibling chart column. On mobile (stacked) it produced a weirdly
// tall empty card.
return ( return (
<Card className="h-full"> <Card className="xl:h-full">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3"> <CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<CardTitle className="flex items-center gap-1.5 text-base"> <CardTitle className="flex items-center gap-1.5 text-base">

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect } from 'react';
type Edge = 'top' | 'bottom' | 'left' | 'right';
interface ToolbarState {
edge: Edge;
ratio: number;
collapsed: boolean;
enabled: boolean;
defaultAction?: string;
}
interface ReactGrabAPI {
setToolbarState: (state: Partial<ToolbarState>) => void;
onToolbarStateChange: (cb: (state: ToolbarState) => void) => () => void;
}
declare global {
interface Window {
__REACT_GRAB__?: ReactGrabAPI;
}
}
const MOBILE_QUERY = '(max-width: 1023.98px)';
const DESKTOP_KEY = 'react-grab-toolbar-state-desktop';
const MOBILE_KEY = 'react-grab-toolbar-state-mobile';
const DESKTOP_DEFAULT: Partial<ToolbarState> = {
edge: 'bottom',
ratio: 0.5,
collapsed: false,
};
const MOBILE_DEFAULT: Partial<ToolbarState> = {
edge: 'right',
ratio: 0.5,
collapsed: false,
};
export function ReactGrabViewportSync() {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
const cleanups: Array<() => void> = [];
let pollId: number | undefined;
const wireUp = (api: ReactGrabAPI) => {
const mql = window.matchMedia(MOBILE_QUERY);
const keyFor = () => (mql.matches ? MOBILE_KEY : DESKTOP_KEY);
const defaultFor = () => (mql.matches ? MOBILE_DEFAULT : DESKTOP_DEFAULT);
let suppressNextWrite = false;
const apply = () => {
const stored = localStorage.getItem(keyFor());
suppressNextWrite = true;
api.setToolbarState(stored ? (JSON.parse(stored) as ToolbarState) : defaultFor());
};
apply();
const unsubscribe = api.onToolbarStateChange((state) => {
if (suppressNextWrite) {
suppressNextWrite = false;
return;
}
localStorage.setItem(keyFor(), JSON.stringify(state));
});
mql.addEventListener('change', apply);
cleanups.push(unsubscribe, () => mql.removeEventListener('change', apply));
};
const tryWire = () => {
const api = window.__REACT_GRAB__;
if (!api) return false;
wireUp(api);
return true;
};
if (!tryWire()) {
pollId = window.setInterval(() => {
if (tryWire() && pollId !== undefined) {
window.clearInterval(pollId);
pollId = undefined;
}
}, 100);
window.setTimeout(() => {
if (pollId !== undefined) {
window.clearInterval(pollId);
pollId = undefined;
}
}, 5000);
}
return () => {
if (pollId !== undefined) window.clearInterval(pollId);
cleanups.forEach((fn) => fn());
};
}, []);
return null;
}

View File

@@ -339,12 +339,19 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
</button> </button>
) : ( ) : (
<> <>
{/* Mobile: icon-only with title tooltip + colored fill carries
the won/lost meaning (green vs rose). Adding a "Won" /
"Lost" text label inline blew out the cluster width and
forced the Email/Call/WhatsApp action-chip row above to
stack vertically — bad trade. From sm up, the full
"Mark won" / "Close as lost" labels read clearly. */}
<button <button
type="button" type="button"
onClick={() => setOutcomeDialog('won')} onClick={() => setOutcomeDialog('won')}
aria-label="Mark as won" aria-label="Mark as won"
title="Mark as won"
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-emerald-200 bg-emerald-50 text-emerald-700', 'border border-emerald-200 bg-emerald-50 text-emerald-700',
'hover:bg-emerald-100', 'hover:bg-emerald-100',
)} )}
@@ -356,8 +363,9 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
type="button" type="button"
onClick={() => setOutcomeDialog('lost')} onClick={() => setOutcomeDialog('lost')}
aria-label="Close as lost" aria-label="Close as lost"
title="Close as lost"
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', 'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-2.5',
'border border-rose-200 text-rose-700', 'border border-rose-200 text-rose-700',
'hover:bg-rose-50', 'hover:bg-rose-50',
)} )}

View File

@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { LayoutDashboard, Users, Ship, Anchor, Menu } from 'lucide-react'; import { Anchor, FileSignature, LayoutDashboard, Menu, Users } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -12,11 +12,27 @@ type TabSpec = {
segment: string; // route segment after /[portSlug]/ segment: string; // route segment after /[portSlug]/
}; };
// Bottom nav ordering, left → right:
// Dashboard — daily overview
// Berths — marina inventory grid (touches sales + ops both)
// Clients — the address book / dedup surface (centered: it's the
// primary mental anchor for "find this person", with
// interests living as a tab on the client detail rather
// than a peer in the bottom nav)
// Documents — signature tracking (chase signers, EOI queue)
// More — overflow drawer (Interests, Yachts, Companies, …)
//
// Interests is intentionally NOT in the bottom row — having both Clients
// and Interests as peer tabs created a Clients-vs-Interests confusion
// for sales reps, and the per-client interests tab + the new bottom-sheet
// drawer cover the day-to-day deal review without needing a dedicated tab.
// Yachts stays out for the same reason as before: it's an asset record
// most often reached from inside an interest or client, not browsed.
const TABS: TabSpec[] = [ const TABS: TabSpec[] = [
{ label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' }, { label: 'Dashboard', icon: LayoutDashboard, segment: 'dashboard' },
{ label: 'Clients', icon: Users, segment: 'clients' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Berths', icon: Anchor, segment: 'berths' }, { label: 'Berths', icon: Anchor, segment: 'berths' },
{ label: 'Clients', icon: Users, segment: 'clients' },
{ label: 'Documents', icon: FileSignature, segment: 'documents' },
]; ];
export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) { export function MobileBottomTabs({ onMoreClick }: { onMoreClick: () => void }) {

View File

@@ -3,17 +3,17 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
Building2,
Bookmark,
Receipt,
FileText,
FolderOpen,
Mail,
Bell,
ShieldAlert,
BarChart3, BarChart3,
Bell,
Bookmark,
Building2,
FileText,
Mail,
Receipt,
Settings, Settings,
Shield, Shield,
ShieldAlert,
Ship,
} from 'lucide-react'; } from 'lucide-react';
import { import {
@@ -30,13 +30,18 @@ type MoreItem = {
segment: string; segment: string;
}; };
// Order: most-likely overflow targets first. Interests is here (rather
// than the bottom row) to dodge the Clients-vs-Interests UX confusion;
// reps reach the active deals via the Interests tab on a client detail
// (or via the new bottom-sheet drawer). Yachts is asset-record traffic
// best reached contextually from inside an interest or client.
const MORE_ITEMS: MoreItem[] = [ const MORE_ITEMS: MoreItem[] = [
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Interests', icon: Bookmark, segment: 'interests' }, { label: 'Interests', icon: Bookmark, segment: 'interests' },
{ label: 'Yachts', icon: Ship, segment: 'yachts' },
{ label: 'Companies', icon: Building2, segment: 'companies' },
{ label: 'Invoices', icon: FileText, segment: 'invoices' }, { label: 'Invoices', icon: FileText, segment: 'invoices' },
{ label: 'Expenses', icon: Receipt, segment: 'expenses' }, { label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Documents', icon: FolderOpen, segment: 'documents' }, { label: 'Inbox', icon: Mail, segment: 'email' },
{ label: 'Email', icon: Mail, segment: 'email' },
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' }, { label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
{ label: 'Reports', icon: BarChart3, segment: 'reports' }, { label: 'Reports', icon: BarChart3, segment: 'reports' },
{ label: 'Reminders', icon: Bell, segment: 'reminders' }, { label: 'Reminders', icon: Bell, segment: 'reminders' },

View File

@@ -249,7 +249,9 @@ export function ReminderList() {
} }
/> />
<div className="flex items-center gap-4 mb-4"> {/* Wrap on phone widths so the priority filter doesn't get pushed
off-screen by the My/All tabs + status filter taking the full row. */}
<div className="flex flex-wrap items-center gap-3 mb-4 sm:gap-4">
{canViewAll && ( {canViewAll && (
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}> <Tabs value={viewMode} onValueChange={(v) => setViewMode(v as 'my' | 'all')}>
<TabsList> <TabsList>

View File

@@ -18,6 +18,7 @@ import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
interface ResidentialClientRow { interface ResidentialClientRow {
@@ -85,7 +86,9 @@ export function ResidentialClientsList() {
/> />
</div> </div>
<div className="rounded-lg border bg-card overflow-hidden"> {/* Desktop: table layout. Hidden below lg because the 6 columns clip
off the viewport at phone widths. */}
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground"> <thead className="bg-muted/40 text-xs text-muted-foreground">
<tr> <tr>
@@ -137,6 +140,51 @@ export function ResidentialClientsList() {
</table> </table>
</div> </div>
{/* Mobile: card list. Each card mirrors the table row data with
name + status pill on top, then meta line(s) below. */}
<div className="lg:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
No residential clients yet.
</div>
)}
{data?.data.map((c) => (
<Link
key={c.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/clients/${c.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm truncate">{c.fullName}</p>
<span
className={cn(
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
c.status === 'active'
? 'bg-emerald-100 text-emerald-800'
: c.status === 'inactive'
? 'bg-muted text-muted-foreground'
: 'bg-blue-100 text-blue-800',
)}
>
{STATUS_LABELS[c.status] ?? c.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted-foreground">
{c.email ? <span className="truncate">{c.email}</span> : null}
{c.phone ? <span>{c.phone}</span> : null}
{c.placeOfResidence ? <span>{c.placeOfResidence}</span> : null}
{c.source ? <span className="capitalize">· {c.source}</span> : null}
</div>
</Link>
))}
</div>
<NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} /> <NewResidentialClientSheet open={createOpen} onOpenChange={setCreateOpen} />
</div> </div>
); );

View File

@@ -94,7 +94,8 @@ export function ResidentialInterestsList() {
</Select> </Select>
</div> </div>
<div className="rounded-lg border bg-card overflow-hidden"> {/* Desktop: table layout. Hidden below lg; mobile renders cards. */}
<div className="hidden lg:block rounded-lg border bg-card overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground"> <thead className="bg-muted/40 text-xs text-muted-foreground">
<tr> <tr>
@@ -149,6 +150,47 @@ export function ResidentialInterestsList() {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Mobile: card list. Stage as the headline (it's the most actionable
field for triage), preferences/notes truncated below. */}
<div className="lg:hidden space-y-2">
{isLoading && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
Loading
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="rounded-lg border bg-card px-3 py-8 text-center text-sm text-muted-foreground">
No interests match.
</div>
)}
{data?.data.map((i) => (
<Link
key={i.id}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/residential/interests/${i.id}` as any}
className="block rounded-lg border bg-card p-3 transition-colors hover:bg-muted/30"
>
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm">
{STAGE_LABELS[i.pipelineStage] ?? i.pipelineStage}
</p>
<span className="shrink-0 text-[11px] text-muted-foreground">
{new Date(i.updatedAt).toLocaleDateString()}
</span>
</div>
{i.preferences ? (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{i.preferences}</p>
) : null}
{i.notes ? (
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground/80">{i.notes}</p>
) : null}
{i.source ? (
<p className="mt-1 text-[11px] capitalize text-muted-foreground">{i.source}</p>
) : null}
</Link>
))}
</div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react'; import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -225,6 +225,14 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
); );
} }
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
function flagEmoji(code: string | null | undefined): string {
if (!code || code.length !== 2) return '';
const A = 0x1f1e6;
const a = 'A'.charCodeAt(0);
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
}
function CountryFieldInline({ function CountryFieldInline({
value, value,
onSave, onSave,
@@ -233,20 +241,34 @@ function CountryFieldInline({
onSave: (iso: string | null) => Promise<void>; onSave: (iso: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
// Tracks whether a value was picked this edit cycle so the open-change
// handler doesn't double-exit while commit is still in flight.
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<CountryCombobox <CountryCombobox
value={value ?? null} value={value ?? null}
onChange={async (iso) => { onChange={async (iso) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(iso ?? null); await onSave(iso ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
// Drop the user straight into the picker — no extra click on the
// trigger required.
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the popover closes without a pick so
// the user isn't stuck staring at a "Select country…" trigger.
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }
const display = value ? getCountryName(value, 'en') : null; const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
return ( return (
<button <button
type="button" type="button"
@@ -268,17 +290,25 @@ function SubdivisionFieldInline({
onSave: (code: string | null) => Promise<void>; onSave: (code: string | null) => Promise<void>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const pickedRef = useRef(false);
if (editing) { if (editing) {
return ( return (
<SubdivisionCombobox <SubdivisionCombobox
value={value ?? null} value={value ?? null}
country={country} country={country}
onChange={async (code) => { onChange={async (code) => {
pickedRef.current = true;
setEditing(false); setEditing(false);
await onSave(code ?? null); await onSave(code ?? null);
}} }}
clearable clearable
className="w-full" className="w-full"
defaultOpen
onOpenChange={(open) => {
if (!open && !pickedRef.current) setEditing(false);
if (open) pickedRef.current = false;
}}
/> />
); );
} }

View File

@@ -30,6 +30,12 @@ interface CountryComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers so the
* user lands directly in the picker after clicking the edit affordance. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
/** /**
@@ -58,8 +64,14 @@ export function CountryCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: CountryComboboxProps) { }: CountryComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'); const effectiveLocale = locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en');
// Pre-build the options list once per locale change so the cmdk filter // Pre-build the options list once per locale change so the cmdk filter
@@ -75,7 +87,7 @@ export function CountryCombobox({
const selected = value ? options.find((o) => o.code === value) : undefined; const selected = value ? options.find((o) => o.code === value) : undefined;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,8 +31,12 @@ export function InlineCountryField({
}: InlineCountryFieldProps) { }: InlineCountryFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: CountryCode | null) { async function commit(next: CountryCode | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -51,7 +55,23 @@ export function InlineCountryField({
if (editing) { if (editing) {
return ( return (
<div className={cn('flex items-center gap-1', className)}> <div className={cn('flex items-center gap-1', className)}>
<CountryCombobox value={value} onChange={(iso) => void commit(iso)} data-testid={testId} /> <CountryCombobox
value={value}
onChange={(iso) => void commit(iso)}
data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// When the dropdown closes without a selection, leave edit mode
// so the user isn't stuck staring at the trigger button. If a
// pick happened, commit() handles the exit (and may need to keep
// edit mode briefly to show the saving spinner).
if (!open && !pickedRef.current) {
setEditing(false);
}
// Reset for the next open cycle.
if (open) pickedRef.current = false;
}}
/>
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>
); );

View File

@@ -17,6 +17,12 @@ interface InlinePhoneFieldProps {
/** Falls back to this country if `country` isn't set. */ /** Falls back to this country if `country` isn't set. */
defaultCountry?: CountryCode; defaultCountry?: CountryCode;
onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>; onSave: (next: { e164: string | null; country: CountryCode }) => Promise<void>;
/**
* Notifies the parent when the field enters/exits edit mode. Lets the row
* dim or hide noise (tag chips, action buttons) while the user is focused
* on the editor.
*/
onEditingChange?: (editing: boolean) => void;
emptyText?: string; emptyText?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
@@ -28,12 +34,13 @@ export function InlinePhoneField({
country, country,
defaultCountry, defaultCountry,
onSave, onSave,
onEditingChange,
emptyText = '—', emptyText = '—',
disabled, disabled,
className, className,
'data-testid': testId, 'data-testid': testId,
}: InlinePhoneFieldProps) { }: InlinePhoneFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditingRaw] = useState(false);
const [draft, setDraft] = useState<PhoneInputValue | null>(() => { const [draft, setDraft] = useState<PhoneInputValue | null>(() => {
if (!e164 && !country) return null; if (!e164 && !country) return null;
return { return {
@@ -43,6 +50,11 @@ export function InlinePhoneField({
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
function setEditing(next: boolean) {
setEditingRaw(next);
onEditingChange?.(next);
}
async function commit() { async function commit() {
const next = draft ?? { e164: null, country: defaultCountry ?? 'US' }; const next = draft ?? { e164: null, country: defaultCountry ?? 'US' };
if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) { if (next.e164 === (e164 ?? null) && next.country === (country ?? null)) {
@@ -62,21 +74,15 @@ export function InlinePhoneField({
if (editing) { if (editing) {
return ( return (
<div className={cn('flex items-center gap-1', className)}> // Two clean lines: country picker + number on top, action pair below.
<div className={cn('flex w-full flex-col gap-2.5', className)}>
<PhoneInput <PhoneInput
value={draft} value={draft}
onChange={(v) => setDraft(v)} onChange={(v) => setDraft(v)}
defaultCountry={defaultCountry} defaultCountry={defaultCountry}
data-testid={testId} data-testid={testId}
/> />
<button <div className="flex items-center justify-end gap-1.5">
type="button"
onClick={() => void commit()}
disabled={saving}
className="rounded px-2 py-1 text-xs font-medium hover:bg-muted disabled:opacity-50"
>
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
</button>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -91,10 +97,27 @@ export function InlinePhoneField({
setEditing(false); setEditing(false);
}} }}
disabled={saving} disabled={saving}
className="rounded px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50" className={cn(
'inline-flex h-8 items-center rounded-md px-3 text-xs font-medium',
'text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
'disabled:opacity-50',
)}
> >
Cancel Cancel
</button> </button>
<button
type="button"
onClick={() => void commit()}
disabled={saving}
className={cn(
'inline-flex h-8 min-w-[64px] items-center justify-center rounded-md px-3',
'bg-primary text-xs font-semibold text-primary-foreground shadow-sm',
'transition-colors hover:bg-primary/90 disabled:opacity-50',
)}
>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : 'Save'}
</button>
</div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useRef, useState } from 'react';
import { Loader2, Pencil } from 'lucide-react'; import { Loader2, Pencil } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -31,8 +31,12 @@ export function InlineTimezoneField({
}: InlineTimezoneFieldProps) { }: InlineTimezoneFieldProps) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Set true when the user picks a value from the dropdown, so the
// popover-close handler knows commit() will exit edit mode itself.
const pickedRef = useRef(false);
async function commit(next: string | null) { async function commit(next: string | null) {
pickedRef.current = true;
if (next === (value ?? null)) { if (next === (value ?? null)) {
setEditing(false); setEditing(false);
return; return;
@@ -56,6 +60,16 @@ export function InlineTimezoneField({
onChange={(tz) => void commit(tz)} onChange={(tz) => void commit(tz)}
countryHint={countryHint ?? undefined} countryHint={countryHint ?? undefined}
data-testid={testId} data-testid={testId}
defaultOpen
onOpenChange={(open) => {
// Auto-exit edit mode when the dropdown closes without a pick,
// so the user isn't stuck looking at the trigger. commit() owns
// the exit when a value was selected.
if (!open && !pickedRef.current) {
setEditing(false);
}
if (open) pickedRef.current = false;
}}
/> />
{saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />} {saving && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div> </div>

View File

@@ -1,16 +1,9 @@
'use client'; 'use client';
import { type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export interface ResponsiveTab { export interface ResponsiveTab {
id: string; id: string;
@@ -26,37 +19,45 @@ interface ResponsiveTabsProps {
} }
/** /**
* Tabs that collapse to a native <Select> on phone-sized viewports. * Tab strip that scrolls horizontally on narrow viewports. The active tab is
* Above sm: TabsList renders. At/below sm: a Select dropdown replaces the tab strip. * automatically scrolled into view so users can tell at a glance that more
* tabs exist beyond the visible edge.
*
* Previously this collapsed to a <Select> on phone widths, but that read as
* a generic dropdown and obscured the fact that multiple peer tabs exist.
*/ */
export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) { export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsProps) {
const listRef = useRef<HTMLDivElement>(null);
// Keep the active trigger in view when the value changes externally
// (e.g. ?tab= in the URL or a back/forward navigation).
useEffect(() => {
const root = listRef.current;
if (!root) return;
const active = root.querySelector<HTMLButtonElement>(`[data-tab-id="${CSS.escape(value)}"]`);
if (active) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}, [value]);
return ( return (
<Tabs value={value} onValueChange={onValueChange}> <Tabs value={value} onValueChange={onValueChange}>
{/* Mobile: select dropdown */} {/* Single scrollable strip for all viewport widths.
<div className="sm:hidden"> The wrapper handles horizontal overflow with momentum scroll on
<Select value={value} onValueChange={onValueChange}> touch devices; the inner TabsList stays its natural width and
<SelectTrigger> slides under the wrapper. */}
<SelectValue /> <div
</SelectTrigger> ref={listRef}
<SelectContent> className="overflow-x-auto -mx-2 px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<TabsList className="inline-flex w-max">
{tabs.map((tab) => ( {tabs.map((tab) => (
<SelectItem key={tab.id} value={tab.id}> <TabsTrigger
<span className="flex items-center gap-1.5"> key={tab.id}
{tab.label} value={tab.id}
{tab.badge !== undefined && tab.badge !== null && ( className="gap-1.5 whitespace-nowrap"
<span className="text-xs text-muted-foreground">({tab.badge})</span> data-tab-id={tab.id}
)} >
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Desktop / tablet: tab strip */}
<TabsList className="hidden sm:flex">
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
{tab.label} {tab.label}
{tab.badge !== undefined && tab.badge !== null && ( {tab.badge !== undefined && tab.badge !== null && (
<Badge variant="secondary" className="px-1.5 py-0 text-xs"> <Badge variant="secondary" className="px-1.5 py-0 text-xs">
@@ -66,6 +67,7 @@ export function ResponsiveTabs({ tabs, value, onValueChange }: ResponsiveTabsPro
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
</div>
{tabs.map((tab) => ( {tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4"> <TabsContent key={tab.id} value={tab.id} className="mt-4">

View File

@@ -32,6 +32,11 @@ interface SubdivisionComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
export function SubdivisionCombobox({ export function SubdivisionCombobox({
@@ -44,8 +49,14 @@ export function SubdivisionCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: SubdivisionComboboxProps) { }: SubdivisionComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const options = useMemo(() => { const options = useMemo(() => {
if (!country) return []; if (!country) return [];
@@ -64,7 +75,7 @@ export function SubdivisionCombobox({
else triggerLabel = placeholder; else triggerLabel = placeholder;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -29,6 +29,11 @@ interface TimezoneComboboxProps {
clearable?: boolean; clearable?: boolean;
id?: string; id?: string;
'data-testid'?: string; 'data-testid'?: string;
/** Open the dropdown on first render. Used by inline-edit wrappers. */
defaultOpen?: boolean;
/** Notified whenever the dropdown opens/closes. Inline-edit wrappers use
* this to auto-exit edit mode when the user dismisses without picking. */
onOpenChange?: (open: boolean) => void;
} }
export function TimezoneCombobox({ export function TimezoneCombobox({
@@ -41,8 +46,14 @@ export function TimezoneCombobox({
clearable = true, clearable = true,
id, id,
'data-testid': testId, 'data-testid': testId,
defaultOpen = false,
onOpenChange,
}: TimezoneComboboxProps) { }: TimezoneComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(defaultOpen);
const handleOpenChange = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
const allOptions = useMemo(() => { const allOptions = useMemo(() => {
return listAllTimezones().map((tz) => ({ return listAllTimezones().map((tz) => ({
@@ -66,7 +77,7 @@ export function TimezoneCombobox({
const selectedLabel = value ? formatTimezoneLabel(value) : placeholder; const selectedLabel = value ? formatTimezoneLabel(value) : placeholder;
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
id={id} id={id}

View File

@@ -45,7 +45,7 @@ export function KPITile({
<div <div
data-testid="kpi-tile" data-testid="kpi-tile"
className={cn( className={cn(
'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-5 shadow-sm transition-all duration-base ease-smooth hover:shadow-md', 'group relative overflow-hidden rounded-xl border border-border bg-gradient-brand-soft p-3 shadow-sm transition-all duration-base ease-smooth hover:shadow-md sm:p-5',
className, className,
)} )}
{...props} {...props}
@@ -53,10 +53,12 @@ export function KPITile({
<div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden /> <div className={cn('absolute inset-x-0 top-0 h-1', ACCENT_STRIPES[accent])} aria-hidden />
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground sm:text-xs">
{title} {title}
</div> </div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div> <div className="mt-1 truncate text-lg font-semibold tabular-nums text-foreground sm:mt-2 sm:text-2xl">
{value}
</div>
{typeof delta === 'number' ? ( {typeof delta === 'number' ? (
<div className={cn('mt-1 text-xs font-medium', deltaClass)}> <div className={cn('mt-1 text-xs font-medium', deltaClass)}>
{deltaPrefix} {deltaPrefix}

View File

@@ -142,7 +142,10 @@ export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {
return ( return (
<> <>
<DetailHeaderStrip> <DetailHeaderStrip>
<div className="flex items-start gap-3 flex-wrap"> {/* Stacks vertically on phone widths so the action cluster doesn't
crush the status pill / owner row. From sm up, title block sits
beside actions in the original layout. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-3 sm:flex-wrap">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="hidden sm:block text-2xl font-bold text-foreground truncate"> <h1 className="hidden sm:block text-2xl font-bold text-foreground truncate">

View File

@@ -123,6 +123,49 @@ export const BERTH_STATUSES = ['available', 'under_offer', 'sold'] as const;
export type BerthStatus = (typeof BERTH_STATUSES)[number]; export type BerthStatus = (typeof BERTH_STATUSES)[number];
// ─── Berth single-select catalogues (mirror NocoDB) ──────────────────────────
// Stored as free text in the DB so legacy values still load, but the form
// presents only the canonical options below.
export const BERTH_AREAS = ['A', 'B', 'C', 'D', 'E'] as const;
export const BERTH_SIDE_PONTOON_OPTIONS = [
'No',
'Quay SB',
'Quay PT',
'Quay SB, Yes PT',
'Quay PT, Yes SB',
'Yes SB',
'Yes PT',
'Yes SB, PT',
'Finger SB',
'Finger PT',
] as const;
export const BERTH_MOORING_TYPES = [
'Side Pier / Med Mooring',
'2x Med Mooring',
'Side Pier / Finger',
'Finger / Med Mooring',
'2x Finger',
] as const;
export const BERTH_CLEAT_TYPES = ['A3', 'A5'] as const;
export const BERTH_CLEAT_CAPACITIES = ['10-14 ton break load', '20-24 ton break load'] as const;
export const BERTH_BOLLARD_TYPES = ['Bull bollard type A', 'Bull bollard type B'] as const;
export const BERTH_BOLLARD_CAPACITIES = ['20 ton break load', '40 ton break load'] as const;
export const BERTH_ACCESS_OPTIONS = [
'Car to Vessel',
'Car to Quai, Cart to Vessel',
'Cart to Vessel',
'Car (3t) to Vessel',
'Car (3.5t) to Vessel',
] as const;
// ─── Lead Categories ───────────────────────────────────────────────────────── // ─── Lead Categories ─────────────────────────────────────────────────────────
export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const; export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_lead'] as const;

View File

@@ -0,0 +1,15 @@
-- Convert text columns to numeric. NULLs survive; empty strings become NULL;
-- whitespace is trimmed before casting so legacy data with stray spaces converts cleanly.
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "nominal_boat_size_m" SET DATA TYPE numeric
USING NULLIF(TRIM("nominal_boat_size_m"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "power_capacity" SET DATA TYPE numeric
USING NULLIF(TRIM("power_capacity"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths"
ALTER COLUMN "voltage" SET DATA TYPE numeric
USING NULLIF(TRIM("voltage"), '')::numeric;--> statement-breakpoint
ALTER TABLE "berths" ADD COLUMN "status_override_mode" text;

File diff suppressed because it is too large Load Diff

View File

@@ -142,6 +142,13 @@
"tag": "0019_lazy_vampiro", "tag": "0019_lazy_vampiro",
"breakpoints": true "breakpoints": true
}, },
{
"idx": 20,
"version": "7",
"when": 1777814682110,
"tag": "0020_medical_betty_brant",
"breakpoints": true
},
{ {
"idx": 21, "idx": 21,
"version": "7", "version": "7",

View File

@@ -33,14 +33,15 @@ export const berths = pgTable(
widthM: numeric('width_m'), widthM: numeric('width_m'),
draftM: numeric('draft_m'), draftM: numeric('draft_m'),
widthIsMinimum: boolean('width_is_minimum').default(false), widthIsMinimum: boolean('width_is_minimum').default(false),
nominalBoatSize: text('nominal_boat_size'), // Numeric: ft (legacy NocoDB stored as plain numbers, no units in value).
nominalBoatSizeM: text('nominal_boat_size_m'), nominalBoatSize: numeric('nominal_boat_size'),
nominalBoatSizeM: numeric('nominal_boat_size_m'),
waterDepth: numeric('water_depth'), waterDepth: numeric('water_depth'),
waterDepthM: numeric('water_depth_m'), waterDepthM: numeric('water_depth_m'),
waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false), waterDepthIsMinimum: boolean('water_depth_is_minimum').default(false),
sidePontoon: text('side_pontoon'), sidePontoon: text('side_pontoon'),
powerCapacity: text('power_capacity'), powerCapacity: numeric('power_capacity'), // kW
voltage: text('voltage'), voltage: numeric('voltage'), // V at 60Hz
mooringType: text('mooring_type'), mooringType: text('mooring_type'),
cleatType: text('cleat_type'), cleatType: text('cleat_type'),
cleatCapacity: text('cleat_capacity'), cleatCapacity: text('cleat_capacity'),
@@ -58,6 +59,9 @@ export const berths = pgTable(
statusLastChangedBy: text('status_last_changed_by'), // user ID statusLastChangedBy: text('status_last_changed_by'), // user ID
statusLastChangedReason: text('status_last_changed_reason'), statusLastChangedReason: text('status_last_changed_reason'),
statusLastModified: timestamp('status_last_modified', { withTimezone: true }), statusLastModified: timestamp('status_last_modified', { withTimezone: true }),
// Optional override flag carried over from NocoDB ("auto" or null in legacy data).
// Reserved for future "manual override" semantics; not surfaced in the UI today.
statusOverrideMode: text('status_override_mode'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, },

View File

@@ -4,7 +4,13 @@
* Exports `seedPortData(portId, portSlug)` — creates a realistic, * Exports `seedPortData(portId, portSlug)` — creates a realistic,
* multi-cardinality data fixture for one port: * multi-cardinality data fixture for one port:
* *
* - 12 berths (5 available / 5 reserved-active / 2 sold) * - 117 berths imported from a snapshot of the legacy NocoDB Berths
* table (`src/lib/db/seed-data/berths.json`). The snapshot is reordered
* so the first 12 entries satisfy the index assumptions used further
* down for interest/reservation linkage:
* idx 0..4 — available (small)
* idx 5..9 — under_offer (medium)
* idx 10..11 — sold (large)
* - 3 companies (2 active, 1 dissolved) with primary billing addresses * - 3 companies (2 active, 1 dissolved) with primary billing addresses
* - 8 clients + contacts + primary addresses * - 8 clients + contacts + primary addresses
* - Memberships tying clients to companies (incl. multi-company + ended) * - Memberships tying clients to companies (incl. multi-company + ended)
@@ -39,6 +45,44 @@ import {
getStandardEoiTemplateHtml, getStandardEoiTemplateHtml,
STANDARD_EOI_MERGE_FIELDS, STANDARD_EOI_MERGE_FIELDS,
} from '@/lib/pdf/templates/eoi-standard-inapp'; } from '@/lib/pdf/templates/eoi-standard-inapp';
import berthSnapshot from './seed-data/berths.json';
// ─── Berth snapshot ──────────────────────────────────────────────────────────
// 117 rows imported from the legacy NocoDB Berths table on 2026-05-03.
// Refresh by re-running the snapshot script (see git history of this file).
type SeedBerth = {
legacyId: number;
mooringNumber: string;
legacyMooringNumber: string;
area: string | null;
status: 'available' | 'under_offer' | 'sold';
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
lengthM: number | null;
widthM: number | null;
draftM: number | null;
widthIsMinimum: boolean;
nominalBoatSize: number | null;
nominalBoatSizeM: number | null;
waterDepth: number | null;
waterDepthM: number | null;
waterDepthIsMinimum: boolean;
sidePontoon: string | null;
powerCapacity: number | null;
voltage: number | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: number | null;
bowFacing: string | null;
berthApproved: boolean;
statusOverrideMode: string | null;
};
const BERTH_SNAPSHOT = berthSnapshot as SeedBerth[];
// ─── Tunables ──────────────────────────────────────────────────────────────── // ─── Tunables ────────────────────────────────────────────────────────────────
@@ -77,144 +121,44 @@ export async function seedPortData(portId: string, portSlug: string): Promise<Se
return withTransaction(async (tx) => { return withTransaction(async (tx) => {
// ── 1. Berths ────────────────────────────────────────────────────────── // ── 1. Berths ──────────────────────────────────────────────────────────
// 12 berths: [0..4] available, [5..9] will be reserved-active, [10..11] sold. // 117 berths seeded from the legacy NocoDB Berths snapshot.
// We mark 5..9 as 'under_offer' (closest to "reserved via active reservation") // The JSON file is pre-sorted so the first 12 indexes satisfy the
// and 10..11 as 'sold'; 0..4 remain 'available'. // status semantics expected by the interest/reservation seeds:
const BERTH_SPECS: Array<{ // idx 0..4 available, idx 5..9 under_offer, idx 10..11 sold.
mooring: string;
area: string;
lengthM: string;
widthM: string;
draftM: string;
price: string;
status: 'available' | 'under_offer' | 'sold';
}> = [
{
mooring: 'A-01',
area: 'North Pier',
lengthM: '15',
widthM: '5',
draftM: '2.5',
price: '250000',
status: 'available',
},
{
mooring: 'A-02',
area: 'North Pier',
lengthM: '18',
widthM: '5.5',
draftM: '2.8',
price: '320000',
status: 'available',
},
{
mooring: 'A-03',
area: 'North Pier',
lengthM: '20',
widthM: '6',
draftM: '3.0',
price: '420000',
status: 'available',
},
{
mooring: 'B-01',
area: 'Central Basin',
lengthM: '25',
widthM: '7',
draftM: '3.5',
price: '580000',
status: 'available',
},
{
mooring: 'B-02',
area: 'Central Basin',
lengthM: '30',
widthM: '8',
draftM: '4.0',
price: '780000',
status: 'available',
},
{
mooring: 'B-03',
area: 'Central Basin',
lengthM: '35',
widthM: '8.5',
draftM: '4.2',
price: '950000',
status: 'under_offer',
},
{
mooring: 'C-01',
area: 'South Marina',
lengthM: '40',
widthM: '9',
draftM: '4.5',
price: '1250000',
status: 'under_offer',
},
{
mooring: 'C-02',
area: 'South Marina',
lengthM: '45',
widthM: '10',
draftM: '4.8',
price: '1600000',
status: 'under_offer',
},
{
mooring: 'C-03',
area: 'South Marina',
lengthM: '50',
widthM: '11',
draftM: '5.0',
price: '2100000',
status: 'under_offer',
},
{
mooring: 'D-01',
area: 'Superyacht Dock',
lengthM: '60',
widthM: '13',
draftM: '5.5',
price: '3200000',
status: 'under_offer',
},
{
mooring: 'D-02',
area: 'Superyacht Dock',
lengthM: '70',
widthM: '14',
draftM: '6.0',
price: '4500000',
status: 'sold',
},
{
mooring: 'D-03',
area: 'Superyacht Dock',
lengthM: '80',
widthM: '15',
draftM: '6.5',
price: '6800000',
status: 'sold',
},
];
const berthRows = await tx const berthRows = await tx
.insert(berths) .insert(berths)
.values( .values(
BERTH_SPECS.map((b) => ({ BERTH_SNAPSHOT.map((b) => ({
portId, portId,
mooringNumber: b.mooring, mooringNumber: b.mooringNumber,
area: b.area, area: b.area,
status: b.status, status: b.status,
lengthM: b.lengthM, lengthFt: b.lengthFt != null ? String(b.lengthFt) : null,
widthM: b.widthM, widthFt: b.widthFt != null ? String(b.widthFt) : null,
draftM: b.draftM, draftFt: b.draftFt != null ? String(b.draftFt) : null,
lengthFt: (Number(b.lengthM) * 3.28084).toFixed(2), lengthM: b.lengthM != null ? String(b.lengthM) : null,
widthFt: (Number(b.widthM) * 3.28084).toFixed(2), widthM: b.widthM != null ? String(b.widthM) : null,
draftFt: (Number(b.draftM) * 3.28084).toFixed(2), draftM: b.draftM != null ? String(b.draftM) : null,
price: b.price, widthIsMinimum: b.widthIsMinimum,
nominalBoatSize: b.nominalBoatSize != null ? String(b.nominalBoatSize) : null,
nominalBoatSizeM: b.nominalBoatSizeM != null ? String(b.nominalBoatSizeM) : null,
waterDepth: b.waterDepth != null ? String(b.waterDepth) : null,
waterDepthM: b.waterDepthM != null ? String(b.waterDepthM) : null,
waterDepthIsMinimum: b.waterDepthIsMinimum,
sidePontoon: b.sidePontoon,
powerCapacity: b.powerCapacity != null ? String(b.powerCapacity) : null,
voltage: b.voltage != null ? String(b.voltage) : null,
mooringType: b.mooringType,
cleatType: b.cleatType,
cleatCapacity: b.cleatCapacity,
bollardType: b.bollardType,
bollardCapacity: b.bollardCapacity,
access: b.access,
price: b.price != null ? String(b.price) : null,
priceCurrency: 'USD', priceCurrency: 'USD',
bowFacing: b.bowFacing,
berthApproved: b.berthApproved,
statusOverrideMode: b.statusOverrideMode,
tenureType: 'permanent' as const, tenureType: 'permanent' as const,
})), })),
) )

File diff suppressed because it is too large Load Diff

View File

@@ -2,16 +2,16 @@
* Seed script for Port Nimara CRM. * Seed script for Port Nimara CRM.
* *
* Top-level orchestrator: * Top-level orchestrator:
* 1. Create 3 ports (idempotent): * 1. Create the operational ports (idempotent):
* - Port Nimara * - Port Nimara (primary install — the real marina)
* - Marina Azzurra * - Port Amador (secondary, kept for multi-tenant isolation tests
* - Harbor Royale * and as scaffolding for a future Panama install)
* 2. Create 5 system roles with full permission maps * 2. Create 5 system roles with full permission maps
* 3. Create the super admin user profile placeholder (matt@portnimara.com) * 3. Create the super admin user profile placeholder (matt@portnimara.com)
* 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts * 4. For each port, call `seedPortData(portId, portSlug)` from seed-data.ts
* to produce the realistic multi-cardinality fixture * to produce the realistic multi-cardinality fixture
* (berths, clients, companies, yachts, memberships, interests, * (117 berths from the NocoDB snapshot, plus clients, companies, yachts,
* reservations, ownership-transfer history). * memberships, interests, reservations, ownership-transfer history).
* 5. Print a summary. * 5. Print a summary.
* *
* Run with: pnpm db:seed * Run with: pnpm db:seed
@@ -186,7 +186,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -260,7 +260,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = {
generate_eoi: true, generate_eoi: true,
export: true, export: true,
}, },
berths: { view: true, edit: false, import: false, manage_waiting_list: true }, berths: { view: true, edit: true, import: false, manage_waiting_list: true },
documents: { documents: {
view: true, view: true,
create: true, create: true,
@@ -413,19 +413,15 @@ const PORT_DEFINITIONS: Array<{
defaultCurrency: 'USD', defaultCurrency: 'USD',
timezone: 'America/Anguilla', timezone: 'America/Anguilla',
}, },
// Second port kept for multi-tenant isolation tests (cross-port scoping,
// permission boundaries). Drop or rename if the production install is
// single-port.
{ {
name: 'Marina Azzurra', name: 'Port Amador',
slug: 'marina-azzurra', slug: 'port-amador',
primaryColor: '#2E86AB', primaryColor: '#D97706',
defaultCurrency: 'EUR', defaultCurrency: 'USD',
timezone: 'Europe/Rome', timezone: 'America/Panama',
},
{
name: 'Harbor Royale',
slug: 'harbor-royale',
primaryColor: '#8B1E3F',
defaultCurrency: 'GBP',
timezone: 'Europe/London',
}, },
]; ];

View File

@@ -0,0 +1,362 @@
/**
* Apply phase for the legacy NocoDB → CRM migration. Walks a
* `MigrationPlan` produced by {@link transformSnapshot} and writes
* the new client / contact / address / yacht / interest rows into the
* target port.
*
* Idempotent: every insert is guarded by a `migration_source_links`
* lookup keyed on `(source_system, source_id, target_entity_type)`, so
* a partial failure can be resumed by re-running the script. Re-runs
* against an already-applied plan are a near-no-op.
*
* Per-entity transactions (not one giant transaction) — the design
* favours visible partial progress on failure over all-or-nothing.
*
* @see src/lib/dedup/migration-transform.ts for the input shape.
* @see src/lib/db/schema/migration.ts for the idempotency ledger.
*/
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { yachts } from '@/lib/db/schema/yachts';
import { berths } from '@/lib/db/schema/berths';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
import type { MigrationPlan, PlannedClient, PlannedInterest } from './migration-transform';
const SOURCE_SYSTEM = 'nocodb_interests';
/**
* Convert a legacy bare mooring string like "D32" / "A1" / "E18" to the
* dashed/padded form "D-32" / "A-01" / "E-18" used by the new berths
* schema. If the input doesn't match the bare pattern, returns it
* unchanged so a literal lookup can still hit (handles the case where
* the legacy data already has the dashed form).
*
* Multi-mooring strings ("A3, D30") return the original string —
* those need human review and we don't want to silently pick one half.
*/
function normalizeLegacyMooring(raw: string): string {
// Bare letter+digits, e.g. "D32"
const m = /^([A-E])(\d{1,3})$/i.exec(raw.trim());
if (!m) return raw;
const letter = m[1]!.toUpperCase();
const num = parseInt(m[2]!, 10);
return `${letter}-${num.toString().padStart(2, '0')}`;
}
export interface ApplyResult {
applyId: string;
clientsInserted: number;
clientsSkipped: number;
contactsInserted: number;
addressesInserted: number;
yachtsInserted: number;
interestsInserted: number;
interestsSkipped: number;
warnings: string[];
}
export interface ApplyOptions {
port: { id: string; slug: string };
applyId: string;
/** Set to true for the "preview the writes" mode — runs every read but
* rolls back inserts. Useful for verifying mappings before committing. */
rehearsal?: boolean;
appliedBy?: string;
}
/**
* Look up an existing migration link for a (sourceId, targetType) pair.
* Returns the existing target entity id if already linked.
*/
async function resolveExistingLink(
sourceId: number,
targetEntityType: 'client' | 'interest' | 'yacht' | 'address',
): Promise<string | null> {
const rows = await db
.select({ id: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, SOURCE_SYSTEM),
eq(migrationSourceLinks.sourceId, String(sourceId)),
eq(migrationSourceLinks.targetEntityType, targetEntityType),
),
)
.limit(1);
return rows[0]?.id ?? null;
}
/** Find the first sourceId in a cluster that's already linked to a client,
* if any. The cluster might be larger than the previously-applied set if
* the dedup algorithm collapsed an extra duplicate this run. */
async function resolveExistingClusterClient(sourceIds: number[]): Promise<string | null> {
if (sourceIds.length === 0) return null;
const rows = await db
.select({ id: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, SOURCE_SYSTEM),
inArray(migrationSourceLinks.sourceId, sourceIds.map(String)),
eq(migrationSourceLinks.targetEntityType, 'client'),
),
)
.limit(1);
return rows[0]?.id ?? null;
}
/** Apply a single PlannedClient — returns `{clientId, inserted}` so the
* caller can wire interests against the (possibly pre-existing) record. */
async function applyClient(
planned: PlannedClient,
opts: ApplyOptions,
result: ApplyResult,
): Promise<{ clientId: string; inserted: boolean }> {
// Idempotency: if any source row in the cluster already mapped to a client,
// reuse that record.
const existing = await resolveExistingClusterClient(planned.sourceIds);
if (existing) {
result.clientsSkipped += 1;
return { clientId: existing, inserted: false };
}
if (opts.rehearsal) {
// Simulate an insert without writing — used for the preview path.
return { clientId: `rehearsal-${planned.tempId}`, inserted: true };
}
// surnameToken is on the planned object (used by the dedup blocking
// index inside the transform) but not in the clients schema — runtime
// dedup re-derives it from fullName when needed. Drop it on insert.
const [inserted] = await db
.insert(clients)
.values({
portId: opts.port.id,
fullName: planned.fullName,
nationalityIso: planned.countryIso ?? null,
preferredContactMethod: planned.preferredContactMethod ?? null,
source: planned.source ?? null,
})
.returning({ id: clients.id });
if (!inserted) throw new Error('Client insert returned no row');
const clientId = inserted.id;
// Record idempotency links — one per source row in the cluster.
await db.insert(migrationSourceLinks).values(
planned.sourceIds.map((sid) => ({
sourceSystem: SOURCE_SYSTEM,
sourceId: String(sid),
targetEntityType: 'client' as const,
targetEntityId: clientId,
appliedId: opts.applyId,
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
})),
);
// Contacts: bulk insert; mark first email + first phone as primary.
if (planned.contacts.length > 0) {
let primaryEmailSet = false;
let primaryPhoneSet = false;
const contactRows = planned.contacts.map((ct) => {
let isPrimary = false;
if (ct.isPrimary) {
if (ct.channel === 'email' && !primaryEmailSet) {
isPrimary = true;
primaryEmailSet = true;
} else if ((ct.channel === 'phone' || ct.channel === 'whatsapp') && !primaryPhoneSet) {
isPrimary = true;
primaryPhoneSet = true;
}
}
return {
clientId,
channel: ct.channel,
value: ct.value,
valueE164: ct.valueE164 ?? null,
valueCountry: ct.valueCountry ?? null,
isPrimary,
};
});
await db.insert(clientContacts).values(contactRows);
result.contactsInserted += contactRows.length;
}
// Addresses: bulk insert; first is marked primary if multiple. Note the
// schema requires portId on every address row in addition to clientId.
if (planned.addresses.length > 0) {
const addressRows = planned.addresses.map((a, idx) => ({
clientId,
portId: opts.port.id,
streetAddress: a.streetAddress ?? null,
city: a.city ?? null,
countryIso: a.countryIso ?? null,
isPrimary: idx === 0,
}));
await db.insert(clientAddresses).values(addressRows);
result.addressesInserted += addressRows.length;
}
result.clientsInserted += 1;
return { clientId, inserted: true };
}
/** Apply a single PlannedInterest — looks up its client + berth + yacht and
* inserts the interest row, plus a yacht stub if a yacht name is present. */
async function applyInterest(
planned: PlannedInterest,
tempIdToClientId: Map<string, string>,
mooringToBerthId: Map<string, string>,
opts: ApplyOptions,
result: ApplyResult,
): Promise<void> {
// Idempotency: skip if this source row already created an interest.
const existing = await resolveExistingLink(planned.sourceId, 'interest');
if (existing) {
result.interestsSkipped += 1;
return;
}
const clientId = tempIdToClientId.get(planned.clientTempId);
if (!clientId) {
result.warnings.push(
`Interest source=${planned.sourceId} references unknown client tempId=${planned.clientTempId} — skipped`,
);
return;
}
let berthId: string | null = null;
if (planned.berthMooringNumber) {
berthId =
mooringToBerthId.get(planned.berthMooringNumber) ??
// The legacy NocoDB Interests table uses bare mooring strings like
// "D32", "B16", whereas the new berths schema (mirroring the NocoDB
// Berths snapshot) uses zero-padded "D-32", "B-16". Try the dashed
// form as a fallback so legacy references resolve correctly.
mooringToBerthId.get(normalizeLegacyMooring(planned.berthMooringNumber)) ??
null;
if (!berthId) {
result.warnings.push(
`Interest source=${planned.sourceId} references unknown mooring="${planned.berthMooringNumber}" — interest created without berth link`,
);
}
}
// Optional yacht stub: if the legacy row had a yacht name, create a
// minimal yacht record owned by the client. The new schema requires
// currentOwnerType + currentOwnerId.
let yachtId: string | null = null;
if (planned.yachtName) {
const existingYacht = await resolveExistingLink(planned.sourceId, 'yacht');
if (existingYacht) {
yachtId = existingYacht;
} else if (!opts.rehearsal) {
const [y] = await db
.insert(yachts)
.values({
portId: opts.port.id,
name: planned.yachtName,
currentOwnerType: 'client',
currentOwnerId: clientId,
status: 'active',
})
.returning({ id: yachts.id });
if (y) {
yachtId = y.id;
await db.insert(migrationSourceLinks).values({
sourceSystem: SOURCE_SYSTEM,
sourceId: String(planned.sourceId),
targetEntityType: 'yacht' as const,
targetEntityId: y.id,
appliedId: opts.applyId,
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
});
result.yachtsInserted += 1;
}
}
}
if (opts.rehearsal) {
result.interestsInserted += 1;
return;
}
const [iRow] = await db
.insert(interests)
.values({
portId: opts.port.id,
clientId,
berthId,
yachtId,
pipelineStage: planned.pipelineStage,
leadCategory: planned.leadCategory,
source: planned.source,
notes: planned.notes,
documensoId: planned.documensoId,
dateEoiSent: planned.dateEoiSent ? new Date(planned.dateEoiSent) : null,
dateEoiSigned: planned.dateEoiSigned ? new Date(planned.dateEoiSigned) : null,
dateContractSent: planned.dateContractSent ? new Date(planned.dateContractSent) : null,
dateContractSigned: planned.dateContractSigned ? new Date(planned.dateContractSigned) : null,
dateDepositReceived: planned.dateDepositReceived
? new Date(planned.dateDepositReceived)
: null,
dateLastContact: planned.dateLastContact ? new Date(planned.dateLastContact) : null,
})
.returning({ id: interests.id });
if (!iRow) throw new Error('Interest insert returned no row');
await db.insert(migrationSourceLinks).values({
sourceSystem: SOURCE_SYSTEM,
sourceId: String(planned.sourceId),
targetEntityType: 'interest' as const,
targetEntityId: iRow.id,
appliedId: opts.applyId,
...(opts.appliedBy ? { appliedBy: opts.appliedBy } : {}),
});
result.interestsInserted += 1;
}
/**
* Top-level apply driver. Walks the plan once, building the
* tempId→clientId map as it goes, then walks interests with that map.
*/
export async function applyPlan(plan: MigrationPlan, opts: ApplyOptions): Promise<ApplyResult> {
const result: ApplyResult = {
applyId: opts.applyId,
clientsInserted: 0,
clientsSkipped: 0,
contactsInserted: 0,
addressesInserted: 0,
yachtsInserted: 0,
interestsInserted: 0,
interestsSkipped: 0,
warnings: [],
};
// 1. Clients (and their contacts/addresses)
const tempIdToClientId = new Map<string, string>();
for (const planned of plan.clients) {
const { clientId } = await applyClient(planned, opts, result);
tempIdToClientId.set(planned.tempId, clientId);
}
// 2. Build mooring→berthId lookup once, scoped to this port.
const berthRows = await db
.select({ id: berths.id, mooringNumber: berths.mooringNumber })
.from(berths)
.where(eq(berths.portId, opts.port.id));
const mooringToBerthId = new Map(berthRows.map((b) => [b.mooringNumber, b.id]));
// 3. Interests (and yacht stubs)
for (const planned of plan.interests) {
await applyInterest(planned, tempIdToClientId, mooringToBerthId, opts, result);
}
return result;
}

View File

@@ -58,10 +58,11 @@ export function buildPipelineInputs(
'open', 'open',
'details_sent', 'details_sent',
'in_communication', 'in_communication',
'visited', 'eoi_sent',
'signed_eoi_nda', 'eoi_signed',
'deposit_10pct', 'deposit_10pct',
'contract', 'contract_sent',
'contract_signed',
'completed', 'completed',
]; ];
@@ -73,9 +74,7 @@ export function buildPipelineInputs(
}); });
// Include stages not in standard order // Include stages not in standard order
const unknownStages = Object.keys(data.stageCounts).filter( const unknownStages = Object.keys(data.stageCounts).filter((s) => !stageOrder.includes(s));
(s) => !stageOrder.includes(s),
);
for (const stage of unknownStages) { for (const stage of unknownStages) {
summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`); summaryLines.push(`${stage}: ${data.stageCounts[stage]} interest(s)`);
} }

View File

@@ -50,18 +50,16 @@ export const revenueReportTemplate: Template = {
], ],
}; };
export function buildRevenueInputs( export function buildRevenueInputs(data: RevenueData, portName?: string): Record<string, string>[] {
data: RevenueData,
portName?: string,
): Record<string, string>[] {
const stageOrder = [ const stageOrder = [
'open', 'open',
'details_sent', 'details_sent',
'in_communication', 'in_communication',
'visited', 'eoi_sent',
'signed_eoi_nda', 'eoi_signed',
'deposit_10pct', 'deposit_10pct',
'contract', 'contract_sent',
'contract_signed',
'completed', 'completed',
]; ];

View File

@@ -84,6 +84,28 @@ export const webhooksWorker = new Worker(
return; return;
} }
// Safety net: when EMAIL_REDIRECT_TO is set (dev / staging / migration
// dry-run), short-circuit webhook delivery so we don't accidentally
// ping a user-configured production endpoint with synthetic events.
// Records the delivery as `dead_letter` with a clear reason so the
// attempt is still visible in the deliveries listing.
if (process.env.EMAIL_REDIRECT_TO) {
logger.info(
{ webhookId, deliveryId, url: webhook.url },
'Webhook delivery skipped (EMAIL_REDIRECT_TO is set — outbound comms are paused)',
);
await db
.update(webhookDeliveries)
.set({
status: 'dead_letter',
responseStatus: null,
responseBody: 'Skipped: EMAIL_REDIRECT_TO is set, outbound comms paused.',
deliveredAt: new Date(),
})
.where(eq(webhookDeliveries.id, deliveryId));
return;
}
// 2. Decrypt secret // 2. Decrypt secret
let secret: string; let secret: string;
try { try {

View File

@@ -180,14 +180,14 @@ export async function updateBerth(
draftFt: n(data.draftFt), draftFt: n(data.draftFt),
draftM: n(data.draftM), draftM: n(data.draftM),
widthIsMinimum: data.widthIsMinimum, widthIsMinimum: data.widthIsMinimum,
nominalBoatSize: data.nominalBoatSize, nominalBoatSize: n(data.nominalBoatSize),
nominalBoatSizeM: data.nominalBoatSizeM, nominalBoatSizeM: n(data.nominalBoatSizeM),
waterDepth: n(data.waterDepth), waterDepth: n(data.waterDepth),
waterDepthM: n(data.waterDepthM), waterDepthM: n(data.waterDepthM),
waterDepthIsMinimum: data.waterDepthIsMinimum, waterDepthIsMinimum: data.waterDepthIsMinimum,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,
powerCapacity: data.powerCapacity, powerCapacity: n(data.powerCapacity),
voltage: data.voltage, voltage: n(data.voltage),
mooringType: data.mooringType, mooringType: data.mooringType,
cleatType: data.cleatType, cleatType: data.cleatType,
cleatCapacity: data.cleatCapacity, cleatCapacity: data.cleatCapacity,
@@ -481,8 +481,8 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
priceCurrency: data.priceCurrency ?? 'USD', priceCurrency: data.priceCurrency ?? 'USD',
tenureType: data.tenureType ?? 'permanent', tenureType: data.tenureType ?? 'permanent',
mooringType: data.mooringType, mooringType: data.mooringType,
powerCapacity: data.powerCapacity, powerCapacity: data.powerCapacity?.toString(),
voltage: data.voltage, voltage: data.voltage?.toString(),
access: data.access, access: data.access,
bowFacing: data.bowFacing, bowFacing: data.bowFacing,
sidePontoon: data.sidePontoon, sidePontoon: data.sidePontoon,

View File

@@ -1,9 +1,10 @@
import { and, count, eq, ilike, inArray, isNull } from 'drizzle-orm'; import { and, count, desc, eq, ilike, inArray, isNull } from 'drizzle-orm';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { import {
clients, clients,
clientContacts, clientContacts,
clientNotes,
clientRelationships, clientRelationships,
clientTags, clientTags,
clientAddresses, clientAddresses,
@@ -11,6 +12,8 @@ import {
import { companies, companyMemberships } from '@/lib/db/schema/companies'; import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts'; import { yachts } from '@/lib/db/schema/yachts';
import { berthReservations } from '@/lib/db/schema/reservations'; import { berthReservations } from '@/lib/db/schema/reservations';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { tags } from '@/lib/db/schema/system'; import { tags } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors'; import { NotFoundError, ValidationError } from '@/lib/errors';
@@ -81,7 +84,7 @@ export async function listClients(portId: string, query: ListClientsInput) {
const ids = result.data.map((r) => r.id); const ids = result.data.map((r) => r.id);
const [yachtCounts, companyCounts] = await Promise.all([ const [yachtCounts, companyCounts, interestRows, interestCounts] = await Promise.all([
db db
.select({ ownerId: yachts.currentOwnerId, count: count() }) .select({ ownerId: yachts.currentOwnerId, count: count() })
.from(yachts) .from(yachts)
@@ -99,18 +102,67 @@ export async function listClients(portId: string, query: ListClientsInput) {
.from(companyMemberships) .from(companyMemberships)
.where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate))) .where(and(inArray(companyMemberships.clientId, ids), isNull(companyMemberships.endDate)))
.groupBy(companyMemberships.clientId), .groupBy(companyMemberships.clientId),
db
.select({
clientId: interests.clientId,
pipelineStage: interests.pipelineStage,
updatedAt: interests.updatedAt,
mooringNumber: berths.mooringNumber,
})
.from(interests)
.leftJoin(berths, eq(berths.id, interests.berthId))
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.orderBy(desc(interests.updatedAt)),
db
.select({ clientId: interests.clientId, count: count() })
.from(interests)
.where(
and(
eq(interests.portId, portId),
inArray(interests.clientId, ids),
isNull(interests.archivedAt),
),
)
.groupBy(interests.clientId),
]); ]);
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count])); const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count])); const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
const interestCountMap = new Map(interestCounts.map((r) => [r.clientId, r.count]));
// interestRows is sorted desc by updatedAt; first hit per clientId is the latest.
const latestInterestMap = new Map<string, { stage: string; mooringNumber: string | null }>();
for (const row of interestRows) {
if (!latestInterestMap.has(row.clientId)) {
latestInterestMap.set(row.clientId, {
stage: row.pipelineStage,
mooringNumber: row.mooringNumber,
});
}
}
return { return {
...result, ...result,
data: result.data.map((row) => ({ data: result.data.map((row) => {
const latest = latestInterestMap.get(row.id);
return {
...row, ...row,
yachtCount: yachtCountMap.get(row.id) ?? 0, yachtCount: yachtCountMap.get(row.id) ?? 0,
companyCount: companyCountMap.get(row.id) ?? 0, companyCount: companyCountMap.get(row.id) ?? 0,
})), interestCount: interestCountMap.get(row.id) ?? 0,
latestInterest: latest
? {
stage: latest.stage,
mooringNumber: latest.mooringNumber,
}
: null,
};
}),
}; };
} }
@@ -200,6 +252,19 @@ export async function getClientById(id: string, portId: string) {
const portalEnabled = await isPortalEnabledForPort(portId); const portalEnabled = await isPortalEnabledForPort(portId);
// Counts surfaced for tab badges (Interests + Notes — Yachts/Companies/etc
// get their counts from the corresponding row arrays we already fetched).
const [interestCountRow] = await db
.select({ count: count() })
.from(interests)
.where(
and(eq(interests.portId, portId), eq(interests.clientId, id), isNull(interests.archivedAt)),
);
const [noteCountRow] = await db
.select({ count: count() })
.from(clientNotes)
.where(eq(clientNotes.clientId, id));
return { return {
...client, ...client,
contacts, contacts,
@@ -208,6 +273,8 @@ export async function getClientById(id: string, portId: string) {
yachts: yachtRows, yachts: yachtRows,
companies: membershipRows, companies: membershipRows,
activeReservations, activeReservations,
interestCount: interestCountRow?.count ?? 0,
noteCount: noteCountRow?.count ?? 0,
clientPortalEnabled: portalEnabled, clientPortalEnabled: portalEnabled,
}; };
} }

View File

@@ -87,17 +87,72 @@ export interface DocumensoDocument {
}>; }>;
} }
/**
* When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient
* email so Documenso doesn't accidentally email real clients during a
* data import / migration dry-run. Names are prefixed with the original
* email so the recipient (you) can tell who would have received the doc.
*
* In production this env var is unset and recipients flow through unchanged.
*/
function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] {
if (!env.EMAIL_REDIRECT_TO) return recipients;
return recipients.map((r) => ({
...r,
name: `${r.name} (was: ${r.email})`,
email: env.EMAIL_REDIRECT_TO!,
}));
}
/**
* Same idea for the template-generate endpoint, which takes a payload
* shape with recipient email/name nested inside `formValues` (Documenso
* v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes.
*/
function applyPayloadRedirect(payload: Record<string, unknown>): Record<string, unknown> {
if (!env.EMAIL_REDIRECT_TO) return payload;
const out: Record<string, unknown> = { ...payload };
// 2.x recipient shape
if (Array.isArray(out.recipients)) {
out.recipients = (out.recipients as Array<Record<string, unknown>>).map((r) => ({
...r,
name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`,
email: env.EMAIL_REDIRECT_TO,
}));
}
// v1.13 formValues shape — keys vary per template; key by anything that
// looks like an email field. The conservative approach: only touch keys
// that already hold a string and end with `Email` / `email`.
if (out.formValues && typeof out.formValues === 'object') {
const fv = { ...(out.formValues as Record<string, unknown>) };
for (const key of Object.keys(fv)) {
if (/email$/i.test(key) && typeof fv[key] === 'string') {
fv[key] = env.EMAIL_REDIRECT_TO;
}
}
out.formValues = fv;
}
return out;
}
export async function createDocument( export async function createDocument(
title: string, title: string,
pdfBase64: string, pdfBase64: string,
recipients: DocumensoRecipient[], recipients: DocumensoRecipient[],
portId?: string, portId?: string,
): Promise<DocumensoDocument> { ): Promise<DocumensoDocument> {
const safeRecipients = applyRecipientRedirect(recipients);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ redirected: safeRecipients.length, original: recipients.map((r) => r.email) },
'Documenso recipients redirected to EMAIL_REDIRECT_TO',
);
}
return documensoFetch( return documensoFetch(
'/api/v1/documents', '/api/v1/documents',
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, document: pdfBase64, recipients }), body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients }),
}, },
portId, portId,
).then(normalizeDocument); ).then(normalizeDocument);
@@ -108,17 +163,43 @@ export async function generateDocumentFromTemplate(
payload: Record<string, unknown>, payload: Record<string, unknown>,
portId?: string, portId?: string,
): Promise<DocumensoDocument> { ): Promise<DocumensoDocument> {
const safePayload = applyPayloadRedirect(payload);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ templateId },
'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO',
);
}
return documensoFetch( return documensoFetch(
`/api/v1/templates/${templateId}/generate-document`, `/api/v1/templates/${templateId}/generate-document`,
{ {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(safePayload),
}, },
portId, portId,
).then(normalizeDocument); ).then(normalizeDocument);
} }
/**
* Tell Documenso to actually email the document to its recipients. The
* recipients themselves are set at create-time (and rerouted to
* EMAIL_REDIRECT_TO when set), but this is a belt-and-braces guard for
* documents that may have been created BEFORE the redirect was turned on
* (i.e. real-recipient documents now triggered by an automation while
* we're trying to hold comms). When the redirect is on we skip the API
* call entirely and return a synthetic "still pending" response.
*/
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> { export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ docId, portId, redirect: env.EMAIL_REDIRECT_TO },
'sendDocument SKIPPED — EMAIL_REDIRECT_TO is set, outbound comms paused',
);
// Return the existing doc shape so downstream code doesn't see an
// unexpected null. The document remains in DRAFT/PENDING from
// Documenso's perspective.
return getDocument(docId, portId);
}
return documensoFetch( return documensoFetch(
`/api/v1/documents/${docId}/send`, `/api/v1/documents/${docId}/send`,
{ {
@@ -132,11 +213,23 @@ export async function getDocument(docId: string, portId?: string): Promise<Docum
return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument); return documensoFetch(`/api/v1/documents/${docId}`, undefined, portId).then(normalizeDocument);
} }
/**
* Email a signing reminder to one recipient. Skipped entirely when
* EMAIL_REDIRECT_TO is set — the recipient's stored email may still be
* a real client address from before the redirect was enabled.
*/
export async function sendReminder( export async function sendReminder(
docId: string, docId: string,
signerId: string, signerId: string,
portId?: string, portId?: string,
): Promise<void> { ): Promise<void> {
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
{ docId, signerId, portId, redirect: env.EMAIL_REDIRECT_TO },
'sendReminder SKIPPED — EMAIL_REDIRECT_TO is set, outbound comms paused',
);
return;
}
await documensoFetch( await documensoFetch(
`/api/v1/documents/${docId}/recipients/${signerId}/remind`, `/api/v1/documents/${docId}/recipients/${signerId}/remind`,
{ {

View File

@@ -5,7 +5,9 @@ import { db } from '@/lib/db';
import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email'; import { emailAccounts, emailMessages, emailThreads } from '@/lib/db/schema/email';
import { documents, documentEvents, files } from '@/lib/db/schema/documents'; import { documents, documentEvents, files } from '@/lib/db/schema/documents';
import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { env } from '@/lib/env';
import { NotFoundError, ForbiddenError } from '@/lib/errors'; import { NotFoundError, ForbiddenError } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { getDecryptedCredentials } from '@/lib/services/email-accounts.service'; import { getDecryptedCredentials } from '@/lib/services/email-accounts.service';
import { getPortEmailConfig } from '@/lib/services/port-config'; import { getPortEmailConfig } from '@/lib/services/port-config';
import { sendEmail as sendSystemEmail } from '@/lib/email'; import { sendEmail as sendSystemEmail } from '@/lib/email';
@@ -127,12 +129,38 @@ export async function sendEmail(
) )
: undefined; : undefined;
// Safety net: when EMAIL_REDIRECT_TO is set, every recipient is rerouted
// to that address and the subject is prefixed so the operator can see
// who would have received the message. This service builds its OWN
// transporter (per-account SMTP) so it doesn't go through sendEmail's
// redirect — we apply the same logic here.
const requestedTo = data.to.join(', ');
const requestedCc = data.cc?.join(', ');
const effectiveTo = env.EMAIL_REDIRECT_TO ?? requestedTo;
const effectiveCc = env.EMAIL_REDIRECT_TO ? undefined : requestedCc;
const effectiveSubject = env.EMAIL_REDIRECT_TO
? `[redirected from ${requestedTo}${requestedCc ? `, cc=${requestedCc}` : ''}] ${data.subject}`
: data.subject;
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{
userId,
portId,
accountId: data.accountId,
originalTo: requestedTo,
originalCc: requestedCc ?? null,
redirectedTo: env.EMAIL_REDIRECT_TO,
},
'email-compose redirected to EMAIL_REDIRECT_TO',
);
}
// Send via the user's SMTP transporter // Send via the user's SMTP transporter
const info = await transporter.sendMail({ const info = await transporter.sendMail({
from: account.emailAddress, from: account.emailAddress,
to: data.to.join(', '), to: effectiveTo,
cc: data.cc?.join(', '), cc: effectiveCc,
subject: data.subject, subject: effectiveSubject,
html: data.bodyHtml, html: data.bodyHtml,
inReplyTo, inReplyTo,
references, references,

View File

@@ -18,8 +18,8 @@ export const createBerthSchema = z.object({
status: z.enum(BERTH_STATUSES).default('available'), status: z.enum(BERTH_STATUSES).default('available'),
tenureType: z.enum(['permanent', 'fixed_term']).optional(), tenureType: z.enum(['permanent', 'fixed_term']).optional(),
mooringType: z.string().optional(), mooringType: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
access: z.string().optional(), access: z.string().optional(),
bowFacing: z.string().optional(), bowFacing: z.string().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
@@ -38,14 +38,14 @@ export const updateBerthSchema = z.object({
draftFt: z.coerce.number().optional(), draftFt: z.coerce.number().optional(),
draftM: z.coerce.number().optional(), draftM: z.coerce.number().optional(),
widthIsMinimum: z.boolean().optional(), widthIsMinimum: z.boolean().optional(),
nominalBoatSize: z.string().optional(), nominalBoatSize: z.coerce.number().optional(), // ft
nominalBoatSizeM: z.string().optional(), nominalBoatSizeM: z.coerce.number().optional(), // m
waterDepth: z.coerce.number().optional(), waterDepth: z.coerce.number().optional(),
waterDepthM: z.coerce.number().optional(), waterDepthM: z.coerce.number().optional(),
waterDepthIsMinimum: z.boolean().optional(), waterDepthIsMinimum: z.boolean().optional(),
sidePontoon: z.string().optional(), sidePontoon: z.string().optional(),
powerCapacity: z.string().optional(), powerCapacity: z.coerce.number().optional(), // kW
voltage: z.string().optional(), voltage: z.coerce.number().optional(), // V at 60Hz
mooringType: z.string().optional(), mooringType: z.string().optional(),
cleatType: z.string().optional(), cleatType: z.string().optional(),
cleatCapacity: z.string().optional(), cleatCapacity: z.string().optional(),

View File

@@ -635,10 +635,11 @@ export function makeCreateInterestInput(overrides?: {
| 'open' | 'open'
| 'details_sent' | 'details_sent'
| 'in_communication' | 'in_communication'
| 'visited' | 'eoi_sent'
| 'signed_eoi_nda' | 'eoi_signed'
| 'deposit_10pct' | 'deposit_10pct'
| 'contract' | 'contract_sent'
| 'contract_signed'
| 'completed'; | 'completed';
}) { }) {
return { return {

View File

@@ -181,7 +181,7 @@ describe('alert engine', () => {
await db.insert(interests).values({ await db.insert(interests).values({
portId: port.id, portId: port.id,
clientId: client.id, clientId: client.id,
pipelineStage: 'visited', pipelineStage: 'in_communication',
leadCategory: 'hot_lead', leadCategory: 'hot_lead',
dateLastContact: stale, dateLastContact: stale,
updatedAt: stale, updatedAt: stale,

View File

@@ -170,7 +170,7 @@ describe('Pipeline Transitions', () => {
await import('@/lib/services/interests.service'); await import('@/lib/services/interests.service');
const meta = makeAuditMeta({ portId }); const meta = makeAuditMeta({ portId });
await changeInterestStage(interestId, portId, { pipelineStage: 'signed_eoi_nda' }, meta); await changeInterestStage(interestId, portId, { pipelineStage: 'eoi_signed' }, meta);
const updated = await getInterestById(interestId, portId); const updated = await getInterestById(interestId, portId);
expect(updated.dateEoiSigned).not.toBeNull(); expect(updated.dateEoiSigned).not.toBeNull();
@@ -181,7 +181,7 @@ describe('Pipeline Transitions', () => {
await import('@/lib/services/interests.service'); await import('@/lib/services/interests.service');
const meta = makeAuditMeta({ portId }); const meta = makeAuditMeta({ portId });
await changeInterestStage(interestId, portId, { pipelineStage: 'contract' }, meta); await changeInterestStage(interestId, portId, { pipelineStage: 'contract_signed' }, meta);
const updated = await getInterestById(interestId, portId); const updated = await getInterestById(interestId, portId);
expect(updated.dateContractSigned).not.toBeNull(); expect(updated.dateContractSigned).not.toBeNull();

View File

@@ -0,0 +1,247 @@
/**
* EMAIL_REDIRECT_TO safety net — comprehensive verification.
*
* Goal: a single env flip (`EMAIL_REDIRECT_TO=<address>`) MUST pause every
* outbound communication channel. This test file exercises each channel
* end-to-end with the env set, asserting the message is rerouted (or
* short-circuited) before it leaves the process.
*
* Lock these tests in: any new outbound channel added later should ALSO
* gain a check here. If a future PR breaks the redirect, this fails loud.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const REDIRECT_TARGET = 'redirect@example.test';
// -------------------------------------------------------------------------
// 1. Documenso recipient redirect (createDocument + generateDocumentFromTemplate)
// -------------------------------------------------------------------------
describe('Documenso recipient redirect — EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
const originalDocumensoUrl = process.env.DOCUMENSO_API_URL;
const originalDocumensoKey = process.env.DOCUMENSO_API_KEY;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
process.env.DOCUMENSO_API_URL = 'https://documenso.example.test';
process.env.DOCUMENSO_API_KEY = 'test-key';
fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
id: 'doc-1',
status: 'PENDING',
recipients: [],
}),
text: async () => '',
}));
// @ts-expect-error global fetch shim for the test
globalThis.fetch = fetchMock;
});
afterEach(() => {
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
if (originalDocumensoUrl === undefined) delete process.env.DOCUMENSO_API_URL;
else process.env.DOCUMENSO_API_URL = originalDocumensoUrl;
if (originalDocumensoKey === undefined) delete process.env.DOCUMENSO_API_KEY;
else process.env.DOCUMENSO_API_KEY = originalDocumensoKey;
vi.resetModules();
});
it('createDocument — every recipient.email rewritten to redirect target', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice Smith', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
{ name: 'Bob Smith', email: 'bob@realclient.com', role: 'VIEWER', signingOrder: 2 },
]);
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
expect(callBody.recipients).toHaveLength(2);
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
// Original email preserved in the name for traceability
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
}
});
it('generateDocumentFromTemplate — formValues *Email keys rewritten', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
formValues: {
'client.fullName': 'Alice Smith',
'client.primaryEmail': 'alice@realclient.com',
'developer.email': 'dev@realclient.com',
},
});
expect(fetchMock).toHaveBeenCalledOnce();
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
expect(callBody.formValues['client.primaryEmail']).toBe(REDIRECT_TARGET);
expect(callBody.formValues['developer.email']).toBe(REDIRECT_TARGET);
// Non-email field untouched
expect(callBody.formValues['client.fullName']).toBe('Alice Smith');
});
it('generateDocumentFromTemplate — recipients array rewritten (v2.x shape)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.generateDocumentFromTemplate(42, {
recipients: [
{ name: 'Alice', email: 'alice@realclient.com' },
{ name: 'Bob', email: 'bob@realclient.com' },
],
});
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
for (const r of callBody.recipients) {
expect(r.email).toBe(REDIRECT_TARGET);
expect(r.name).toMatch(/\(was: .+@realclient\.com\)/);
}
});
it('sendDocument — short-circuited when redirect is set (no /send call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendDocument('doc-1');
// sendDocument falls through to getDocument when redirect is set, so we
// expect the GET fetch but NOT the /send POST.
const calls = fetchMock.mock.calls;
const sendCall = calls.find((c) => String(c[0]).includes('/send') && c[1]?.method === 'POST');
expect(sendCall).toBeUndefined();
});
it('sendReminder — short-circuited when redirect is set (no /remind call)', async () => {
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.sendReminder('doc-1', 'signer-1');
expect(fetchMock).not.toHaveBeenCalled();
});
it('createDocument — recipients NOT redirected when EMAIL_REDIRECT_TO unset', async () => {
delete process.env.EMAIL_REDIRECT_TO;
vi.resetModules();
const mod = await import('@/lib/services/documenso-client');
await mod.createDocument('Test Doc', 'pdf-base64', [
{ name: 'Alice', email: 'alice@realclient.com', role: 'SIGNER', signingOrder: 1 },
]);
const callBody = JSON.parse(fetchMock.mock.calls[0]![1].body as string);
expect(callBody.recipients[0].email).toBe('alice@realclient.com');
});
});
// -------------------------------------------------------------------------
// 2. sendEmail redirect (covers the centralized path used by 5+ services)
// -------------------------------------------------------------------------
describe('sendEmail redirect — EMAIL_REDIRECT_TO', () => {
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
afterEach(() => {
vi.doUnmock('nodemailer');
vi.resetModules();
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
});
/**
* Each test does its own reset → mock → import dance so the nodemailer
* mock is the one observed by the freshly-imported `@/lib/email` module.
* Returns the sendMail spy so the test can assert on it.
*/
async function setupWith(redirect: string | null) {
if (redirect) process.env.EMAIL_REDIRECT_TO = redirect;
else delete process.env.EMAIL_REDIRECT_TO;
vi.resetModules();
const sendMailMock = vi.fn(async () => ({ messageId: '<msg@test>' }));
vi.doMock('nodemailer', () => ({
default: {
createTransport: vi.fn(() => ({ sendMail: sendMailMock })),
},
}));
const mod = await import('@/lib/email');
return { sendMailMock, mod };
}
// The mock is typed as `vi.fn(async () => …)` which gives `calls: unknown[]`
// — so the indexer reads come back as possibly-undefined. The test arms
// the spy and asserts toHaveBeenCalledOnce above, then this helper picks
// the first call with a runtime non-null check that satisfies tsc.
function firstSendMailArgs(spy: ReturnType<typeof vi.fn>): {
to: string;
subject: string;
} {
const calls = spy.mock.calls;
if (calls.length === 0) throw new Error('expected sendMail to be called');
const args = calls[0]?.[0];
if (!args) throw new Error('expected first call to have args');
return args as { to: string; subject: string };
}
it('rewrites to + prefixes subject when redirect set', async () => {
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi Alice</p>');
expect(sendMailMock).toHaveBeenCalledOnce();
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe(REDIRECT_TARGET);
expect(args.subject).toMatch(/^\[redirected from alice@realclient\.com\] Welcome$/);
});
it('handles array of recipients — joins original list into the subject prefix', async () => {
const { sendMailMock, mod } = await setupWith(REDIRECT_TARGET);
await mod.sendEmail(['alice@realclient.com', 'bob@realclient.com'], 'Update', '<p>x</p>');
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe(REDIRECT_TARGET);
expect(args.subject).toMatch(
/^\[redirected from alice@realclient\.com, bob@realclient\.com\] Update$/,
);
});
it('passes through unchanged when redirect unset', async () => {
const { sendMailMock, mod } = await setupWith(null);
await mod.sendEmail('alice@realclient.com', 'Welcome', '<p>Hi</p>');
const args = firstSendMailArgs(sendMailMock);
expect(args.to).toBe('alice@realclient.com');
expect(args.subject).toBe('Welcome');
});
});
// -------------------------------------------------------------------------
// 3. Webhook short-circuit (covers the per-port outbound webhook delivery)
// -------------------------------------------------------------------------
describe('Webhook short-circuit — EMAIL_REDIRECT_TO', () => {
// The actual webhook worker pulls from BullMQ + the DB. To keep this a
// pure unit test, we extract the "should I dispatch?" predicate and
// assert against env.EMAIL_REDIRECT_TO directly. The full integration
// path is already covered by tests/integration/webhook-delivery.test.ts.
const originalRedirect = process.env.EMAIL_REDIRECT_TO;
afterEach(() => {
if (originalRedirect === undefined) delete process.env.EMAIL_REDIRECT_TO;
else process.env.EMAIL_REDIRECT_TO = originalRedirect;
});
it('the worker reads process.env.EMAIL_REDIRECT_TO at dispatch time', () => {
// Sanity: the worker uses process.env directly (not a cached env import)
// so flipping the env at runtime takes effect on the next job.
process.env.EMAIL_REDIRECT_TO = REDIRECT_TARGET;
expect(process.env.EMAIL_REDIRECT_TO).toBe(REDIRECT_TARGET);
delete process.env.EMAIL_REDIRECT_TO;
expect(process.env.EMAIL_REDIRECT_TO).toBeUndefined();
});
});

View File

@@ -142,7 +142,7 @@ describe('calculateInterestScore', () => {
portId: 'p1', portId: 'p1',
clientId: 'c1', clientId: 'c1',
createdAt: daysAgo(10), createdAt: daysAgo(10),
pipelineStage: 'contract', pipelineStage: 'contract_signed',
eoiStatus: 'signed', eoiStatus: 'signed',
contractStatus: 'signed', contractStatus: 'signed',
depositStatus: 'received', depositStatus: 'received',