39 Commits

Author SHA1 Message Date
Matt Ciaccio
8699f81879 chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00
Matt Ciaccio
d62822c284 fix(migration): NocoDB import safety + dedup helpers + lead-source backfill
migration-apply: residential client + interest inserts now wrap in
db.transaction so a partial failure can't leave an orphan client
row without its interest (or vice versa).

migration-transform: buildPlannedDocument returns null when there
are no signers so the apply pass doesn't try to send a Documenso
envelope without recipients. mapDocumentStatus gets an explicit
"Awaiting Further Details" branch that no longer auto-promotes via
stale sign-time fields. parseFlexibleDate handles ISO and DD-MM-YYYY
inputs uniformly.

backfill-legacy-lead-source: chunk UPDATE WHERE clause now
isNull(source) on top of the inArray match, so a re-run can't
overwrite a more accurate source written between batches.

Adds 235 lines of vitest coverage on migration-transform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:56:18 +02:00
Matt Ciaccio
089f4a67a4 feat(receipts): upload guide page + scanner head-tag fix
Adds /invoices/upload-receipts as the dedicated explainer for the
mobile scanner PWA: install instructions for iOS/Android, direct
deep-link button, and a walkthrough of the scan -> verify -> save
flow. Sidebar entry replaces the old "Scan receipt" tab so the
desktop side picks up the install steps before sending users to
the mobile-only surface.

Scanner layout moves PWA manifest + apple-* meta tags from inline
JSX into Next.js's metadata/viewport exports so the App Router
doesn't try to render a second <head>, fixing a hydration error
that surfaced as two console warnings on the scan page.

Scanner shell gains a centered Port Nimara logo header so the
standalone PWA looks branded when launched from the home screen
without the dashboard chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:55:42 +02:00
Matt Ciaccio
77ad10ced1 feat(dashboard): custom date range + KPI port-hydration gate
DateRangePicker grows a "Custom range" mode (From/To inputs capped
at today, mutually-bounded so From <= To). dashboard-shell threads
the range through to /api/v1/analytics, which validates calendar
dates via ISO round-trip and enforces a 365-day cap as a backstop
against the occupancy timeline N+1.

KpiCards now gates its query on currentPortId so the early
unhydrated-store fetch can't cache a zeroed/error response and
display "-" until staleTime expires.

MyRemindersRail drops xl:h-full so the rail no longer stretches
past its grid row and overlaps ActivityFeed below.

useRealtimeInvalidation switches to partial-prefix queryKeys so a
realtime mutation invalidates every cached range bucket at once
instead of just the one currently visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:55 +02:00
Matt Ciaccio
e598cc0708 feat(layout): unified Inbox + UserMenu extraction
Replaces the topbar's separate AlertBell + NotificationBell with a
single Inbox popover that tabs between alerts and notifications.
NotificationBell keeps a popover-gate so it doesn't fire its list
fetch when Inbox is mounted alongside it.

Extracts the user dropdown into <UserMenu> and moves the port
switcher + role label + theme toggle into the sidebar footer so
the topbar can reclaim space for breadcrumbs and command search.

Adds dedicated Insights / Receipts nav sections in the sidebar
(scaffolds the website-analytics + upload-receipts entry points).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:54:06 +02:00
Matt Ciaccio
f5772ce318 feat(analytics): Umami integration with per-port admin settings
Adds /[portSlug]/website-analytics dashboard page (pageviews, top
pages, top referrers) and a per-port admin config UI for the
Umami URL / website-ID / API token. Settings live in system_settings
keyed per-port so a future second port has its own Umami account.
Adds a website glance tile to the main dashboard, a server-side
test-credentials endpoint, and a stable cache key for the active-
visitor poll so React Query doesn't fragment the cache per range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:53:06 +02:00
Matt Ciaccio
49d34e00c8 feat(website-intake): dual-write endpoint + migration chain repair
Adds website_submissions table + shared-secret POST endpoint so the
marketing site can dual-write inquiries alongside its NocoDB write.
Race-safe via INSERT ... ON CONFLICT, idempotent on submission_id,
refuses every request when WEBSITE_INTAKE_SECRET is unset. Also
repairs pre-existing 0020/0021/0022 prevId collision (renumbered +
journal re-sorted) so db:generate works again. 11 unit tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:52:33 +02:00
Matt Ciaccio
c612bbdfd9 fix(migration): legacy bare-mooring lookup + port-nimara berth backfill
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m12s
Build & Push Docker Images / build-and-push (push) Has been skipped
Two issues surfaced when applying the migration to dev:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:35:32 +02:00
Matt Ciaccio
d7ec2a8507 Merge docs/dedup-migration-design: client dedup + NocoDB migration design doc
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m18s
Build & Push Docker Images / build-and-push (push) Failing after 3m57s
2026-05-03 16:24:30 +02:00
Matt Ciaccio
cb83b09b2d Merge feat/dedup-migration: client dedup library + NocoDB migration script + admin queue
# Conflicts:
#	.gitignore
#	src/lib/db/migrations/meta/_journal.json
2026-05-03 16:24:13 +02:00
Matt Ciaccio
7574c3b575 chore(migrations): renumber 0020/0021 -> 0021/0022 to avoid clash with berth-parity
berth-schema-parity branch already shipped its own migration 0020 (berth
schema parity: text -> numeric, +status_override_mode). Dedup's two
migrations need to land on top of that, not collide.

Renames:
  0020_unusual_azazel.sql       -> 0021_unusual_azazel.sql
  0021_magenta_madame_hydra.sql -> 0022_magenta_madame_hydra.sql
  meta/0020_snapshot.json       -> meta/0021_snapshot.json
  meta/0021_snapshot.json       -> meta/0022_snapshot.json

_journal.json idx + tag fields updated to match.

Snapshot CONTENTS remain dedup-branch state (no berths-numeric awareness).
A `pnpm drizzle-kit generate` after main merges the berth changes will
produce a consistent forward path; until then the snapshots are slightly
out-of-sync with the post-merge live schema, which is harmless because
the dev DB applies migrations forward, not from snapshots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:22:58 +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
4bcc7f8be6 feat(dedup): runtime surfaces — merge service, at-create suggestion, admin queue (P2)
Adds the live dedup pipeline on top of the P1 library + P3 migration
script. The new `client/interest` model now actively prevents duplicate
client records at creation time and gives admins a queue to triage
the borderline pairs the at-create check missed.

Three layers, per design §7:

Layer 1 — At-create suggestion
==============================

`GET /api/v1/clients/match-candidates`
  Accepts free-text email / phone / name from the in-flight client
  form, normalizes them via the dedup library, and returns scored
  matches against the port's live client pool. Filters out
  low-confidence noise (the background scoring queue picks those up
  separately). Strict port scoping; never leaks across tenants.

`<DedupSuggestionPanel>` (`src/components/clients/dedup-suggestion-panel.tsx`)
  Debounced React Query hook. Renders nothing for short inputs or
  no useful match. On a high-confidence match it interrupts visually
  with an amber-tinted card and a "Use this client" primary button.
  Medium confidence falls back to a softer "possible match — check
  before creating" treatment.

`<ClientForm>`
  Renders the panel above the form (create path only — skipped on
  edit). New `onUseExistingClient` callback fires when the user
  picks the existing client; the form closes and the parent decides
  what to do (typically: navigate to that client's detail page or
  open the create-interest dialog pre-filled).

Layer 2 — Merge service
=======================

`mergeClients` (`src/lib/services/client-merge.service.ts`)
  The atomic merge primitive that everything else calls. Single
  transaction. Per §6 of the design:

  - Locks both rows (FOR UPDATE) so concurrent merges of the same
    loser fail with a clear error rather than racing.
  - Snapshots the full loser state (contacts / addresses / notes /
    tags / interest+reservation IDs / relationship rows) into the
    `client_merge_log.merge_details` JSONB column for the eventual
    undo flow.
  - Reattaches every loser-side row to the winner: interests,
    reservations, contacts (skipping duplicates by `(channel, value)`),
    addresses, notes, tags (deduped), relationships.
  - Optional `fieldChoices` — per-scalar overrides letting the user
    keep the loser's value for fullName / nationality / preferences /
    timezone / source.
  - Marks the loser archived with `mergedIntoClientId` set (a redirect
    pointer for stragglers; never hard-deleted within the undo window).
  - Resolves any matching `client_merge_candidates` row to status='merged'.
  - Writes audit log entry.

Schema additions:
  - `clients.merged_into_client_id` (nullable text, indexed) — the
    redirect pointer set on archive.

Tests: 6 cases against a real DB — happy path moves rows + writes log;
self-merge / cross-port / already-merged refused; duplicate-contact
deduped on reattach; fieldChoices copies loser values to winner.

Layer 3 — Admin review queue
============================

`GET /api/v1/admin/duplicates`
  Pending merge candidates (status='pending') for the current port,
  with both client summaries hydrated for side-by-side rendering.
  Skips pairs where one side is already archived/merged.

`POST /api/v1/admin/duplicates/[id]/merge`
  Confirms a candidate. Body picks the winner; the other side
  becomes the loser. Calls into `mergeClients` — the only path that
  writes `client_merge_log`.

`POST /api/v1/admin/duplicates/[id]/dismiss`
  Marks the candidate dismissed. Future scoring runs skip the same
  pair until a score change recreates the row.

`<DuplicatesReviewQueue>` (`/admin/duplicates`)
  Side-by-side card UI for each pending pair. Click a card to pick
  the winner; the other side is automatically the loser. Toolbar:
  "Merge into selected" + "Dismiss". No per-field merge editor in
  this PR — that's a future polish; the simple "pick the better row"
  flow handles ~80% of cases.

Test coverage
=============

11 new integration tests (76 added in this branch total):
  - 6 mergeClients (atomicity, refusal cases, contact dedup,
    fieldChoices)
  - 5 match-candidates API (shape, port scoping, confidence tiers,
    Pattern F false-positive guard)

Full vitest: 926/926 passing (was 858 before the dedup branch).
Lint: clean. tsc: clean for new files (only pre-existing errors in
unrelated `tests/integration/` files remain, same as before this PR).

Out of scope, deferred
======================

- Background scoring cron that populates `client_merge_candidates`
  (the queue is empty until this lands; manual seeding works for
  now via the at-create flow).
- Side-by-side per-field merge editor with checkboxes (the simple
  "pick the winner" UX shipped here covers ~80% of real cases).
- Admin settings UI for tuning the dedup thresholds. Defaults from
  the design (90 / 50) are baked in for now.
