21 Commits

Author SHA1 Message Date
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
52 changed files with 16457 additions and 721 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,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

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

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

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

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

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

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

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