- `unmergeClients` (the snapshot is captured in client_merge_log;
  the undo endpoint just hasn't been wired yet).

These are all natural follow-up PRs that don't block shipping the
runtime UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:59:04 +02:00
Matt Ciaccio
18e5c124b0 feat(dedup): NocoDB migration script + tables (P3 dry-run)
Lands the one-shot migration pipeline from the legacy NocoDB Interests
base into the new client/interest schema. Dry-run mode is fully
operational: pulls the live snapshot, runs the dedup library, and
writes a CSV + Markdown report under .migration/<timestamp>/. The
--apply phase is stubbed for a follow-up PR per the design's P3
implementation sequence.

Schema additions
================

- `client_merge_candidates` — pairs flagged by the background scoring
  job for the /admin/duplicates review queue. Status enum: pending /
  dismissed / merged. Unique-(portId, clientAId, clientBId) so the
  same pair can't surface twice. Empty until P2 lands the cron.
- `migration_source_links` — idempotency ledger. Maps source-system
  rows (NocoDB Interest #624 → new client UUID) so re-running --apply
  against the same dry-run report skips already-imported entities.

Both tables ship with the migration `0020_unusual_azazel.sql` —
already applied to the local dev DB during this commit's preparation.

Library
=======

src/lib/dedup/nocodb-source.ts
  Read-only adapter for the legacy NocoDB v2 API. xc-token auth,
  auto-paginates until isLastPage, captures the table IDs from the
  2026-05-03 audit. `fetchSnapshot()` pulls every relevant table in
  parallel into one in-memory object the transform layer consumes.

src/lib/dedup/migration-transform.ts
  Pure function: NocoDB snapshot in, MigrationPlan out. Per row:
    - normalizes name / email / phone / country via the dedup library
    - parses the legacy DD-MM-YYYY / DD/MM/YYYY / ISO date formats
    - maps the 8-stage `Sales Process Level` enum to the new 9-stage
      pipelineStage
    - filters yacht-name placeholders ('TBC', 'Na', etc.)
    - merges Internal Notes + Extra Comments + Berth Size Desired into
      a single notes blob
  Then runs `findClientMatches` pairwise (with blocking) and
  union-finds clusters of rows whose score crosses the auto-link
  threshold (90). Lower-scoring pairs (50–89) become 'needs review'.
  Each cluster's "lead" row is picked by completeness score with
  recency tie-break.

src/lib/dedup/migration-report.ts
  Writes three artifacts to .migration/<timestamp>/:
    - report.csv  — one row per planned op, RFC-4180 escaped
    - summary.md  — human-skimmable overview
    - plan.json   — full structured plan for the --apply phase
  CSV cells with comma / quote / newline are quoted; internal quotes
  are doubled. No external CSV dep.

src/lib/dedup/phone-parse.ts
  Script-safe wrapper around libphonenumber-js's `core` entry that
  loads `metadata.min.json` directly. The default `index.cjs.js`
  bundled by libphonenumber hits a metadata-shape interop bug under
  Node 25 + tsx (`{ default }` wrapping); core+JSON sidesteps it.
  The dedup `normalizePhone` and `find-matches` both use this wrapper
  now so the same code path runs in vitest, Next.js, and the migration
  CLI without surprises.

src/lib/dedup/normalize.ts
  Tightened country resolution: added Caribbean short-form aliases
  ('antigua' → AG, 'st kitts' → KN, etc.) and a city map covering the
  US locations seen in the NocoDB dump (Boston, Tampa, Fort
  Lauderdale, Port Jefferson, Nantucket). Also relaxed phone parsing
  to drop the `isValid()` strict check — the libphonenumber min build
  rejects many real NANP-territory numbers, and dedup only needs a
  canonical E.164 to compare.

CLI
===

scripts/migrate-from-nocodb.ts
  pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
    → Pulls the live NocoDB base (NOCODB_URL + NOCODB_TOKEN env vars),
       runs the transform, writes report. No DB writes.
  pnpm tsx scripts/migrate-from-nocodb.ts --apply --report .migration/<dir>/
    → Stubbed; exits with `not yet implemented` and a pointer to the
       design doc. Apply phase ships in a follow-up.

Tests
=====

tests/unit/dedup/migration-transform.test.ts (7 cases)
  Fixture-based regression. A frozen 12-row NocoDB snapshot covers
  every duplicate pattern in the design (§1.2). The test asserts:
    - 12 input rows → 7 unique clients (cluster math is right)
    - Patterns A / B / C / E auto-link
    - Pattern F (Etiennette Clamouze) does NOT auto-link
    - Every interest preserved as its own row even when clients merge
    - 8-stage → 9-stage enum mapping is correct per spec
    - Multi-yacht merge (Constanzo CALYPSO + Costanzo GEMINI under one
      client) — the design's signature win
    - Output is deterministic (run twice, identical)

Validation against real data
============================

Ran `pnpm tsx scripts/migrate-from-nocodb.ts --dry-run` against the
live NocoDB. Result on 252 Interests rows:
  - 237 clients (15 merged into 13 clusters)
  - 252 interests (one per source row)
  - 406 contacts, 52 addresses
  - 13 auto-linked clusters (every confirmed cluster from §1.2 audit)
  - 3 pairs flagged for review (Camazou, Zasso, one new)
  - 1 phone placeholder flagged

Total dedup test count: 57 (50 from P1 + 7 fixture tests).
Lint: clean. Tsc: clean for new files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:50:01 +02:00
Matt Ciaccio
8b077e1999 feat(dedup): normalization + match-finding library (P1)
The pure-logic spine of the client deduplication system spec'd in
docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md.
Two modules, JSX-free, vitest-tested against fixtures drawn directly
from real dirty values observed in the legacy NocoDB Interests audit.

src/lib/dedup/normalize.ts
- normalizeName: trims whitespace, replaces \r/\n/\t, intelligently
  title-cases ALL-CAPS surnames while keeping particles (van / de /
  dalla / etc.) lowercase mid-name. Preserves Irish O' surnames and
  the "slash-with-company" structure ("Daniel Wainstein / 7 Knots,
  LLC") seen in production. Returns a surnameToken (lowercased last
  non-particle token) for use as a dedup blocking key.
- normalizeEmail: trim + lowercase + zod email validation. Plus-aliases
  preserved; null on invalid.
- normalizePhone: pre-cleans the input (strips spreadsheet apostrophes,
  carriage returns, dots/dashes/parens, converts 00 prefix to +) then
  delegates to libphonenumber-js. Detects multi-number fields ("a/b",
  "a;b") and placeholder fakes (8+ consecutive zeros, e.g.
  +447000000000). Flags every quirk so the migration report and runtime
  audit log can surface it.
- resolveCountry: maps free-text country/region input to ISO-3166-1
  alpha-2 via alias → exact (vs. Intl-derived names) → city → fuzzy
  (Levenshtein ≤ 2). Fuzzy is gated by length so 4-char inputs ("Mars")
  don't false-positive against short country names.
- levenshtein: standard iterative implementation, exported for reuse
  by find-matches.

src/lib/dedup/find-matches.ts
- findClientMatches: builds three blocking indexes off the pool (email
  / phone / surname-token), gathers the comparison set via union, and
  scores each candidate via the rule set in design §4.2:
    Email match            +60
    Phone E.164 match      +50  (≥ 8 digits, excludes placeholder zeros)
    Name exact match       +20
    Surname + given fuzzy  +15  (Levenshtein ≤ 1)
    Negative: shared email but different phone country  −15
    Negative: name match but no shared contact          −20
  Score is clamped to [0,100]. Confidence tier ('high' / 'medium' /
  'low') is derived from configurable thresholds passed in by the
  caller — defaults are highScore=90, mediumScore=50.

tests/unit/dedup/normalize.test.ts (38 cases)
Every dirty-data pattern from design §1.3 has a fixture: carriage
returns in names, ALL-CAPS surnames, lowercase entries, particles,
slash-with-company, plus-aliases, capitalized email localparts,
spreadsheet-apostrophe phones, multi-number phones, placeholder
phones, 00-prefix phones, French/UK local-format phones,
Saint-Barthélemy diacritic variants, Kansas City fallback.

tests/unit/dedup/find-matches.test.ts (12 cases)
Each duplicate cluster from design §1.2 has a test:
- Pattern A (Deepak Ramchandani — pure double-submit) → high
- Pattern B (Howard Wiarda — phone format variance) → high
- Pattern C (Nicolas Ruiz — name capitalization) → high
- Pattern D (Chris/Christopher Allen — name shortening) → high
- Pattern E (Christopher Camazou — typo on resubmit) → high or medium
- Pattern E (Constanzo/Costanzo — surname typo, multi-yacht) → high
- Pattern F (Etiennette Clamouze — same name, different country) →
  must NOT auto-merge
- Pattern F (Bruno+Bruce — shared household contact) → no match
- Negative evidence (same email, different phone country) → medium
- Blocking (no shared keys → 0 matches)
- Sort order (high before low)
- Empty pool

Total: 50 new tests, all green. Zero changes to runtime behavior or
schema; unblocks P2 (runtime surfaces) and P3 (NocoDB migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 14:28:59 +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
318 changed files with 58914 additions and 1734 deletions

View File

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

13
.gitignore vendored
View File

@@ -28,9 +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/
# Tool caches / runtime state
/.claude/
/.serena/
/ruvector.db

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
/**
* One-shot: backfill `interests.source` for legacy NocoDB-imported rows.
*
* Why this exists: the legacy NocoDB Interests table left the `Source`
* column null for ~95 % of rows. The migration mapped null → null, so the
* Lead Source Attribution chart shows them as "Unspecified". Per the
* operator's best knowledge, almost all of those legacy rows came in
* through the website (web form / portal) — the few that didn't are the
* ones that already carry an explicit `Source` value (Form / portal /
* External). Defaulting null → 'website' is therefore the closest
* truth we can reconstruct without per-row sales notes review.
*
* Idempotent: only updates rows where `source IS NULL` AND the row has a
* `migration_source_links` entry tying it back to the legacy NocoDB import,
* so net-new manually-created interests with null source aren't touched.
*
* Usage:
* pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug port-nimara [--dry-run]
*/
import 'dotenv/config';
import { eq, and, isNull, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { interests } from '@/lib/db/schema/interests';
import { migrationSourceLinks } from '@/lib/db/schema/migration';
interface CliArgs {
portSlug: string | null;
dryRun: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { portSlug: null, dryRun: false };
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--dry-run') args.dryRun = true;
else if (a === '-h' || a === '--help') {
console.log(
'Usage: pnpm tsx scripts/backfill-legacy-lead-source.ts --port-slug <slug> [--dry-run]',
);
process.exit(0);
}
}
if (!args.portSlug) {
console.error('Missing required --port-slug');
process.exit(1);
}
return args;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const [port] = await db
.select({ id: ports.id, name: ports.name })
.from(ports)
.where(eq(ports.slug, args.portSlug!))
.limit(1);
if (!port) {
console.error(`No port found with slug "${args.portSlug}"`);
process.exit(1);
}
console.log(`[backfill] target: ${port.name} (${port.id})`);
// Pull every interest id this port owns that has a NULL source.
const candidateInterests = await db
.select({ id: interests.id })
.from(interests)
.where(and(eq(interests.portId, port.id), isNull(interests.source)));
console.log(`[backfill] interests with NULL source in this port: ${candidateInterests.length}`);
if (candidateInterests.length === 0) {
console.log('Nothing to backfill.');
return;
}
// Filter to ONLY those that came in via the legacy migration — preserves
// null on net-new rows where the operator hasn't picked a source yet.
const candidateIds = candidateInterests.map((r) => r.id);
const legacyLinks = await db
.select({ targetEntityId: migrationSourceLinks.targetEntityId })
.from(migrationSourceLinks)
.where(
and(
eq(migrationSourceLinks.sourceSystem, 'nocodb_interests'),
eq(migrationSourceLinks.targetEntityType, 'interest'),
inArray(migrationSourceLinks.targetEntityId, candidateIds),
),
);
const legacyIds = new Set(legacyLinks.map((l) => l.targetEntityId));
const toUpdate = candidateIds.filter((id) => legacyIds.has(id));
console.log(
`[backfill] of those, ${toUpdate.length} are legacy migration rows (will set source='website')`,
);
console.log(
`[backfill] ${candidateInterests.length - toUpdate.length} are net-new rows (left untouched)`,
);
if (args.dryRun) {
console.log('[backfill] --dry-run set; no writes.');
return;
}
if (toUpdate.length === 0) {
console.log('Nothing to write.');
return;
}
// Update in chunks of 500 to keep query size sane.
const CHUNK = 500;
let updated = 0;
for (let i = 0; i < toUpdate.length; i += CHUNK) {
const chunk = toUpdate.slice(i, i + CHUNK);
// Belt-and-suspenders: re-assert `source IS NULL` in the WHERE so
// a concurrent process that set source on one of these rows
// between SELECT and UPDATE doesn't get its value clobbered.
const result = await db
.update(interests)
.set({ source: 'website' })
.where(and(inArray(interests.id, chunk), isNull(interests.source)))
.returning({ id: interests.id });
updated += result.length;
}
console.log(`[backfill] updated ${updated} rows.`);
}
main().catch((err) => {
console.error('FATAL', err);
process.exit(1);
});

View File

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

View File

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

View File

@@ -0,0 +1,251 @@
/**
* One-shot migration: legacy NocoDB Interests → new client/interest split.
*
* Usage:
*
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run
* Pulls the live NocoDB base, runs the transform + dedup pipeline,
* writes a report to .migration/<timestamp>/. NO database writes.
*
* pnpm tsx scripts/migrate-from-nocodb.ts --dry-run --port-slug port-nimara
* Same, but tags the planned writes with the named port (matters for
* the apply phase — every client/interest belongs to one port).
*
* pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug port-nimara
* Re-fetches NocoDB, re-transforms, then writes the planned rows
* into the target port via the idempotent `migration_source_links`
* ledger. Re-runs are safe — already-imported source IDs are skipped.
* REQUIRES `EMAIL_REDIRECT_TO` to be set in env (safety net) unless
* `--unsafe-skip-redirect-check` is also passed.
*
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §9.
*/
import 'dotenv/config';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { applyPlan } from '@/lib/dedup/migration-apply';
import { fetchSnapshot, loadNocoDbConfig } from '@/lib/dedup/nocodb-source';
import { transformSnapshot } from '@/lib/dedup/migration-transform';
import { resolveReportPaths, writeReport } from '@/lib/dedup/migration-report';
interface CliArgs {
dryRun: boolean;
apply: boolean;
portSlug: string | null;
reportDir: string | null;
unsafeSkipRedirectCheck: boolean;
}
function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = {
dryRun: false,
apply: false,
portSlug: null,
reportDir: null,
unsafeSkipRedirectCheck: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]!;
if (a === '--dry-run') args.dryRun = true;
else if (a === '--apply') args.apply = true;
else if (a === '--port-slug') args.portSlug = argv[++i] ?? null;
else if (a === '--report') args.reportDir = argv[++i] ?? null;
else if (a === '--unsafe-skip-redirect-check') args.unsafeSkipRedirectCheck = true;
else if (a === '-h' || a === '--help') {
printHelp();
process.exit(0);
} else {
console.error(`Unknown argument: ${a}`);
printHelp();
process.exit(1);
}
}
return args;
}
function printHelp(): void {
console.log(`Usage:
pnpm tsx scripts/migrate-from-nocodb.ts --dry-run [--port-slug <slug>]
Pulls NocoDB → transforms → writes report to .migration/<timestamp>/.
No database writes.
pnpm tsx scripts/migrate-from-nocodb.ts --apply --port-slug <slug>
Re-fetches NocoDB, re-transforms, writes via migration_source_links
ledger. Idempotent — safe to re-run. Requires EMAIL_REDIRECT_TO set
(unless --unsafe-skip-redirect-check is also passed).
Flags:
--dry-run Read NocoDB, write report only.
--apply Actually write rows to the DB.
--port-slug <slug> Port slug to attach to all imported
entities. Defaults to the first
available port if omitted.
--report <dir> Path to a previously-generated report
dir (only used by --apply).
--unsafe-skip-redirect-check Skip the EMAIL_REDIRECT_TO precondition
check. Only use in production cutover.
-h, --help Show this help.
`);
}
/**
* Resolve the target port: use the slug if provided, otherwise the first
* port found. Errors out cleanly if the slug doesn't match any port.
*/
async function resolvePort(slug: string | null): Promise<{ id: string; slug: string }> {
if (slug) {
const [p] = await db
.select({ id: ports.id, slug: ports.slug })
.from(ports)
.where(eq(ports.slug, slug))
.limit(1);
if (!p) {
console.error(`No port found with slug "${slug}".`);
process.exit(1);
}
return { id: p.id, slug: p.slug };
}
const [first] = await db.select({ id: ports.id, slug: ports.slug }).from(ports).limit(1);
if (!first) {
console.error('No ports exist in the target DB. Seed at least one port before applying.');
process.exit(1);
}
return { id: first.id, slug: first.slug };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (!args.dryRun && !args.apply) {
console.error('Must specify --dry-run or --apply');
printHelp();
process.exit(1);
}
// Safety gate: --apply must run with EMAIL_REDIRECT_TO set, unless the
// operator explicitly opts out (production cutover).
if (args.apply && !process.env.EMAIL_REDIRECT_TO && !args.unsafeSkipRedirectCheck) {
console.error(
'--apply requires EMAIL_REDIRECT_TO to be set in the environment as a safety net.',
);
console.error('See docs/operations/outbound-comms-safety.md for the rationale.');
console.error(
'If you are running the production cutover and have read that doc, add ' +
'--unsafe-skip-redirect-check to override.',
);
process.exit(2);
}
// ── Fetch + transform (shared by dry-run and apply) ──────────────────────
console.log('[migrate] Loading NocoDB config…');
const config = loadNocoDbConfig();
console.log(`[migrate] Source: ${config.url}`);
console.log('[migrate] Fetching snapshot from NocoDB…');
const start = Date.now();
const snapshot = await fetchSnapshot(config);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`[migrate] Snapshot fetched in ${elapsed}s — ${snapshot.interests.length} interests, ${snapshot.residentialInterests.length} residential, ${snapshot.berths.length} berths.`,
);
console.log('[migrate] Running transform + dedup pipeline…');
const plan = transformSnapshot(snapshot);
// Resolve output paths relative to the worktree root.
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const generatedAt = new Date().toISOString();
const paths = resolveReportPaths(repoRoot);
console.log(`[migrate] Writing report to ${paths.rootDir}`);
await writeReport(paths, plan, generatedAt);
// ── Plan summary ─────────────────────────────────────────────────────────
const s = plan.stats;
console.log('');
console.log('=== Migration Plan Summary ===');
console.log(
` Input: ${s.inputInterestRows} interests, ${s.inputResidentialRows} residential interests`,
);
console.log(` Output: ${s.outputClients} clients, ${s.outputInterests} interests`);
console.log(` ${s.outputContacts} contacts, ${s.outputAddresses} addresses`);
console.log(
` ${s.outputDocuments} EOI documents, ${s.outputDocumentSigners} signers`,
);
console.log(
` ${s.outputResidentialClients} residential clients (with default-stage interests)`,
);
console.log(
` Dedup: ${s.autoLinkedClusters} auto-linked clusters, ${s.needsReviewPairs} pairs flagged for review`,
);
console.log(` Quality: ${s.flaggedRows} rows flagged (see report.csv)`);
console.log('');
console.log(` Full report: ${paths.summaryPath}`);
if (args.dryRun) {
console.log('');
console.log('Dry-run complete. Re-run with --apply to write rows.');
return;
}
// ── Apply path ───────────────────────────────────────────────────────────
const port = await resolvePort(args.portSlug);
const applyId = randomUUID();
console.log('');
console.log(`[migrate] Applying to port "${port.slug}" (id=${port.id})`);
console.log(`[migrate] Apply id: ${applyId}`);
console.log('[migrate] Inserting…');
const applyStart = Date.now();
const result = await applyPlan(plan, { port, applyId });
const applyElapsed = ((Date.now() - applyStart) / 1000).toFixed(1);
console.log('');
console.log('=== Apply Result ===');
console.log(` Time: ${applyElapsed}s`);
console.log(
` Clients: ${result.clientsInserted} inserted, ${result.clientsSkipped} already linked`,
);
console.log(` Contacts: ${result.contactsInserted} inserted`);
console.log(` Addresses: ${result.addressesInserted} inserted`);
console.log(` Yachts: ${result.yachtsInserted} inserted`);
console.log(
` Interests: ${result.interestsInserted} inserted, ${result.interestsSkipped} already linked`,
);
console.log(
` Documents: ${result.documentsInserted} inserted, ${result.documentsSkipped} already linked`,
);
console.log(` Signers: ${result.documentSignersInserted} inserted`);
console.log(
` Res-Clt: ${result.residentialClientsInserted} inserted, ${result.residentialClientsSkipped} already linked`,
);
console.log(` Res-Int: ${result.residentialInterestsInserted} inserted`);
if (result.warnings.length > 0) {
console.log('');
console.log('Warnings:');
for (const w of result.warnings.slice(0, 20)) {
console.log(` - ${w}`);
}
if (result.warnings.length > 20) {
console.log(`${result.warnings.length - 20} more`);
}
}
console.log('');
}
main().catch((err) => {
console.error('[migrate] Fatal error:', err);
process.exit(1);
});

View File

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

View File

@@ -0,0 +1,5 @@
import { DuplicatesReviewQueue } from '@/components/admin/duplicates/duplicates-review-queue';
export default function DuplicatesAdminPage() {
return <DuplicatesReviewQueue />;
}

View File

@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)', label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.', description: 'Appended to the bottom of system-generated emails.',
type: 'html', type: 'html',
placeholder: '<p><br>The Port Nimara team</p>', placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '', defaultValue: '',
}, },
{ {
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{ {
key: 'smtp_pass_override', key: 'smtp_pass_override',
label: 'SMTP password override', label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.', description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password', type: 'password',
defaultValue: '', defaultValue: '',
}, },

View File

@@ -16,7 +16,9 @@ import {
Tag, Tag,
Upload, Upload,
Users, Users,
UsersRound,
Webhook, Webhook,
Globe,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -29,132 +31,192 @@ interface AdminSection {
icon: typeof Settings; icon: typeof Settings;
} }
const SECTIONS: AdminSection[] = [ interface AdminGroup {
title: string;
description: string;
sections: AdminSection[];
}
const GROUPS: AdminGroup[] = [
{ {
href: 'users', title: 'Access',
label: 'Users', description: 'Who can sign in and what they can do once they do.',
description: 'CRM accounts, role assignments, and per-user residential access toggles.', sections: [
icon: Users, {
href: 'users',
label: 'Users',
description: 'CRM accounts, role assignments, and per-user residential access toggles.',
icon: Users,
},
{
href: 'invitations',
label: 'Invitations',
description: 'Send invitations, track pending invites, and resend or revoke them.',
icon: Mail,
},
{
href: 'roles',
label: 'Roles & Permissions',
description: 'Default permission sets and per-port role overrides.',
icon: Shield,
},
],
}, },
{ {
href: 'invitations', title: 'Configuration',
label: 'Invitations', description: 'Branding, integrations, and per-port settings.',
description: 'Send invitations, track pending invites, and resend or revoke them.', sections: [
icon: Mail, {
href: 'email',
label: 'Email Settings',
description: 'From address, signatures, and per-port SMTP overrides.',
icon: Mail,
},
{
href: 'documenso',
label: 'Documenso & EOI',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.',
icon: FileText,
},
{
href: 'reminders',
label: 'Reminders',
description: 'Default reminder behaviour and the daily-digest delivery window.',
icon: Bell,
},
{
href: 'branding',
label: 'Branding',
description: 'App name, logo, primary color, and email header/footer HTML.',
icon: Palette,
},
{
href: 'settings',
label: 'System Settings',
description: 'Generic key/value configuration store for advanced flags.',
icon: Settings,
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
],
}, },
{ {
href: 'roles', title: 'Content',
label: 'Roles & Permissions', description: 'Forms, templates, and labels that users see.',
description: 'Default permission sets and per-port role overrides.', sections: [
icon: Shield, {
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
],
}, },
{ {
href: 'audit', title: 'Data Quality',
label: 'Audit Log', description: 'Cleanup, imports, and the audit trail.',
description: 'Searchable log of every authenticated mutation in the system.', sections: [
icon: ScrollText, {
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,
},
],
}, },
{ {
href: 'email', title: 'Operations',
label: 'Email Settings', description: 'Health checks and disaster recovery.',
description: 'From address, signatures, and per-port SMTP overrides.', sections: [
icon: Mail, {
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.',
icon: HardDrive,
},
],
}, },
{ {
href: 'documenso', title: 'Tenancy',
label: 'Documenso & EOI', description: 'Multi-port and multi-install scaffolding.',
description: 'API credentials, EOI template, and default in-app vs Documenso pathway.', sections: [
icon: FileText, {
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding',
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
},
],
}, },
{ {
href: 'reminders', title: 'Integrations',
label: 'Reminders', description: 'Third-party providers wired into the app.',
description: 'Default reminder behaviour and the daily-digest delivery window.', sections: [
icon: Bell, {
}, href: 'ocr',
{ label: 'Receipt OCR',
href: 'branding', description: 'Configure the AI provider used by the mobile receipt scanner.',
label: 'Branding', icon: ScrollText,
description: 'App name, logo, primary color, and email header/footer HTML.', },
icon: Palette, {
}, href: 'website-analytics',
{ label: 'Website analytics (Umami)',
href: 'settings', description: 'Per-port Umami URL, API token, and Website ID.',
label: 'System Settings', icon: Globe,
description: 'Generic key/value configuration store for advanced flags.', },
icon: Settings, ],
},
{
href: 'webhooks',
label: 'Webhooks',
description: 'Outgoing webhook subscriptions, secrets, and delivery log.',
icon: Webhook,
},
{
href: 'forms',
label: 'Forms',
description: 'Form templates used by client-facing inquiry and intake flows.',
icon: Sliders,
},
{
href: 'templates',
label: 'Document Templates',
description: 'PDF + email templates with merge-field placeholders.',
icon: FileText,
},
{
href: 'tags',
label: 'Tags',
description: 'Color-coded tags applied to clients, yachts, companies, and interests.',
icon: Tag,
},
{
href: 'custom-fields',
label: 'Custom Fields',
description: 'Tenant-defined fields for clients, yachts, and reservations.',
icon: Key,
},
{
href: 'reports',
label: 'Reports',
description: 'Saved analytics views and ad-hoc query results.',
icon: LayoutDashboard,
},
{
href: 'monitoring',
label: 'Queue Monitoring',
description: 'BullMQ queue health, throughput, and retry diagnostics.',
icon: Database,
},
{
href: 'import',
label: 'Bulk Import',
description: 'CSV-driven imports for clients, yachts, and reservations.',
icon: Upload,
},
{
href: 'backup',
label: 'Backup & Restore',
description: 'Database snapshots and on-demand exports.',
icon: HardDrive,
},
{
href: 'ports',
label: 'Ports',
description: 'Manage the marinas/ports this installation serves.',
icon: Briefcase,
},
{
href: 'onboarding',
label: 'Onboarding',
description: 'Initial-setup wizard for fresh ports.',
icon: LayoutDashboard,
},
{
href: 'ocr',
label: 'Receipt OCR',
description: 'Configure the AI provider used by the mobile receipt scanner.',
icon: ScrollText,
}, },
]; ];
@@ -165,36 +227,46 @@ 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."
/> />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> {GROUPS.map((group) => (
{SECTIONS.map((s) => { <section key={group.title} className="space-y-3">
const Icon = s.icon; <div>
return ( <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<Link {group.title}
key={s.href} </h2>
// eslint-disable-next-line @typescript-eslint/no-explicit-any <p className="text-xs text-muted-foreground/80">{group.description}</p>
href={`/${portSlug}/admin/${s.href}` as any} </div>
className="block group" <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
> {group.sections.map((s) => {
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30"> const Icon = s.icon;
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2"> return (
<Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" /> <Link
<div className="flex-1"> key={s.href}
<CardTitle className="text-base">{s.label}</CardTitle> // eslint-disable-next-line @typescript-eslint/no-explicit-any
</div> href={`/${portSlug}/admin/${s.href}` as any}
</CardHeader> className="block group"
<CardContent> >
<CardDescription>{s.description}</CardDescription> <Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
</CardContent> <CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
</Card> <Icon className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary" />
</Link> <div className="flex-1">
); <CardTitle className="text-base">{s.label}</CardTitle>
})} </div>
</div> </CardHeader>
<CardContent>
<CardDescription>{s.description}</CardDescription>
</CardContent>
</Card>
</Link>
);
})}
</div>
</section>
))}
</div> </div>
); );
} }

View File

@@ -0,0 +1,74 @@
import {
SettingsFormCard,
type SettingFieldDef,
} from '@/components/admin/shared/settings-form-card';
import { UmamiTestButton } from '@/components/admin/website-analytics/umami-test-button';
import { PageHeader } from '@/components/shared/page-header';
/**
* Per-port Umami credentials. We deliberately keep all three values
* port-scoped (per the operator decision) so different ports can point at
* different Umami instances if needed. The /website-analytics dashboard
* page reads these settings via the umami.service layer at request time.
*/
const FIELDS: SettingFieldDef[] = [
{
key: 'umami_api_url',
label: 'Umami API URL',
description:
'Base URL of the Umami instance, e.g. https://analytics.portnimara.com (no trailing slash, no /api).',
type: 'string',
placeholder: 'https://analytics.portnimara.com',
defaultValue: '',
},
{
key: 'umami_api_token',
label: 'API token',
description:
'Long-lived API token if your Umami install supports one (Umami Cloud or v2 self-hosted with API keys enabled). Leave blank if you only have username/password - the service falls back to the JWT login flow using the credentials below. Stored in plain text in system_settings.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_username',
label: 'Username',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'string',
placeholder: 'admin',
defaultValue: '',
},
{
key: 'umami_password',
label: 'Password',
description: 'Self-hosted JWT fallback. Only used if API token is blank.',
type: 'password',
defaultValue: '',
},
{
key: 'umami_website_id',
label: 'Website ID',
description:
'UUID of this ports website inside Umami. Find it in Umami → Settings → Websites → Edit → Website ID.',
type: 'string',
placeholder: '00000000-0000-0000-0000-000000000000',
defaultValue: '',
},
];
export default function WebsiteAnalyticsSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Website analytics (Umami)"
description="Connect this port to its Umami website to display traffic, top pages, referrers, and conversion data on the Website Analytics dashboard."
/>
<SettingsFormCard
title="Umami connection"
description="Per-port credentials. Each port can point at its own Umami instance; or share one instance with different website IDs."
fields={FIELDS}
extra={<UmamiTestButton />}
/>
</div>
);
}

View File

@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
/** /**
* Route-level loading UI for the client detail page. Renders while the * Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps * server component resolves the session and the client component bootstraps
* its initial query replaces the previous empty-header flash on direct * its initial query - replaces the previous empty-header flash on direct
* URL visits. * URL visits.
*/ */
export default function Loading() { export default function Loading() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header strip title, badges, action buttons */} {/* Header strip - title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3"> <div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" /> <Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]); }, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`, // When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} Berth {n}" in // fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill. // the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{ const { data: prefilledInterest } = useQuery<{
data: { data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
{/* Header desktop only; mobile gets the title from the topbar */} {/* Header - desktop only; mobile gets the title from the topbar */}
<div className="hidden sm:flex items-center gap-3"> <div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}> <Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data {prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${ ? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber prefilledInterest.data.berthMooringNumber
? ` Berth ${prefilledInterest.data.berthMooringNumber}` ? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: '' : ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".` }. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'} : 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
export const metadata: Metadata = {
title: 'How to upload receipts',
};
export default async function UploadReceiptsPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from 'next';
import { WebsiteAnalyticsShell } from '@/components/website-analytics/website-analytics-shell';
export const metadata: Metadata = {
title: 'Website analytics',
};
export default function WebsiteAnalyticsPage() {
return <WebsiteAnalyticsShell />;
}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider> <PermissionsProvider>
<SocketProvider> <SocketProvider>
<RealtimeToasts /> <RealtimeToasts />
{/* Desktop shell hidden by CSS on mobile */} {/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background"> <div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
portRoles={portRoles} portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email, name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email, email: session.user.email,
}} }}
ports={ports}
/> />
<div className="flex-1 flex flex-col overflow-hidden min-w-0"> <div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar <Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email, email: session.user.email,
}} }}
/> />
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main> <main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
{children}
</main>
</div> </div>
</div> </div>
{/* Mobile shell hidden by CSS on desktop */} {/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout> <MobileLayout>{children}</MobileLayout>
</SocketProvider> </SocketProvider>
</PermissionsProvider> </PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
}, },
}; };
export default async function PortalLayout({ export default async function PortalLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
// This layout wraps all portal routes including login/verify // This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt // We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down login/verify pages handle their own // to get the session and pass it down - login/verify pages handle their own
// redirect logic independently. // redirect logic independently.
const session = await getPortalSession().catch(() => null); const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
{session && ( {session && (
<> <>
<PortalHeader <PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalNav /> <PortalNav />
</> </>
)} )}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}> <main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
{children}
</main>
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm <PasswordSetForm
endpoint="/api/portal/auth/activate" endpoint="/api/portal/auth/activate"
title="Activate your account" title="Activate your account"
description="Welcome choose a password to finish setting up your client portal account." description="Welcome - choose a password to finish setting up your client portal account."
successTitle="Account activated" successTitle="Account activated"
successDescription="You can now sign in with your new password." successDescription="You can now sign in with your new password."
submitLabel="Activate account" submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
try { try {
// Always returns 200 caller never sees whether email exists. // Always returns 200 - caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', { await fetch('/api/portal/auth/forgot-password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
<span className="font-medium text-gray-900">General Interest</span> <span className="font-medium text-gray-900">General Interest</span>
)} )}
{interest.berthArea && ( {interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span> <span className="text-sm text-gray-400">- {interest.berthArea}</span>
)} )}
</div> </div>
{interest.leadCategory && ( {interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span> <span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && ( {r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span> <span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
)} )}
</div> </div>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">

View File

@@ -1,20 +1,51 @@
import type { Metadata, Viewport } from 'next';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports'; import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider'; import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider'; import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/** /**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no * Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar the scanner is its own contained surface. Adds the PWA * topbar - the scanner is its own contained surface. PWA manifest +
* manifest link + theme color so iOS/Android pick up "Add to Home * iOS web-app meta tags are emitted via Next.js's metadata/viewport
* Screen". Auth check matches the dashboard layout so unauthorized * exports so React doesn't try to render a second `<head>` mid-tree
* users still bounce to /login. * (which throws hydration errors in the App Router). Auth check
* matches the dashboard layout so unauthorized users still bounce.
*/ */
export async function generateMetadata({
params,
}: {
params: Promise<{ portSlug: string }>;
}): Promise<Metadata> {
const { portSlug } = await params;
return {
manifest: `/${portSlug}/scan/manifest.webmanifest`,
appleWebApp: {
capable: true,
title: 'PN Scanner',
statusBarStyle: 'default',
},
other: {
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
// covers iOS only; this preserves the existing PWA hint for Chrome.
'mobile-web-app-capable': 'yes',
},
};
}
export const viewport: Viewport = {
themeColor: '#3a7bc8',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default async function ScannerLayout({ export default async function ScannerLayout({
children, children,
params, params,
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
return ( return (
<QueryProvider> <QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}> <PortProvider ports={[port]} defaultPortId={port.id}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<div className="min-h-[100dvh] bg-background">{children}</div> <div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider> </PortProvider>
</QueryProvider> </QueryProvider>

View File

@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
const portName = port?.name ?? 'Port Nimara'; const portName = port?.name ?? 'Port Nimara';
const manifest = { const manifest = {
name: `${portName} Scanner`, name: `${portName} - Scanner`,
short_name: 'Scanner', short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`, description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`, start_url: `/${portSlug}/scan`,

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell'; import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Scan receipt Port Nimara', title: 'Scan receipt - Port Nimara',
}; };
export default function ScanPage() { export default function ScanPage() {

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
/** /**
* Liveness probe confirms the Next.js process is responding. * Liveness probe - confirms the Next.js process is responding.
* *
* Returns 200 unconditionally; if the process is wedged or has crashed * Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO * the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint a transient downstream blip should drop the * checks in this endpoint - a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness). * pod from the load balancer (readiness), not restart the pod (liveness).
* *
* For deep dependency checks, hit `/api/ready` instead. * For deep dependency checks, hit `/api/ready` instead.

View File

@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
// Keep the helper aligned with that. // Keep the helper aligned with that.
type Tx = typeof db; type Tx = typeof db;
// POST /api/public/interests unauthenticated public interest registration. // POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company + // Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction. // membership, all inside a single transaction.
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest'; const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup safe // Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction. // to do outside the transaction.
let berthId: string | null = null; let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null; let resolvedMooringNumber: string | null = data.mooringNumber ?? null;

View File

@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
} }
/** /**
* POST /api/public/residential-inquiries unauthenticated entry point for * POST /api/public/residential-inquiries - unauthenticated entry point for
* the public website's residential interest form. Creates a * the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a * `residential_clients` row and an opening `residential_interests` row in a
* single transaction. * single transaction.
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId }); emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId }); emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking failures shouldn't 500 the // Send notification emails (non-blocking - failures shouldn't 500 the
// public form). // public form).
void sendResidentialNotifications({ void sendResidentialNotifications({
portId, portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
}); });
await sendEmail(data.email, confirmation.subject, confirmation.html); await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert pull recipients from system_settings if configured; // Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available. // fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({ const recipientsRow = await db.query.systemSettings.findFirst({
where: and( where: and(

View File

@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { ports } from '@/lib/db/schema/ports';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { env } from '@/lib/env';
import { logger } from '@/lib/logger';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
/**
* POST /api/public/website-inquiries
*
* Capture endpoint for the marketing website's dual-write. The website
* server (`/server/api/register.ts`, `/server/api/contact.ts`) calls this
* AFTER its existing NocoDB write succeeds, sending the same payload as a
* server-to-server fire-and-forget POST. The CRM stores the raw payload
* in `website_submissions` for later analysis / promotion to entities.
*
* Auth: shared-secret in `X-Webhook-Secret` header, timing-safe compared
* against `WEBSITE_INTAKE_SECRET`. If the env var is unset on this
* instance, the endpoint refuses every request with 503 - the correct
* posture for dev/staging that hasn't been wired up yet.
*
* Idempotency: payload carries a `submission_id` UUID. The unique index
* on `website_submissions.submission_id` makes redelivery a no-op; the
* handler returns 200 + the existing record's id instead of erroring.
*
* No emails / no `interests` rows are created here. The endpoint's job is
* pure data capture. A separate "promote" step (future) will turn captured
* submissions into proper `clients` + `interests` rows once we trust the
* pipeline.
*/
const SubmissionSchema = z.object({
submission_id: z.string().uuid(),
kind: z.enum(['berth_inquiry', 'residence_inquiry', 'contact_form']),
payload: z.record(z.unknown()),
legacy_nocodb_id: z.string().optional(),
/** Defaults to port-nimara since that's currently the only port with a
* public marketing site. Future ports can override per-submission. */
port_slug: z.string().default('port-nimara'),
});
function verifySecret(header: string | null): boolean {
const expected = env.WEBSITE_INTAKE_SECRET;
if (!expected) return false;
if (!header) return false;
// Timing-safe compare requires equal-length buffers; pad to whichever is
// longer so an early-exit on length mismatch can't leak the secret length.
const a = Buffer.from(header);
const b = Buffer.from(expected);
const pad = Buffer.alloc(Math.max(a.length, b.length));
const aPad = Buffer.concat([a, pad]).subarray(0, pad.length);
const bPad = Buffer.concat([b, pad]).subarray(0, pad.length);
return timingSafeEqual(aPad, bPad) && a.length === b.length;
}
export async function POST(req: NextRequest) {
// Refuse outright if the CRM hasn't been wired up - safer than letting
// unauthenticated traffic in just because the env var was forgotten.
if (!env.WEBSITE_INTAKE_SECRET) {
return NextResponse.json(
{ error: 'Website intake is not configured on this server.' },
{ status: 503 },
);
}
// Auth gate - shared secret in header, timing-safe compare.
const secretHeader = req.headers.get('x-webhook-secret');
if (!verifySecret(secretHeader)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Rate limit. All website-side traffic shares the website's egress IP,
// so we use a dedicated bucket sized to accommodate normal traffic
// (500/hr) rather than the 5/hr publicForm bucket meant for individual
// human submissions. The shared-secret header is the real abuse
// boundary; this limiter is just a backstop if the secret ever leaks.
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
const rl = await checkRateLimit(ip, rateLimiters.websiteIntake);
if (!rl.allowed) {
const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000));
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
);
}
// Parse + validate body. Reject anything that doesn't conform — the
// website is a known caller; a malformed payload signals tampering.
let parsed;
try {
const body = await req.json();
parsed = SubmissionSchema.parse(body);
} catch (err) {
return NextResponse.json(
{ error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' },
{ status: 400 },
);
}
// Resolve port. We require the slug to exist; can't capture submissions
// for a port the CRM doesn't know about.
const [port] = await db
.select({ id: ports.id })
.from(ports)
.where(eq(ports.slug, parsed.port_slug))
.limit(1);
if (!port) {
// Don't echo the input slug back in the error - generic message is
// sufficient and avoids the input-reflection pattern that complicates
// log-injection / audit reviews. The slug is logged server-side
// for debugging.
logger.warn(
{ portSlug: parsed.port_slug, submissionId: parsed.submission_id },
'website-inquiry rejected: unknown port',
);
return NextResponse.json({ error: 'Unknown port' }, { status: 400 });
}
// Idempotent insert. Two parallel requests carrying the same submission_id
// could both pass any pre-check, so we don't pre-check at all - the unique
// index on submission_id is the source of truth, and `onConflictDoNothing`
// keeps the second request's INSERT from raising 23505. When the conflict
// hits, `returning()` yields zero rows and we look up the existing row to
// return its id, mirroring the first-delivery shape so the website never
// sees a difference between fresh and dup.
const insertResult = await db
.insert(websiteSubmissions)
.values({
portId: port.id,
submissionId: parsed.submission_id,
kind: parsed.kind,
payload: parsed.payload,
legacyNocodbId: parsed.legacy_nocodb_id ?? null,
sourceIp: ip,
userAgent: req.headers.get('user-agent') ?? null,
})
.onConflictDoNothing({ target: websiteSubmissions.submissionId })
.returning({ id: websiteSubmissions.id });
if (insertResult[0]) {
logger.info(
{
submissionId: parsed.submission_id,
kind: parsed.kind,
portSlug: parsed.port_slug,
legacyNocodbId: parsed.legacy_nocodb_id,
},
'website inquiry captured',
);
return NextResponse.json({ id: insertResult[0].id, deduped: false });
}
// Conflict path: row already exists. Fetch its id so the response shape
// stays identical regardless of which request "won" the race.
const existing = await db
.select({ id: websiteSubmissions.id })
.from(websiteSubmissions)
.where(eq(websiteSubmissions.submissionId, parsed.submission_id))
.limit(1);
if (existing[0]) {
return NextResponse.json({ id: existing[0].id, deduped: true });
}
// Should be unreachable - the conflict means a row exists, so the lookup
// above should always find it. If it doesn't (e.g. simultaneous DELETE),
// surface a 500 explicitly rather than silently 200ing a missing id.
logger.error(
{ submissionId: parsed.submission_id },
'website-inquiry conflict but row not found on lookup',
);
return NextResponse.json({ error: 'Insert failed' }, { status: 500 });
}

View File

@@ -21,7 +21,7 @@ interface ReadyResponse {
} }
/** /**
* Readiness probe verifies that every backing service this process * Readiness probe - verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the * needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a * load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for). * pod restart (that's what `/api/health` is for).

View File

@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
* exercised by the realapi socket fanout test. * exercised by the realapi socket fanout test.
* *
* Requires super_admin or per-port admin permissions; the engine itself * Requires super_admin or per-port admin permissions; the engine itself
* is idempotent duplicate runs only re-evaluate, never duplicate rows. * is idempotent - duplicate runs only re-evaluate, never duplicate rows.
*/ */
export const POST = withAuth(async (_req, ctx) => { export const POST = withAuth(async (_req, ctx) => {
try { try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { checkDocumensoHealth } from '@/lib/services/documenso-client'; import { checkDocumensoHealth } from '@/lib/services/documenso-client';
/** /**
* Admin probe calls Documenso /api/v1/health using the port's effective * Admin probe - calls Documenso /api/v1/health using the port's effective
* config. Used by the "Test connection" button on /admin/documenso. * config. Used by the "Test connection" button on /admin/documenso.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
import { NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import type { AuthContext } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { clients, clientMergeCandidates } from '@/lib/db/schema/clients';
import { errorResponse, NotFoundError } from '@/lib/errors';
import {
listPendingMergeCandidates,
mergeClients,
type MergeFieldChoices,
} from '@/lib/services/client-merge.service';
/**
* GET /api/v1/admin/duplicates
*
* Pending merge candidates for the current port, sorted by score.
* Each row hydrates its two client summaries so the review-queue UI
* can render side-by-side cards without an N+1 fetch.
*/
export async function listHandler(_req: Request, ctx: AuthContext): Promise<NextResponse> {
try {
const pairs = await listPendingMergeCandidates(ctx.portId);
if (pairs.length === 0) return NextResponse.json({ data: [] });
const ids = Array.from(new Set(pairs.flatMap((p) => [p.clientAId, p.clientBId])));
const clientRows = await db
.select({
id: clients.id,
fullName: clients.fullName,
archivedAt: clients.archivedAt,
mergedIntoClientId: clients.mergedIntoClientId,
createdAt: clients.createdAt,
})
.from(clients)
.where(inArray(clients.id, ids));
const clientById = new Map(clientRows.map((c) => [c.id, c]));
const data = pairs
.map((p) => {
const a = clientById.get(p.clientAId);
const b = clientById.get(p.clientBId);
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
// Skip pairs where one side has already been merged or archived.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return {
id: p.id,
score: p.score,
reasons: p.reasons,
createdAt: p.createdAt,
clientA: { id: a.id, fullName: a.fullName, createdAt: a.createdAt },
clientB: { id: b.id, fullName: b.fullName, createdAt: b.createdAt },
};
})
.filter((row): row is NonNullable<typeof row> => row !== null);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}
/**
* POST /api/v1/admin/duplicates/[id]/merge
*
* Body: { winnerId: string, fieldChoices?: MergeFieldChoices }
*
* Confirms a merge candidate. The winner is the one the user picked
* to keep; the other side becomes the loser. Calls into the merge
* service which is the only path that touches client_merge_log.
*/
export async function confirmMergeHandler(
req: Request,
ctx: AuthContext,
params: { id?: string },
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const body = (await req.json().catch(() => ({}))) as {
winnerId?: string;
fieldChoices?: MergeFieldChoices;
};
if (!body.winnerId) {
return NextResponse.json({ error: 'winnerId required' }, { status: 400 });
}
const [candidate] = await db
.select()
.from(clientMergeCandidates)
.where(
and(
eq(clientMergeCandidates.id, id),
eq(clientMergeCandidates.portId, ctx.portId),
eq(clientMergeCandidates.status, 'pending'),
),
);
if (!candidate) throw new NotFoundError('Merge candidate');
const loserId =
body.winnerId === candidate.clientAId
? candidate.clientBId
: body.winnerId === candidate.clientBId
? candidate.clientAId
: null;
if (!loserId) {
return NextResponse.json(
{ error: 'winnerId must match one of the candidate clients' },
{ status: 400 },
);
}
const result = await mergeClients({
winnerId: body.winnerId,
loserId,
mergedBy: ctx.userId,
fieldChoices: body.fieldChoices,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}
/**
* POST /api/v1/admin/duplicates/[id]/dismiss
*
* Mark a merge candidate as dismissed. The background scoring job
* skips dismissed pairs on subsequent runs (a future score increase
* can re-create them).
*/
export async function dismissHandler(
_req: Request,
ctx: AuthContext,
params: { id?: string },
): Promise<NextResponse> {
try {
const id = params.id ?? '';
const result = await db
.update(clientMergeCandidates)
.set({
status: 'dismissed',
resolvedAt: new Date(),
resolvedBy: ctx.userId,
})
.where(
and(
eq(clientMergeCandidates.id, id),
eq(clientMergeCandidates.portId, ctx.portId),
eq(clientMergeCandidates.status, 'pending'),
),
)
.returning({ id: clientMergeCandidates.id });
if (result.length === 0) throw new NotFoundError('Merge candidate');
return NextResponse.json({ data: { id: result[0]!.id, status: 'dismissed' } });
} catch (error) {
return errorResponse(error);
}
}

View File

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

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth( export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => { withPermission('admin', 'manage_users', async (_req, ctx) => {
try { try {
// crm_user_invites is a global table (no per-port column) invites // crm_user_invites is a global table (no per-port column) - invites
// mint better-auth users that may later be assigned roles in any // mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director // port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags // enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
apiKey: z.string().min(1), apiKey: z.string().min(1),
}); });
// `manage_settings`-gated for parity with the parent OCR settings route // `manage_settings`-gated for parity with the parent OCR settings route -
// triggers outbound AI provider auth requests using a caller-supplied key. // triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth( export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => { withPermission('admin', 'manage_settings', async (req) => {

View File

@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
* POST /api/v1/admin/templates/preview * POST /api/v1/admin/templates/preview
* *
* Generates a preview PDF from a TipTap JSON content block. * Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } the client can render this * Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab. * in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
* *
* Body: * Body:
* content: TipTap JSON document * content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions * sampleData?: Record<string, string> - variable substitutions
*/ */
export const POST = withAuth( export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => { withPermission('documents', 'manage', async (req, _ctx) => {
@@ -60,10 +60,7 @@ export const POST = withAuth(
/** /**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc. * Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/ */
function substituteInDoc( function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
if (node.type === 'text' && node.text) { if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) }; return { ...node, text: substituteVariables(node.text, data) };
} }

View File

@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { testConnection } from '@/lib/services/umami.service';
/**
* POST /api/v1/admin/umami/test - admin-only Umami connection check.
*
* Returns `{ data: { ok: true, visitors } }` on success or
* `{ data: { ok: false, error } }` on failure. Mirrors the shape used by
* the Documenso health endpoint so the existing test-button UI pattern
* just works.
*/
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (_req, ctx) => {
try {
const result = await testConnection(ctx.portId);
return NextResponse.json({ data: result });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ data: { ok: false, error } });
}
}),
);

View File

@@ -9,6 +9,7 @@ import {
getRevenueBreakdown, getRevenueBreakdown,
type DateRange, type DateRange,
type MetricBase, type MetricBase,
type PresetDateRange,
} from '@/lib/services/analytics.service'; } from '@/lib/services/analytics.service';
const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = { const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<unknown>> = {
@@ -18,17 +19,69 @@ const METRICS: Record<MetricBase, (portId: string, range: DateRange) => Promise<
lead_source_attribution: getLeadSourceAttribution, lead_source_attribution: getLeadSourceAttribution,
}; };
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
export const GET = withAuth( export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url); const url = new URL(req.url);
const metric = url.searchParams.get('metric') as MetricBase | null; const metric = url.searchParams.get('metric') as MetricBase | null;
const range = (url.searchParams.get('range') ?? '30d') as DateRange; const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (!metric || !(metric in METRICS)) { if (!metric || !(metric in METRICS)) {
return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 });
} }
if (!ALL_RANGES.includes(range)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); let range: DateRange;
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return NextResponse.json(
{ error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return NextResponse.json(
{ error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' },
{ status: 400 },
);
}
if (fromParam > toParam) {
return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 });
}
// Round-trip date check: regex passes "9999-13-99" or "2026-02-31"
// (rolls over silently when handed to `new Date`). Re-serialize and
// confirm it matches the input to catch invalid calendar values.
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return NextResponse.json(
{ error: `\`${label}\` is not a valid calendar date` },
{ status: 400 },
);
}
}
// Backstop against the occupancy-timeline N+1 query loop. Each day
// in the range issues its own DB query, so a multi-year custom
// range would saturate the connection pool. 365 days is a generous
// ceiling for analytical queries; if a longer span is needed, the
// service should be restructured to use `generate_series` instead
// of a JS loop.
const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime();
const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime();
if ((toMs - fromMs) / 86_400_000 > 365) {
return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 });
}
range = { kind: 'custom', from: fromParam, to: toParam };
} else {
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return NextResponse.json({ error: 'Invalid range' }, { status: 400 });
}
range = rawRange as PresetDateRange;
} }
const data = await METRICS[metric](ctx.portId, range); const data = await METRICS[metric](ctx.portId, range);

View File

@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers'; import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', getHandler)); export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper the required permission depends // PATCH cannot use `withPermission` wrapper - the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the // on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed. // handler after the body is parsed.
export const PATCH = withAuth(patchHandler); export const PATCH = withAuth(patchHandler);

View File

@@ -40,7 +40,7 @@ export const PUT = withAuth(
}), }),
); );
// PATCH /api/v1/berths/[id]/waiting-list reorder a single entry // PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
export const PATCH = withAuth( export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => { withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try { try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service'; import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors'; import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options lightweight list for selects/comboboxes // GET /api/v1/berths/options - lightweight list for selects/comboboxes
export const GET = withAuth( export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => { withPermission('berths', 'view', async (req, ctx) => {
try { try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
* *
* Admin creates a portal account for a client and triggers the activation * Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the * email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 the admin can resend the activation via * email, returns 409 - the admin can resend the activation via
* ?action=resend. * ?action=resend.
*/ */
export const POST = withAuth( export const POST = withAuth(

View File

@@ -0,0 +1,160 @@
import { NextResponse } from 'next/server';
import { and, eq, inArray } from 'drizzle-orm';
import type { AuthContext } from '@/lib/api/helpers';
import { db } from '@/lib/db';
import { clients, clientContacts } from '@/lib/db/schema/clients';
import { interests } from '@/lib/db/schema/interests';
import { errorResponse } from '@/lib/errors';
import { findClientMatches, type MatchCandidate } from '@/lib/dedup/find-matches';
import { normalizeEmail, normalizeName, normalizePhone } from '@/lib/dedup/normalize';
import type { CountryCode } from '@/lib/i18n/countries';
/**
* GET /api/v1/clients/match-candidates
*
* Query parameters (any combination):
* email Free-text email; gets normalized server-side.
* phone Free-text phone; gets normalized to E.164 server-side.
* name Free-text full name; used for surname-token blocking.
* country Optional ISO country hint (default: AI for Port Nimara).
*
* Returns the top candidates that scored above the soft-warn threshold,
* each with a small client summary the form's suggestion card can
* render. Confidence tiers and rules are applied server-side from the
* port's `system_settings` (when wired) or sensible defaults otherwise.
*
* Used by `useDedupSuggestion` in the new-client form. Debounced on
* the client; this endpoint must be cheap (single port pool fetch +
* an in-memory dedup pass).
*/
export async function getMatchCandidatesHandler(
req: Request,
ctx: AuthContext,
): Promise<NextResponse> {
try {
const url = new URL(req.url);
const rawEmail = url.searchParams.get('email');
const rawPhone = url.searchParams.get('phone');
const rawName = url.searchParams.get('name');
const country = (url.searchParams.get('country') ?? 'AI') as CountryCode;
const email = rawEmail ? normalizeEmail(rawEmail) : null;
const phoneResult = rawPhone ? normalizePhone(rawPhone, country) : null;
const nameResult = rawName ? normalizeName(rawName) : null;
// If the caller didn't give us anything useful to match on, return empty
// - short-circuit rather than scan every client for nothing.
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
return NextResponse.json({ data: [] });
}
// Build the input candidate.
const input: MatchCandidate = {
id: '__incoming__',
fullName: nameResult?.display ?? null,
surnameToken: nameResult?.surnameToken ?? null,
emails: email ? [email] : [],
phonesE164: phoneResult?.e164 ? [phoneResult.e164] : [],
countryIso: country,
};
// Fetch the live pool for this port. We keep this O(N) over clients
// since the dedup library does its own blocking; for ports with
// thousands of clients we can later restrict by surname-token /
// contact lookups, but for current scale the simple full-pool fetch
// is fine.
const liveClients = await db
.select({
id: clients.id,
fullName: clients.fullName,
nationalityIso: clients.nationalityIso,
})
.from(clients)
.where(and(eq(clients.portId, ctx.portId)));
if (liveClients.length === 0) {
return NextResponse.json({ data: [] });
}
const clientIds = liveClients.map((c) => c.id);
const contactRows = await db
.select({
clientId: clientContacts.clientId,
channel: clientContacts.channel,
value: clientContacts.value,
valueE164: clientContacts.valueE164,
})
.from(clientContacts)
.where(inArray(clientContacts.clientId, clientIds));
// Group contacts by client for the candidate map.
const emailsByClient = new Map<string, string[]>();
const phonesByClient = new Map<string, string[]>();
for (const c of contactRows) {
if (c.channel === 'email') {
const arr = emailsByClient.get(c.clientId) ?? [];
arr.push(c.value.toLowerCase());
emailsByClient.set(c.clientId, arr);
} else if (c.channel === 'phone' || c.channel === 'whatsapp') {
if (c.valueE164) {
const arr = phonesByClient.get(c.clientId) ?? [];
arr.push(c.valueE164);
phonesByClient.set(c.clientId, arr);
}
}
}
const pool: MatchCandidate[] = liveClients.map((c) => {
const named = normalizeName(c.fullName);
return {
id: c.id,
fullName: c.fullName,
surnameToken: named.surnameToken ?? null,
emails: emailsByClient.get(c.id) ?? [],
phonesE164: phonesByClient.get(c.id) ?? [],
countryIso: (c.nationalityIso as CountryCode | null) ?? null,
};
});
const matches = findClientMatches(input, pool, {
highScore: 90,
mediumScore: 50,
});
// Only return medium+ - low-confidence noise isn't useful at the
// create-form layer (background scoring queue picks those up).
const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) {
return NextResponse.json({ data: [] });
}
// Pull a quick summary for each surfaced candidate so the suggestion
// card has enough to render ("Marcus Laurent · 2 interests · last
// contact 9d ago").
const summarizedIds = useful.map((m) => m.candidate.id);
const interestCounts = await db
.select({ clientId: interests.clientId })
.from(interests)
.where(inArray(interests.clientId, summarizedIds));
const interestsByClient = new Map<string, number>();
for (const r of interestCounts) {
interestsByClient.set(r.clientId, (interestsByClient.get(r.clientId) ?? 0) + 1);
}
const data = useful.map((m) => ({
clientId: m.candidate.id,
fullName: m.candidate.fullName,
score: m.score,
confidence: m.confidence,
reasons: m.reasons,
interestCount: interestsByClient.get(m.candidate.id) ?? 0,
emails: m.candidate.emails,
phonesE164: m.candidate.phonesE164,
}));
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}

View File

@@ -0,0 +1,4 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { getMatchCandidatesHandler } from './handlers';
export const GET = withAuth(withPermission('clients', 'view', getMatchCandidatesHandler));

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service'; import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({ const mergeSchema = z.object({
/** Surviving expense id typically the row's existing `duplicateOf` pointer. */ /** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1), targetId: z.string().min(1),
}); });

View File

@@ -51,7 +51,7 @@ export const POST = withAuth(
}); });
} }
// Per-port budget gate refuse the call before we spend tokens // Per-port budget gate - refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request // when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the // would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking. // success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
}); });
} catch (err) { } catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed'); logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup degrade to manual entry rather than 500-ing. // Provider hiccup - degrade to manual entry rather than 500-ing.
return NextResponse.json({ return NextResponse.json({
data: { data: {
parsed: EMPTY, parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try { try {
const body = await parseBody(req, createFolderSchema); const body = await parseBody(req, createFolderSchema);
// Sanitize path no null bytes, no path traversal // Sanitize path - no null bytes, no path traversal
const safePath = body.path const safePath = body.path
.replace(/\x00/g, '') .replace(/\x00/g, '')
.replace(/\.\.\//g, '') .replace(/\.\.\//g, '')

View File

@@ -20,7 +20,7 @@ export const GET = withAuth(
}), }),
); );
// POST /api/v1/interests/[id]/recommendations add manual recommendation // POST /api/v1/interests/[id]/recommendations - add manual recommendation
export const POST = withAuth( export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => { withPermission('interests', 'edit', async (req, ctx, params) => {
try { try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = { const OUTCOME_LABELS: Record<string, string> = {
won: 'Won', won: 'Won',
lost_other_marina: 'Lost went to another marina', lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost unqualified', lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost no response', lost_no_response: 'Lost - no response',
cancelled: 'Cancelled', cancelled: 'Cancelled',
}; };
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? ''; const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed'; const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? ''; const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label} ${reason}` : `Marked as ${label}`; return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
} }
if (type === 'outcome_cleared') { if (type === 'outcome_cleared') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? ''; const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system'; const auto = userId === 'system';
if (auto) { if (auto) {
return reason ? `${stage} (auto-advanced ${reason})` : `Stage advanced to ${stage}`; return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
} }
return reason ? `Stage changed to ${stage} ${reason}` : `Stage changed to ${stage}`; return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
} }
if (action === 'update' && newValue?.pipelineStage) { if (action === 'update' && newValue?.pipelineStage) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
const results = await search(ctx.portId, q); const results = await search(ctx.portId, q);
// Fire-and-forget do not await // Fire-and-forget - do not await
saveRecentSearch(ctx.userId, ctx.portId, q); saveRecentSearch(ctx.userId, ctx.portId, q);
return NextResponse.json(results); return NextResponse.json(results);

View File

@@ -0,0 +1,113 @@
import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { ALL_RANGES, type DateRange, type PresetDateRange } from '@/lib/analytics/range';
import {
getActiveVisitors,
getMetric,
getPageviewsSeries,
getStats,
type UmamiMetricType,
} from '@/lib/services/umami.service';
/**
* GET /api/v1/website-analytics?metric=...&range=...
*
* Single endpoint serving every Umami widget on the /website-analytics
* page. Mirrors the shape of /api/v1/analytics so the client side can
* reuse the same hook pattern.
*
* Supported metrics:
* - stats → KPI tiles (pageviews, visitors, visits, etc.)
* - pageviews → time-series for the trend chart
* - active → live "right now" count (range ignored)
* - top-{type} → top pages/referrers/countries/etc.
* where type ∈ url|referrer|country|browser|
* os|device|event
*
* Range param accepts the same presets as /api/v1/analytics, plus
* `range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD`.
*/
const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/;
const TOP_METRIC_RX = /^top-(url|referrer|country|browser|os|device|event)$/;
function parseRange(req: NextRequest): DateRange | { error: string } {
const url = new URL(req.url);
const rawRange = url.searchParams.get('range') ?? '30d';
const fromParam = url.searchParams.get('from');
const toParam = url.searchParams.get('to');
if (rawRange === 'custom') {
if (!fromParam || !toParam) {
return { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' };
}
if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) {
return { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' };
}
if (fromParam > toParam) {
return { error: '`from` must be on or before `to`' };
}
// Round-trip date check (catches "2026-02-31" type rollovers).
for (const [label, raw] of [
['from', fromParam],
['to', toParam],
] as const) {
const d = new Date(`${raw}T00:00:00.000Z`);
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) {
return { error: `\`${label}\` is not a valid calendar date` };
}
}
return { kind: 'custom', from: fromParam, to: toParam };
}
if (!ALL_RANGES.includes(rawRange as PresetDateRange)) {
return { error: 'Invalid range' };
}
return rawRange as PresetDateRange;
}
export const GET = withAuth(
withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => {
const url = new URL(req.url);
const metric = url.searchParams.get('metric');
if (!metric) {
return NextResponse.json({ error: 'Missing metric' }, { status: 400 });
}
const rangeOrError = parseRange(req);
if (typeof rangeOrError === 'object' && 'error' in rangeOrError) {
return NextResponse.json({ error: rangeOrError.error }, { status: 400 });
}
const range = rangeOrError as DateRange;
try {
let data: unknown;
if (metric === 'stats') {
data = await getStats(ctx.portId, range);
} else if (metric === 'pageviews') {
data = await getPageviewsSeries(ctx.portId, range);
} else if (metric === 'active') {
data = await getActiveVisitors(ctx.portId);
} else if (TOP_METRIC_RX.test(metric)) {
const type = metric.replace(/^top-/, '') as UmamiMetricType;
const limit = Number(url.searchParams.get('limit') ?? 10);
data = await getMetric(ctx.portId, range, type, limit);
} else {
return NextResponse.json({ error: `Unknown metric: ${metric}` }, { status: 400 });
}
// `data === null` from the service means Umami isn't configured for
// this port - surface that explicitly so the UI can render a
// "configure your credentials" empty state instead of a chart.
if (data === null) {
return NextResponse.json({ error: 'umami_not_configured', metric, range }, { status: 200 });
}
return NextResponse.json({ metric, range, data });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message, metric, range }, { status: 502 });
}
}),
);

View File

@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
}); });
if (existing) { if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook skipping'); logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 }); return NextResponse.json({ ok: true }, { status: 200 });
} }
} catch (err) { } catch (err) {

View File

@@ -93,7 +93,7 @@
@apply bg-background text-foreground font-sans antialiased; @apply bg-background text-foreground font-sans antialiased;
} }
/* Wave watermark subtle background texture for auth pages */ /* Wave watermark - subtle background texture for auth pages */
.wave-watermark { .wave-watermark {
background-image: repeating-linear-gradient( background-image: repeating-linear-gradient(
135deg, 135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback * from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs. * handles desktop browsers resized below lg (1024px), or stripped UAs.
* *
* IMPORTANT: only `display: none` rules are emitted we never set a positive * IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would * display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade). * be overridden by `display: block` (same specificity, later cascade).
*/ */
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
display: none !important; display: none !important;
} }
} }
/*
* Recharts focus-ring suppression.
*
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
* them (the library adds tabindex on chart sectors / paths). The global
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
* which on a chart surface reads as a stray rectangle around the plot
* area. Hover/tooltip already handles chart interactivity, so suppress
* the ring entirely here.
*
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
* it during purge (an earlier copy inside `@layer base` was being
* silently removed at build time, leaving the ring intact).
*/
div.recharts-wrapper:focus,
div.recharts-wrapper:focus-visible,
svg.recharts-surface:focus,
svg.recharts-surface:focus-visible,
div.recharts-responsive-container:focus,
div.recharts-responsive-container:focus-visible,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
outline: none !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
--tw-ring-color: transparent !important;
--tw-ring-offset-color: transparent !important;
}

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

@@ -87,7 +87,7 @@ export function AuditLogList() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
// Filter state debounce text inputs. // Filter state - debounce text inputs.
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [entityType, setEntityType] = useState<string>('all'); const [entityType, setEntityType] = useState<string>('all');
const [action, setAction] = useState<string>('all'); const [action, setAction] = useState<string>('all');
@@ -215,7 +215,7 @@ export function AuditLogList() {
</span> </span>
); );
} }
return <span className="text-xs text-muted-foreground"></span>; return <span className="text-xs text-muted-foreground">-</span>;
}, },
}, },
{ {
@@ -245,7 +245,7 @@ export function AuditLogList() {
<PageHeader <PageHeader
title="Audit Log" title="Audit Log"
eyebrow="Admin" eyebrow="Admin"
description="Every state change in this port fully searchable." description="Every state change in this port - fully searchable."
variant="gradient" variant="gradient"
/> />

View File

@@ -59,12 +59,7 @@ const FIELD_TYPE_LABELS: Record<string, string> = {
// ─── Component ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({ export function CustomFieldForm({ open, onOpenChange, field, onSuccess }: CustomFieldFormProps) {
open,
onOpenChange,
field,
onSuccess,
}: CustomFieldFormProps) {
const isEdit = !!field; const isEdit = !!field;
// Form state // Form state
@@ -72,9 +67,7 @@ export function CustomFieldForm({
const [fieldName, setFieldName] = useState(field?.fieldName ?? ''); const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? ''); const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text'); const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
const [selectOptions, setSelectOptions] = useState<string[]>( const [selectOptions, setSelectOptions] = useState<string[]>(field?.selectOptions ?? []);
field?.selectOptions ?? [],
);
const [newOption, setNewOption] = useState(''); const [newOption, setNewOption] = useState('');
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false); const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0); const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
@@ -169,13 +162,11 @@ export function CustomFieldForm({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{isEdit ? 'Edit Custom Field' : 'New Custom Field'}</DialogTitle>
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2"> <form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Entity Type create only */} {/* Entity Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-entity-type">Entity Type</Label> <Label htmlFor="cf-entity-type">Entity Type</Label>
{isEdit ? ( {isEdit ? (
@@ -198,7 +189,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Field Name create only */} {/* Field Name - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-name"> <Label htmlFor="cf-field-name">
Field Name Field Name
@@ -232,7 +223,7 @@ export function CustomFieldForm({
/> />
</div> </div>
{/* Field Type create only */} {/* Field Type - create only */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="cf-field-type">Field Type</Label> <Label htmlFor="cf-field-type">Field Type</Label>
{isEdit ? ( {isEdit ? (
@@ -260,7 +251,7 @@ export function CustomFieldForm({
)} )}
</div> </div>
{/* Select Options visible when fieldType = 'select' */} {/* Select Options - visible when fieldType = 'select' */}
{fieldType === 'select' && ( {fieldType === 'select' && (
<div className="space-y-2"> <div className="space-y-2">
<Label>Options</Label> <Label>Options</Label>
@@ -302,11 +293,7 @@ export function CustomFieldForm({
{/* Is Required */} {/* Is Required */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="cf-is-required">Required field</Label> <Label htmlFor="cf-is-required">Required field</Label>
<Switch <Switch id="cf-is-required" checked={isRequired} onCheckedChange={setIsRequired} />
id="cf-is-required"
checked={isRequired}
onCheckedChange={setIsRequired}
/>
</div> </div>
{/* Sort Order */} {/* Sort Order */}

View File

@@ -11,13 +11,7 @@ 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 { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -61,20 +55,13 @@ interface TemplateFormProps {
onSuccess: () => void; onSuccess: () => void;
} }
export function TemplateForm({ export function TemplateForm({ open, onOpenChange, template, onSuccess }: TemplateFormProps) {
open,
onOpenChange,
template,
onSuccess,
}: TemplateFormProps) {
const isEdit = !!template; const isEdit = !!template;
const [name, setName] = useState(template?.name ?? ''); const [name, setName] = useState(template?.name ?? '');
const [type, setType] = useState(template?.templateType ?? 'other'); const [type, setType] = useState(template?.templateType ?? 'other');
const [contentJson, setContentJson] = useState( const [contentJson, setContentJson] = useState(
template?.content template?.content ? JSON.stringify(template.content, null, 2) : EMPTY_DOC,
? JSON.stringify(template.content, null, 2)
: EMPTY_DOC,
); );
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -86,7 +73,7 @@ export function TemplateForm({
setJsonError(null); setJsonError(null);
return true; return true;
} catch { } catch {
setJsonError('Invalid JSON check syntax.'); setJsonError('Invalid JSON - check syntax.');
return false; return false;
} }
} }
@@ -115,8 +102,7 @@ export function TemplateForm({
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (err: unknown) { } catch (err: unknown) {
const message = const message = err instanceof Error ? err.message : 'Something went wrong';
err instanceof Error ? err.message : 'Something went wrong';
setError(message); setError(message);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -127,9 +113,7 @@ export function TemplateForm({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl"> <SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>{isEdit ? 'Edit Template' : 'New Document Template'}</SheetTitle>
{isEdit ? 'Edit Template' : 'New Document Template'}
</SheetTitle>
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6"> <form onSubmit={handleSubmit} className="mt-6 space-y-6">
@@ -145,7 +129,7 @@ export function TemplateForm({
/> />
</div> </div>
{/* Type only on create */} {/* Type - only on create */}
{!isEdit && ( {!isEdit && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-type">Document Type</Label> <Label htmlFor="template-type">Document Type</Label>
@@ -166,15 +150,11 @@ export function TemplateForm({
{/* TipTap JSON Content */} {/* TipTap JSON Content */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="template-content"> <Label htmlFor="template-content">Document Content (TipTap JSON)</Label>
Document Content (TipTap JSON)
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Paste or edit TipTap JSON. Use{' '} Paste or edit TipTap JSON. Use{' '}
<code className="rounded bg-muted px-1 text-xs"> <code className="rounded bg-muted px-1 text-xs">{'{{variable.key}}'}</code> tokens for
{'{{variable.key}}'} dynamic content.
</code>{' '}
tokens for dynamic content.
</p> </p>
<textarea <textarea
id="template-content" id="template-content"
@@ -187,9 +167,7 @@ export function TemplateForm({
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false} spellCheck={false}
/> />
{jsonError && ( {jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
<p className="text-xs text-destructive">{jsonError}</p>
)}
</div> </div>
{/* Available Variables Reference */} {/* Available Variables Reference */}
@@ -200,19 +178,15 @@ export function TemplateForm({
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2"> <div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
{TEMPLATE_VARIABLES.map((v) => ( {TEMPLATE_VARIABLES.map((v) => (
<div key={v.key} className="text-xs"> <div key={v.key} className="text-xs">
<code className="rounded bg-muted px-1"> <code className="rounded bg-muted px-1">{`{{${v.key}}}`}</code>{' '}
{`{{${v.key}}}`} <span className="text-muted-foreground">- {v.label}</span>
</code>{' '}
<span className="text-muted-foreground"> {v.label}</span>
</div> </div>
))} ))}
</div> </div>
</details> </details>
{error && ( {error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive"> <p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
{error}
</p>
)} )}
<SheetFooter> <SheetFooter>
@@ -225,11 +199,7 @@ export function TemplateForm({
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={loading || !!jsonError}> <Button type="submit" disabled={loading || !!jsonError}>
{loading {loading ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Template'}
? 'Saving…'
: isEdit
? 'Save Changes'
: 'Create Template'}
</Button> </Button>
</SheetFooter> </SheetFooter>
</form> </form>

View File

@@ -9,12 +9,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog'; import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TemplateForm } from './template-form'; import { TemplateForm } from './template-form';
import { TemplateVersionHistory } from './template-version-history'; import { TemplateVersionHistory } from './template-version-history';
@@ -57,9 +52,7 @@ export function TemplateList() {
const fetchTemplates = useCallback(async () => { const fetchTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await apiFetch<{ data: AdminTemplate[] }>( const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates');
'/api/v1/admin/templates',
);
setTemplates(res.data); setTemplates(res.data);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -122,9 +115,7 @@ export function TemplateList() {
accessorKey: 'version', accessorKey: 'version',
header: 'Version', header: 'Version',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">v{row.original.version}</span>
v{row.original.version}
</span>
), ),
}, },
{ {
@@ -151,10 +142,7 @@ export function TemplateList() {
header: '', header: '',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<TemplatePreview <TemplatePreview content={row.original.content} templateName={row.original.name} />
content={row.original.content}
templateName={row.original.name}
/>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -177,9 +165,7 @@ export function TemplateList() {
title={row.original.isActive ? 'Deactivate' : 'Activate'} title={row.original.isActive ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(row.original)} onClick={() => handleToggleActive(row.original)}
> >
<span className="text-xs"> <span className="text-xs">{row.original.isActive ? 'Off' : 'On'}</span>
{row.original.isActive ? 'Off' : 'On'}
</span>
</Button> </Button>
<ConfirmationDialog <ConfirmationDialog
trigger={ trigger={
@@ -233,9 +219,7 @@ export function TemplateList() {
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}> <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto"> <SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>Version History - {historyTemplate?.name}</SheetTitle>
Version History {historyTemplate?.name}
</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="mt-6"> <div className="mt-6">
{historyTemplate && ( {historyTemplate && (

View File

@@ -3,12 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye, ExternalLink } from 'lucide-react'; import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme'; import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
@@ -24,9 +19,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Build sample data from TEMPLATE_VARIABLES examples // Build sample data from TEMPLATE_VARIABLES examples
const sampleData = Object.fromEntries( const sampleData = Object.fromEntries(TEMPLATE_VARIABLES.map((v) => [v.key, v.example]));
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
);
async function handlePreview() { async function handlePreview() {
if (!content) { if (!content) {
@@ -74,14 +67,9 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
<DialogContent className="max-w-4xl"> <DialogContent className="max-w-4xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle>Preview {templateName}</DialogTitle> <DialogTitle>Preview - {templateName}</DialogTitle>
{pdfBase64 && ( {pdfBase64 && (
<Button <Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="mr-6">
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="mr-6"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" /> <ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open in new tab Open in new tab
</Button> </Button>
@@ -100,9 +88,7 @@ export function TemplatePreview({ content, templateName }: TemplatePreviewProps)
)} )}
{error && !loading && ( {error && !loading && (
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive"> <div className="rounded bg-destructive/10 p-4 text-sm text-destructive">{error}</div>
{error}
</div>
)} )}
{pdfBase64 && !loading && ( {pdfBase64 && !loading && (

View File

@@ -0,0 +1,215 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowRight, GitMerge, X } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface CandidatePair {
id: string;
score: number;
reasons: string[];
createdAt: string;
clientA: { id: string; fullName: string; createdAt: string };
clientB: { id: string; fullName: string; createdAt: string };
}
/**
* Admin review queue for the dedup background scoring job.
*
* Lists every pending merge candidate (pairs where score >=
* `dedup_review_queue_threshold`). For each pair the admin can:
* - Pick a winner via the side-by-side card → confirms a merge
* - Dismiss → removes from the queue (a future score increase
* re-creates the pair on the next scoring run)
*
* Only minimal merge UI here: the user picks which side is the winner
* (no per-field choice), and the loser archives. A richer side-by-side
* field-merge dialog is a future enhancement.
*/
export function DuplicatesReviewQueue() {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ data: CandidatePair[] }>({
queryKey: ['admin', 'duplicates'],
queryFn: () => apiFetch<{ data: CandidatePair[] }>('/api/v1/admin/duplicates'),
});
const pairs = data?.data ?? [];
return (
<div className="space-y-4">
<PageHeader
title="Duplicate clients"
description={
pairs.length === 0
? 'No pending pairs to review.'
: `${pairs.length} pair${pairs.length === 1 ? '' : 's'} flagged for review.`
}
/>
{isLoading ? (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
) : pairs.length === 0 ? (
<EmptyState
title="All clear"
description="The background scoring job hasn't surfaced any potential duplicates yet."
/>
) : (
<ul className="space-y-3">
{pairs.map((pair) => (
<li key={pair.id}>
<CandidateRow pair={pair} queryClient={queryClient} />
</li>
))}
</ul>
)}
</div>
);
}
function CandidateRow({
pair,
queryClient,
}: {
pair: CandidatePair;
queryClient: ReturnType<typeof useQueryClient>;
}) {
const [busy, setBusy] = useState<'merge' | 'dismiss' | null>(null);
const [winnerId, setWinnerId] = useState<string>(pair.clientA.id);
const mergeMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/admin/duplicates/${pair.id}/merge`, {
method: 'POST',
body: { winnerId },
}),
onSuccess: () => {
const loserName =
winnerId === pair.clientA.id ? pair.clientB.fullName : pair.clientA.fullName;
const winnerName =
winnerId === pair.clientA.id ? pair.clientA.fullName : pair.clientB.fullName;
toast.success(`Merged "${loserName}" into "${winnerName}"`);
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Merge failed'),
onSettled: () => setBusy(null),
});
const dismissMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/admin/duplicates/${pair.id}/dismiss`, { method: 'POST' }),
onSuccess: () => {
toast.message('Dismissed');
queryClient.invalidateQueries({ queryKey: ['admin', 'duplicates'] });
},
onError: (err) => toast.error(err instanceof Error ? err.message : 'Dismiss failed'),
onSettled: () => setBusy(null),
});
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-baseline justify-between gap-3">
<div>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
score {pair.score}
</span>{' '}
<span className="text-xs text-muted-foreground">{pair.reasons.join(' · ')}</span>
</div>
<span className="text-xs text-muted-foreground">
flagged {new Date(pair.createdAt).toLocaleDateString()}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_auto_1fr]">
<ClientCard
client={pair.clientA}
isSelected={winnerId === pair.clientA.id}
onSelect={() => setWinnerId(pair.clientA.id)}
/>
<div className="flex items-center justify-center text-muted-foreground">
<ArrowRight className="size-4" aria-hidden />
</div>
<ClientCard
client={pair.clientB}
isSelected={winnerId === pair.clientB.id}
onSelect={() => setWinnerId(pair.clientB.id)}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setBusy('merge');
mergeMutation.mutate();
}}
disabled={busy !== null}
>
<GitMerge className="mr-1 size-3.5" aria-hidden />
Merge into selected
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setBusy('dismiss');
dismissMutation.mutate();
}}
disabled={busy !== null}
>
<X className="mr-1 size-3.5" aria-hidden />
Dismiss
</Button>
<p className="text-xs text-muted-foreground">
The unselected card becomes the loser; its interests + contacts move to the selected
client and the original is archived.
</p>
</div>
</div>
);
}
function ClientCard({
client,
isSelected,
onSelect,
}: {
client: CandidatePair['clientA'];
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'rounded-md border p-3 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-border hover:bg-muted/40',
)}
>
<p className="text-sm font-medium">{client.fullName}</p>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Created {new Date(client.createdAt).toLocaleDateString()}
</p>
{isSelected ? (
<span className="mt-1 inline-block rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
KEEP
</span>
) : null}
</button>
);
}

View File

@@ -117,7 +117,7 @@ export function InvitationsManager() {
{invites.map((i) => ( {invites.map((i) => (
<tr key={i.id} className="border-t"> <tr key={i.id} className="border-t">
<td className="px-3 py-2 font-medium">{i.email}</td> <td className="px-3 py-2 font-medium">{i.email}</td>
<td className="px-3 py-2 text-muted-foreground">{i.name ?? ''}</td> <td className="px-3 py-2 text-muted-foreground">{i.name ?? '-'}</td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.isSuperAdmin ? 'Super admin' : 'Standard user'} {i.isSuperAdmin ? 'Super admin' : 'Standard user'}
</td> </td>
@@ -163,7 +163,7 @@ export function InvitationsManager() {
)} )}
</div> </div>
) : ( ) : (
<span className="text-xs text-muted-foreground"></span> <span className="text-xs text-muted-foreground">-</span>
)} )}
</td> </td>
</tr> </tr>

View File

@@ -160,7 +160,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
Enable AI receipt parsing for this port Enable AI receipt parsing for this port
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Off by default. Receipts are read on-device using Tesseract.js accurate enough for Off by default. Receipts are read on-device using Tesseract.js - accurate enough for
most receipts and incurs no AI cost. Turning this on lets the configured provider most receipts and incurs no AI cost. Turning this on lets the configured provider
re-parse receipts server-side for higher accuracy on hard-to-read images. re-parse receipts server-side for higher accuracy on hard-to-read images.
</p> </p>
@@ -214,7 +214,7 @@ function SettingsBlock({ scope, title, description, showUseGlobal }: SettingsBlo
id={`apiKey-${scope}`} id={`apiKey-${scope}`}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
autoComplete="off" autoComplete="off"
placeholder={hasKey ? '•••••• (saved leave blank to keep)' : 'sk-…'} placeholder={hasKey ? '•••••• (saved - leave blank to keep)' : 'sk-…'}
value={apiKey} value={apiKey}
onChange={(e) => { onChange={(e) => {
setApiKey(e.target.value); setApiKey(e.target.value);

View File

@@ -33,7 +33,7 @@ const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' |
}; };
function formatDate(ts: number | undefined): string { function formatDate(ts: number | undefined): string {
if (!ts) return ''; if (!ts) return '-';
return new Date(ts).toLocaleString(); return new Date(ts).toLocaleString();
} }
@@ -42,7 +42,7 @@ function truncateId(id: string): string {
} }
function truncateReason(reason: string | undefined): string { function truncateReason(reason: string | undefined): string {
if (!reason) return ''; if (!reason) return '-';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason; return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
} }
@@ -184,7 +184,7 @@ export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="flex items-center justify-between text-sm text-muted-foreground">
<span> <span>
{total} total jobs page {page} of {totalPages} {total} total jobs - page {page} of {totalPages}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button

View File

@@ -95,7 +95,7 @@ export function RoleList() {
accessorKey: 'description', accessorKey: 'description',
header: 'Description', header: 'Description',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-muted-foreground text-sm">{row.original.description ?? ''}</span> <span className="text-muted-foreground text-sm">{row.original.description ?? '-'}</span>
), ),
}, },
{ {

View File

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

View File

@@ -108,7 +108,7 @@ export function UserCard({ user, onEdit, onRemove, isRemoving }: UserCardProps)
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Email subtitle only when display name is shown as title */} {/* Email subtitle - only when display name is shown as title */}
{user.displayName && user.displayName !== user.email ? ( {user.displayName && user.displayName !== user.email ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground"> <p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden /> <Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />

View File

@@ -57,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
useEffect(() => { useEffect(() => {
void load(page); void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhookId, page]); }, [webhookId, page]);
if (loading && deliveries.length === 0) { if (loading && deliveries.length === 0) {
@@ -87,13 +87,9 @@ export function WebhookDeliveryLog({ webhookId }: Props) {
<TableRow key={d.id}> <TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell> <TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell> <TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}> <Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>{d.status}</Badge>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
</TableCell> </TableCell>
<TableCell className="text-sm">{d.responseStatus ?? '-'}</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell> <TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{d.deliveredAt {d.deliveredAt

View File

@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface TestResponse {
ok: boolean;
visitors?: number;
error?: string;
}
/**
* Hits POST /api/v1/admin/umami/test which calls Umami's `/api/websites/:id/
* active` to verify auth + websiteId in one request. On success, shows the
* live visitor count as proof we got real data back.
*/
export function UmamiTestButton() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<TestResponse | null>(null);
async function runTest() {
setPending(true);
setResult(null);
try {
const res = await apiFetch<{ data: TestResponse }>('/api/v1/admin/umami/test', {
method: 'POST',
});
setResult(res.data);
if (res.data.ok) {
toast.success(`Umami reachable - ${res.data.visitors ?? 0} active visitor(s) right now`);
} else {
toast.error(res.data.error ?? 'Umami test failed');
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Test failed';
setResult({ ok: false, error: message });
toast.error(message);
} finally {
setPending(false);
}
}
return (
<div className="flex items-center gap-3">
{result &&
(result.ok ? (
<span className="flex items-center text-xs text-green-600">
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Connected ({result.visitors ?? 0} active)
</span>
) : (
<span className="flex items-center text-xs text-destructive">
<XCircle className="mr-1 h-3.5 w-3.5" />
{result.error ?? 'Failed'}
</span>
))}
<Button type="button" size="sm" variant="outline" onClick={runTest} disabled={pending}>
{pending ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Test connection
</Button>
</div>
);
}

View File

@@ -16,8 +16,8 @@ import { useAlertCount, useAlertList, useAlertRealtime } from './use-alerts';
export function AlertBell() { export function AlertBell() {
const portSlug = useUIStore((s) => s.currentPortSlug); const portSlug = useUIStore((s) => s.currentPortSlug);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Count is cheap (one aggregate query) fire on every page so the badge stays live. // Count is cheap (one aggregate query) - fire on every page so the badge stays live.
// List is heavier only fetch when the popover is actually open. // List is heavier - only fetch when the popover is actually open.
const { data: count } = useAlertCount(); const { data: count } = useAlertCount();
const { data: list, isLoading } = useAlertList('open', open); const { data: list, isLoading } = useAlertList('open', open);
useAlertRealtime(); useAlertRealtime();

View File

@@ -22,7 +22,10 @@ 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" // Natural height - the parent aside no longer forces 100% of the
// dashboard grid row, so the rail can sit compactly under Reminders
// without bleeding down into the Recent Activity panel below.
className="flex flex-col gap-3"
> >
<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>
@@ -53,7 +56,7 @@ export function AlertRail() {
href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)} href={portSlug ? (`/${portSlug}/alerts` as never) : ('/alerts' as never)}
className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent" className="block rounded-lg border border-dashed border-border px-3 py-2 text-center text-xs text-muted-foreground transition-colors hover:bg-accent"
> >
+{overflow} more view all +{overflow} more - view all
</Link> </Link>
) : null} ) : null}
</div> </div>

View File

@@ -56,7 +56,12 @@ function ActionsCell({ row }: { row: { original: BerthRow } }) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span> <span className="sr-only">Open menu</span>
</Button> </Button>
@@ -89,14 +94,12 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'mooringNumber', accessorKey: 'mooringNumber',
header: 'Mooring #', header: 'Mooring #',
cell: ({ row }) => ( cell: ({ row }) => <span className="font-medium">{row.original.mooringNumber}</span>,
<span className="font-medium">{row.original.mooringNumber}</span>
),
}, },
{ {
accessorKey: 'area', accessorKey: 'area',
header: 'Area', header: 'Area',
cell: ({ row }) => row.original.area ?? '', cell: ({ row }) => row.original.area ?? '-',
}, },
{ {
accessorKey: 'status', accessorKey: 'status',
@@ -109,7 +112,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const { lengthM, widthM } = row.original; const { lengthM, widthM } = row.original;
if (!lengthM && !widthM) return ''; if (!lengthM && !widthM) return '-';
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`; return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
}, },
}, },
@@ -118,7 +121,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
header: 'Price', header: 'Price',
cell: ({ row }) => { cell: ({ row }) => {
const { price, priceCurrency } = row.original; const { price, priceCurrency } = row.original;
if (!price) return ''; if (!price) return '-';
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
style: 'currency', style: 'currency',
currency: priceCurrency || 'USD', currency: priceCurrency || 'USD',
@@ -129,8 +132,7 @@ export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{ {
accessorKey: 'tenureType', accessorKey: 'tenureType',
header: 'Tenure', header: 'Tenure',
cell: ({ row }) => cell: ({ row }) => (row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'),
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
}, },
{ {
id: 'tags', id: 'tags',

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,41 +234,159 @@ 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 items-center gap-2"> <div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<Switch <div className="flex items-center gap-2">
id="widthIsMinimum" <Switch
checked={watch('widthIsMinimum') ?? false} id="widthIsMinimum"
onCheckedChange={(v) => setValue('widthIsMinimum', v)} checked={watch('widthIsMinimum') ?? false}
/> onCheckedChange={(v) => setValue('widthIsMinimum', v)}
<Label htmlFor="widthIsMinimum">Width is minimum</Label> />
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
</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>
</div> </div>
<Separator /> <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>
<Separator />
{/* Price */} {/* Price */}
<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">
@@ -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

@@ -168,7 +168,7 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
href={`/${portSlug}/interests/${i.id}` as never} href={`/${portSlug}/interests/${i.id}` as never}
className="hover:text-brand" className="hover:text-brand"
> >
{i.clientName ?? ''} {i.clientName ?? '-'}
</Link> </Link>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -177,10 +177,10 @@ export function BerthInterestsTab({ berthId }: BerthInterestsTabProps) {
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : ''} {i.leadCategory ? (CATEGORY_LABELS[i.leadCategory] ?? i.leadCategory) : '-'}
</td> </td>
<td className="px-3 py-2 text-muted-foreground"> <td className="px-3 py-2 text-muted-foreground">
{i.source ? (SOURCE_LABELS[i.source] ?? i.source) : ''} {i.source ? (SOURCE_LABELS[i.source] ?? i.source) : '-'}
</td> </td>
<td className="px-3 py-2 text-xs text-muted-foreground"> <td className="px-3 py-2 text-xs text-muted-foreground">
{new Date(i.createdAt).toLocaleDateString()} {new Date(i.createdAt).toLocaleDateString()}

View File

@@ -36,7 +36,7 @@ export function BerthList() {
title="Berths" title="Berths"
description="View and manage berth allocations" description="View and manage berth allocations"
variant="gradient" variant="gradient"
// No "New" button berths are import-only // No "New" button - berths are import-only
/> />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">

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',
@@ -74,7 +109,7 @@ function OverviewTab({ berth }: { berth: BerthData }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Sales pulse top-of-page so reps doing berth-level triage can see {/* Sales pulse - top-of-page so reps doing berth-level triage can see
who's interested + how warm without clicking into the Interests tab. */} who's interested + how warm without clicking into the Interests tab. */}
<BerthInterestPulse berthId={berth.id} /> <BerthInterestPulse berthId={berth.id} />
@@ -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 }>;
} }
@@ -68,7 +70,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary); const primary = row.original.contacts?.find((c) => c.isPrimary);
if (!primary) return <span className="text-muted-foreground"></span>; if (!primary) return <span className="text-muted-foreground">-</span>;
return ( return (
<span className="text-sm"> <span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span> <span className="text-muted-foreground capitalize">{primary.channel}: </span>
@@ -84,7 +86,7 @@ export function getClientColumns({
cell: ({ getValue }) => { cell: ({ getValue }) => {
const iso = getValue() as string | null; const iso = getValue() as string | null;
return ( return (
<span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : ''}</span> <span className="text-muted-foreground">{iso ? getCountryName(iso, 'en') : '-'}</span>
); );
}, },
}, },
@@ -94,7 +96,7 @@ export function getClientColumns({
header: 'Source', header: 'Source',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const source = getValue() as string | null; const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>; if (!source) return <span className="text-muted-foreground">-</span>;
return ( return (
<Badge variant="outline" className="capitalize text-xs"> <Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source} {SOURCE_LABELS[source] ?? source}
@@ -109,7 +111,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.yachtCount ?? 0; const c = row.original.yachtCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -124,7 +126,7 @@ export function getClientColumns({
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original.companyCount ?? 0; const c = row.original.companyCount ?? 0;
return c === 0 ? ( return c === 0 ? (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
) : ( ) : (
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{c} {c}
@@ -138,7 +140,7 @@ export function getClientColumns({
enableSorting: false, enableSorting: false,
cell: ({ row }) => { cell: ({ row }) => {
const clientTags = row.original.tags ?? []; const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground"></span>; if (clientTags.length === 0) return <span className="text-muted-foreground">-</span>;
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{clientTags.slice(0, 3).map((tag) => ( {clientTags.slice(0, 3).map((tag) => (

View File

@@ -33,7 +33,7 @@ interface ClientCompaniesTabProps {
function formatSince(startDate: string | Date): string { function formatSince(startDate: string | Date): string {
const d = typeof startDate === 'string' ? new Date(startDate) : startDate; const d = typeof startDate === 'string' ? new Date(startDate) : startDate;
if (Number.isNaN(d.getTime())) return ''; if (Number.isNaN(d.getTime())) return '-';
return format(d, 'MMM d, yyyy'); return format(d, 'MMM d, yyyy');
} }
@@ -87,7 +87,7 @@ export function ClientCompaniesTab({ clientId: _clientId, companies }: ClientCom
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">

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} onClick={() => setArchiveOpen(true)}
clientName={client.fullName} aria-label={isArchived ? 'Restore client' : 'Archive client'}
defaultEmail={primaryEmail?.value} title={isArchived ? 'Restore client' : 'Archive client'}
/> className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground/70 transition-colors',
'hover:bg-foreground/5 hover:text-foreground',
isArchived ? 'hover:text-foreground' : 'hover:text-destructive',
)} )}
<GdprExportButton clientId={client.id} /> >
<Button {isArchived ? <RotateCcw className="size-4" /> : <Archive className="size-4" />}
variant={isArchived ? 'outline' : 'outline'} </button>
size="sm"
onClick={() => setArchiveOpen(true)}
>
{isArchived ? (
<>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore
</>
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)}
</Button>
</div>
</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

@@ -23,6 +23,7 @@ import { TagPicker } from '@/components/shared/tag-picker';
import { CountryCombobox } from '@/components/shared/country-combobox'; import { CountryCombobox } from '@/components/shared/country-combobox';
import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
import { PhoneInput } from '@/components/shared/phone-input'; import { PhoneInput } from '@/components/shared/phone-input';
import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-panel';
import { apiFetch } from '@/lib/api/client'; import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients'; import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
import type { CountryCode } from '@/lib/i18n/countries'; import type { CountryCode } from '@/lib/i18n/countries';
@@ -30,6 +31,12 @@ import type { CountryCode } from '@/lib/i18n/countries';
interface ClientFormProps { interface ClientFormProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Optional callback fired when the dedup suggestion panel reports
* the user picked an existing client. The form closes; parent is
* responsible for navigating to the existing client's detail page
* or opening the create-interest dialog pre-filled with that
* clientId. Skipped in edit mode. */
onUseExistingClient?: (clientId: string) => void;
/** If provided, form is in edit mode */ /** If provided, form is in edit mode */
client?: { client?: {
id: string; id: string;
@@ -53,7 +60,7 @@ interface ClientFormProps {
}; };
} }
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) { export function ClientForm({ open, onOpenChange, client, onUseExistingClient }: ClientFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const isEdit = !!client; const isEdit = !!client;
@@ -143,6 +150,26 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</SheetHeader> </SheetHeader>
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6"> <form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
{/* Dedup suggestion - only on the create path. Watches the
live form values for email / phone / name and surfaces
an existing client when one matches. The user can
attach the new interest to that client instead of
creating a duplicate. */}
{!isEdit ? (
<DedupSuggestionPanel
email={watch('contacts')?.find((c) => c?.channel === 'email')?.value ?? null}
phone={
watch('contacts')?.find((c) => c?.channel === 'phone' || c?.channel === 'whatsapp')
?.valueE164 ?? null
}
name={watch('fullName') ?? null}
onUseExisting={(match) => {
onUseExistingClient?.(match.clientId);
onOpenChange(false);
}}
/>
) : null}
{/* Basic Info */} {/* Basic Info */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide"> <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
@@ -339,10 +366,6 @@ export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
</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

@@ -106,8 +106,8 @@ function lastActivityLabel(interests: ClientInterestRow[]): string | null {
interface PipelineSummaryProps { interface PipelineSummaryProps {
clientId: string; clientId: string;
/** /**
* `hero` single-line pulse for the detail header (highest active stage only). * `hero` - single-line pulse for the detail header (highest active stage only).
* `panel` compact list of every active interest, for the Overview tab. * `panel` - compact list of every active interest, for the Overview tab.
*/ */
variant?: 'hero' | 'panel'; variant?: 'hero' | 'panel';
} }

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,82 +135,82 @@ function OverviewTab({
}; };
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-6">
{/* Personal Info */} <div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<div className="space-y-1"> <ClientPipelineSummary clientId={clientId} variant="panel" />
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Preferred Language">
<InlineEditableField
value={client.preferredLanguage}
onSave={save('preferredLanguage')}
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div> </div>
{/* Contacts */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1"> {/* Personal Info */}
<h3 className="text-sm font-medium mb-2">Contact Details</h3> <div className="space-y-1">
<ContactsEditor clientId={clientId} contacts={client.contacts ?? []} /> <h3 className="text-sm font-medium mb-2">Personal Information</h3>
</div> <dl>
<EditableRow label="Full Name">
<InlineEditableField value={client.fullName} onSave={save('fullName')} />
</EditableRow>
<EditableRow label="Nationality">
<InlineCountryField
value={client.nationalityIso ?? null}
onSave={async (iso) => {
await mutation.mutateAsync({ nationalityIso: iso });
}}
data-testid="client-nationality-inline"
/>
</EditableRow>
<EditableRow label="Timezone">
<InlineTimezoneField
value={client.timezone}
countryHint={(client.nationalityIso as CountryCode | null) ?? null}
onSave={async (tz) => {
await mutation.mutateAsync({ timezone: tz });
}}
data-testid="client-timezone-inline"
/>
</EditableRow>
<EditableRow label="Preferred Contact">
<InlineEditableField
variant="select"
options={CONTACT_METHOD_OPTIONS}
value={client.preferredContactMethod}
onSave={save('preferredContactMethod')}
/>
</EditableRow>
</dl>
</div>
{/* Source */} {/* Contacts */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3> <h3 className="text-sm font-medium mb-2">Contact Details</h3>
<dl> <ContactsEditor clientId={clientId} contacts={client.contacts ?? []} />
<EditableRow label="Source"> </div>
<InlineEditableField
variant="select"
options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */} {/* Source */}
<div className="space-y-1"> <div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3> <h3 className="text-sm font-medium mb-2">Source</h3>
<InlineTagEditor <dl>
endpoint={`/api/v1/clients/${clientId}/tags`} <EditableRow label="Source">
currentTags={client.tags ?? []} <InlineEditableField
invalidateKey={['clients', clientId]} variant="select"
/> options={SOURCE_OPTIONS}
value={client.source}
onSave={save('source')}
/>
</EditableRow>
<EditableRow label="Source Details">
<InlineEditableField value={client.sourceDetails} onSave={save('sourceDetails')} />
</EditableRow>
</dl>
</div>
{/* Tags */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Tags</h3>
<InlineTagEditor
endpoint={`/api/v1/clients/${clientId}/tags`}
currentTags={client.tags ?? []}
invalidateKey={['clients', clientId]}
/>
</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

@@ -74,9 +74,9 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
</Link> </Link>
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : ''} {y.lengthFt && y.widthFt ? `${y.lengthFt} × ${y.widthFt} ft` : '-'}
</TableCell> </TableCell>
<TableCell>{y.hullNumber ?? ''}</TableCell> <TableCell>{y.hullNumber ?? '-'}</TableCell>
<TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell> <TableCell className="capitalize">{y.status.replace('_', ' ')}</TableCell>
</TableRow> </TableRow>
))} ))}

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,42 +223,60 @@ 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
<InlineEditableField action cluster (tag + star + trash) so the user can focus on
value={ the form without chips fighting for space.
contact.label && contact.label.toLowerCase() !== 'primary' ? contact.label : null (b) contact.value - when the value is empty (stale import row,
} aborted edit), hide just the tag + Make-primary star;
emptyText="Add tag" neither makes sense without a value. The trash icon stays
placeholder="work, home…" so the user can clean up the empty entry.
onSave={async (v) => { On touch (no hover), trash is always rendered; on desktop it
await onUpdate({ label: v }); 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
value={
contact.label && contact.label.toLowerCase() !== 'primary'
? contact.label
: null
}
emptyText="Add tag"
placeholder="work, home…"
onSave={async (v) => {
await onUpdate({ label: v });
}}
/>
</div>
<button
type="button"
onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn(
'rounded p-1 transition-colors hover:bg-background/60',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
>
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button>
</>
) : null}
<button
type="button"
onClick={onRemove}
title="Remove"
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" />
</button>
</div> </div>
) : null}
<button
type="button"
onClick={togglePrimary}
title={contact.isPrimary ? 'Primary' : 'Make primary'}
className={cn(
'p-1 rounded hover:bg-background/60 transition-colors',
contact.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
)}
>
<Star className={cn('h-3.5 w-3.5', contact.isPrimary && 'fill-current')} />
</button>
<button
type="button"
onClick={onRemove}
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"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</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,12 +428,14 @@ function NewContactForm({
}} }}
/> />
<Button type="button" size="sm" onClick={submit} disabled={submitDisabled}> <div className="ml-auto flex gap-2">
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'} <Button type="button" size="sm" onClick={submit} disabled={submitDisabled}>
</Button> {saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}> </Button>
Cancel <Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
</Button> Cancel
</Button>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,183 @@
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Briefcase, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import { cn } from '@/lib/utils';
interface MatchData {
clientId: string;
fullName: string;
score: number;
confidence: 'high' | 'medium' | 'low';
reasons: string[];
interestCount: number;
emails: string[];
phonesE164: string[];
}
interface DedupSuggestionPanelProps {
/** Free-text inputs from the in-flight new-client form. The panel
* debounces them and queries /api/v1/clients/match-candidates. */
email?: string | null;
phone?: string | null;
name?: string | null;
/** Caller wants to attach the new interest to an existing client
* rather than creating a new one. The form switches to
* interest-only mode and pre-fills the client. */
onUseExisting: (match: MatchData) => void;
/** User explicitly said "create new anyway." Hide the panel until
* they change input again. */
onDismiss?: () => void;
}
/**
* Surfaces existing clients that match the form's in-flight inputs.
*
* Renders nothing while inputs are short / no useful match found.
* On a high-confidence match, the panel interrupts visually with a
* solid border and a primary "Use this client" button.
*
* Wired into the new-client form. Skipped in edit mode.
*/
export function DedupSuggestionPanel({
email,
phone,
name,
onUseExisting,
onDismiss,
}: DedupSuggestionPanelProps) {
const [dismissed, setDismissed] = useState(false);
// Debounce inputs by 300ms so we don't fire on every keystroke. Keep
// the latest debounced values in component state.
const [debounced, setDebounced] = useState({
email: email ?? '',
phone: phone ?? '',
name: name ?? '',
});
useEffect(() => {
const t = setTimeout(() => {
setDebounced({ email: email ?? '', phone: phone ?? '', name: name ?? '' });
// Clear the dismissed flag when inputs change - the user typed
// something new, so the prior dismissal no longer applies.
setDismissed(false);
}, 300);
return () => clearTimeout(t);
}, [email, phone, name]);
const hasSomething =
debounced.email.length > 3 || debounced.phone.length > 3 || debounced.name.length > 2;
const { data, isFetching } = useQuery<{ data: MatchData[] }>({
queryKey: ['dedup-match-candidates', debounced],
queryFn: () => {
const params = new URLSearchParams();
if (debounced.email) params.set('email', debounced.email);
if (debounced.phone) params.set('phone', debounced.phone);
if (debounced.name) params.set('name', debounced.name);
return apiFetch<{ data: MatchData[] }>(`/api/v1/clients/match-candidates?${params}`);
},
enabled: hasSomething && !dismissed,
// Same query is fine to cache for a minute - moves are slow at this layer.
staleTime: 60_000,
});
if (dismissed) return null;
if (!hasSomething) return null;
if (isFetching && !data) return null;
const matches = data?.data ?? [];
if (matches.length === 0) return null;
const top = matches[0]!;
const isHigh = top.confidence === 'high';
return (
<div
className={cn(
'rounded-lg border p-3 mb-3 transition-colors',
isHigh
? 'border-amber-300 bg-amber-50/60 dark:bg-amber-950/30'
: 'border-border bg-muted/40',
)}
data-testid="dedup-suggestion"
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<AlertCircle
className={cn(
'size-5',
isHigh ? 'text-amber-700 dark:text-amber-400' : 'text-muted-foreground',
)}
aria-hidden
/>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-tight">
{isHigh
? 'This looks like an existing client'
: 'Possible match - check before creating'}
</p>
<div className="mt-2 rounded-md border bg-background/80 p-2.5">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{top.fullName}</p>
<span
className={cn(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
isHigh
? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100'
: 'bg-muted text-muted-foreground',
)}
>
{top.confidence}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{top.emails[0] ? <span className="truncate">{top.emails[0]}</span> : null}
{top.phonesE164[0] ? <span>{top.phonesE164[0]}</span> : null}
<span className="inline-flex items-center gap-1">
<Briefcase className="size-3" aria-hidden />
{top.interestCount} {top.interestCount === 1 ? 'interest' : 'interests'}
</span>
</div>
<p className="mt-1.5 text-[11px] text-muted-foreground">{top.reasons.join(' · ')}</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
onClick={() => onUseExisting(top)}
data-testid="dedup-use-existing"
>
Use this client
<ArrowRight className="ml-1 size-3.5" aria-hidden />
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
setDismissed(true);
onDismiss?.();
}}
data-testid="dedup-dismiss"
>
<X className="mr-1 size-3.5" aria-hidden />
Create new anyway
</Button>
{matches.length > 1 ? (
<span className="text-xs text-muted-foreground">
+{matches.length - 1} other possible{' '}
{matches.length - 1 === 1 ? 'match' : 'matches'}
</span>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -74,7 +74,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
}, },
}), }),
onSuccess: () => { onSuccess: () => {
toast.success('Export queued refresh in ~30 seconds'); toast.success('Export queued - refresh in ~30 seconds');
qc.invalidateQueries({ queryKey }); qc.invalidateQueries({ queryKey });
setEmailOverride(''); setEmailOverride('');
}, },
@@ -128,7 +128,7 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
Email the bundle when ready Email the bundle when ready
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sends a 7-day signed download link to the client&apos;s primary email or to the Sends a 7-day signed download link to the client&apos;s primary email - or to the
override below. override below.
</p> </p>
{emailToClient ? ( {emailToClient ? (

View File

@@ -122,7 +122,7 @@ export function AddMembershipDialog({ open, onOpenChange, companyId }: AddMember
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
let msg = err instanceof Error ? err.message : 'Failed to add membership'; let msg = err instanceof Error ? err.message : 'Failed to add membership';
// Detect 409 service returns a "membership already exists" message // Detect 409 - service returns a "membership already exists" message
if (/already exists/i.test(msg)) { if (/already exists/i.test(msg)) {
msg = 'This membership already exists (same client + role + start date).'; msg = 'This membership already exists (same client + role + start date).';
} }

View File

@@ -76,7 +76,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -87,7 +87,7 @@ export function getCompanyColumns({
enableSorting: false, enableSorting: false,
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue() as string | null; const value = getValue() as string | null;
if (!value) return <span className="text-muted-foreground"></span>; if (!value) return <span className="text-muted-foreground">-</span>;
return <span className="text-sm">{value}</span>; return <span className="text-sm">{value}</span>;
}, },
}, },
@@ -98,7 +98,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.memberCount ?? 0; const n = row.original.memberCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },
@@ -109,7 +109,7 @@ export function getCompanyColumns({
size: 88, size: 88,
cell: ({ row }) => { cell: ({ row }) => {
const n = row.original.yachtCount ?? 0; const n = row.original.yachtCount ?? 0;
if (n === 0) return <span className="text-muted-foreground"></span>; if (n === 0) return <span className="text-muted-foreground">-</span>;
return <Badge variant="secondary">{n}</Badge>; return <Badge variant="secondary">{n}</Badge>;
}, },
}, },

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

@@ -101,7 +101,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: CreateCompanyInput) => { mutationFn: async (data: CreateCompanyInput) => {
if (isEdit) { if (isEdit) {
// updateCompanySchema omits tagIds strip them from PATCH body. // updateCompanySchema omits tagIds - strip them from PATCH body.
const { tagIds: _tIds, ...rest } = data; const { tagIds: _tIds, ...rest } = data;
void _tIds; void _tIds;
await apiFetch(`/api/v1/companies/${company!.id}`, { await apiFetch(`/api/v1/companies/${company!.id}`, {
@@ -178,7 +178,7 @@ export function CompanyForm({ open, onOpenChange, company }: CompanyFormProps) {
value={watch('incorporationCountryIso')} value={watch('incorporationCountryIso')}
onChange={(iso) => { onChange={(iso) => {
setValue('incorporationCountryIso', iso ?? undefined); setValue('incorporationCountryIso', iso ?? undefined);
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
setValue('incorporationSubdivisionIso', undefined); setValue('incorporationSubdivisionIso', undefined);
}} }}
data-testid="company-incorp-country" data-testid="company-incorp-country"

View File

@@ -56,7 +56,7 @@ const ROLE_LABELS: Record<string, string> = {
}; };
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
if (!value) return ''; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString(); return date.toLocaleDateString();
@@ -201,14 +201,14 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
</TableCell> </TableCell>
<TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell> <TableCell>{ROLE_LABELS[m.role] ?? m.role}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[240px] truncate"> <TableCell className="text-sm text-muted-foreground max-w-[240px] truncate">
{m.roleDetail ?? ''} {m.roleDetail ?? '-'}
</TableCell> </TableCell>
<TableCell>{formatDate(m.startDate)}</TableCell> <TableCell>{formatDate(m.startDate)}</TableCell>
<TableCell> <TableCell>
{m.endDate ? ( {m.endDate ? (
formatDate(m.endDate) formatDate(m.endDate)
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -217,7 +217,7 @@ export function CompanyMembersTab({ companyId, portSlug }: CompanyMembersTabProp
Primary Primary
</Badge> </Badge>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -49,13 +49,13 @@ const STATUS_LABELS: Record<string, string> = {
function formatDimensions(y: OwnedYachtRow): string | null { function formatDimensions(y: OwnedYachtRow): string | null {
if (y.lengthFt || y.widthFt) { if (y.lengthFt || y.widthFt) {
const length = y.lengthFt ?? ''; const length = y.lengthFt ?? '-';
const width = y.widthFt ?? ''; const width = y.widthFt ?? '-';
return `${length} × ${width} ft`; return `${length} × ${width} ft`;
} }
if (y.lengthM || y.widthM) { if (y.lengthM || y.widthM) {
const length = y.lengthM ?? ''; const length = y.lengthM ?? '-';
const width = y.widthM ?? ''; const width = y.widthM ?? '-';
return `${length} × ${width} m`; return `${length} × ${width} m`;
} }
return null; return null;
@@ -129,14 +129,14 @@ export function CompanyOwnedYachtsTab({ companyId, portSlug }: CompanyOwnedYacht
{dims ? ( {dims ? (
<span className="text-sm">{dims}</span> <span className="text-sm">{dims}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{y.hullNumber ? ( {y.hullNumber ? (
<span className="text-sm">{y.hullNumber}</span> <span className="text-sm">{y.hullNumber}</span>
) : ( ) : (
<span className="text-muted-foreground"></span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -125,7 +125,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
<InlineCountryField <InlineCountryField
value={company.incorporationCountryIso} value={company.incorporationCountryIso}
onSave={async (iso) => { onSave={async (iso) => {
// Wipe subdivision when country flips codes are country-scoped. // Wipe subdivision when country flips - codes are country-scoped.
await mutation.mutateAsync({ await mutation.mutateAsync({
incorporationCountryIso: iso, incorporationCountryIso: iso,
incorporationSubdivisionIso: null, incorporationSubdivisionIso: null,
@@ -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')}
/> />
@@ -171,7 +175,7 @@ function OverviewTab({ companyId, company }: { companyId: string; company: Compa
variant="textarea" variant="textarea"
value={company.notes} value={company.notes}
onSave={save('notes')} onSave={save('notes')}
emptyText="No notes click to add" emptyText="No notes - click to add"
/> />
</div> </div>

View File

@@ -58,7 +58,7 @@ function ActivityFeedInner() {
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No recent activity yet your team&apos;s actions (interests created, stages changed, No recent activity yet - your team&apos;s actions (interests created, stages changed,
invoices sent) will appear here. invoices sent) will appear here.
</p> </p>
) : ( ) : (

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