Commit Graph

61 Commits

Author SHA1 Message Date
e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00
3a48150d13 feat(tenancies-p5): sidebar entry + 404 top-level page + API module gate
- Dashboard layout resolves tenanciesModuleByPort server-side (one
  isTenanciesModuleEnabled call per port the user has access to) and
  passes the map through AppShell → Sidebar. Atomic SSR — no
  flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
  entry (KeyRound icon, immediately below Berths) only renders when
  the currently-active port has the flag flipped on. Per-port live
  switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
  isTenanciesModuleEnabled and notFound() when disabled — guards
  against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
  prepended with assertTenanciesModuleEnabled — matches design §
  "All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
  (calls enableTenanciesModule after makePort) so the gate is
  satisfied. Affects 2 test files (16 tests retargeted).

Verified: tsc clean, 1493/1493 vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:23:06 +02:00
221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00
e4fb425d05 fix(layout): persist resolved viewport tier in cookie to kill SSR flicker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Successful in 5m33s
User reported: "when I refresh the page with this size viewport it
switches between tablet and desktop view." The root cause was the
two-step tier resolution:

  1. Server renders shell based on User-Agent (mobile vs desktop only).
  2. Client mounts with that hint, useEffect runs matchMedia, may flip.

When the UA says "desktop" but the viewport is actually 900px (so
matchMedia says "tablet"), the chrome visibly switches mid-render.
Most painful on macOS Safari dragged below 1024.

Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on
every matchMedia evaluation. The dashboard layout reads the cookie
and prefers it over the UA classifier for `initialFormFactor`. First
visit can still flicker (no cookie yet); every subsequent reload uses
the resolved tier and renders the correct chrome on first paint.

The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's
initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by
design — AppShell's useEffect resolves the actual tier client-side
from matchMedia). 'tablet' from the cookie collapses to 'desktop' on
SSR; AppShell's useEffect re-resolves to tablet immediately. The
fluent path on cookie hit is desktop -> tablet (no flicker because
both shells render the desktop tree; only the sidebar Sheet wrapper
differs, and that's invisible until opened).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:33:36 +02:00
355f242b8f fix(layout): topbar grid auto-expanded center column hid right buttons at 780-1280
Some checks failed
Build & Push Docker Images / lint (push) Successful in 2m37s
Build & Push Docker Images / build-and-push (push) Has been cancelled
User reported the search bar dropping to a second row + the top-right
buttons (+ New / Inbox / Avatar) going missing as they resized the
browser. Playwright probe confirmed: at every width 780-1280 the
search bar's intrinsic `max-w-2xl` (672px) forced the topbar's
center grid column to expand to that width, leaving the right
column too narrow to hold "+ New + Inbox + Avatar" without
overlapping the search OR going off-screen.

Two coordinated fixes:

1. Grid template `auto_1fr_auto` instead of `1fr_minmax(280,800)_1fr`.
   Side columns now size to their actual content (logo + breadcrumbs
   on the left; New + Inbox + Avatar on the right); the center
   column takes whatever's left. No more "intrinsic content forces
   the column to grow" behaviour.

2. Search wrapper max-width scales by tier: max-w-md (448px) at
   base, lg:max-w-xl (576px), xl:max-w-2xl (672px). Generous enough
   on wide screens, restrained enough on narrow ones so the side
   columns always get the space they need.

Verified via Playwright probe at 780/900/1023/1024/1100/1280 —
"+ New" button now lands inside the header at every width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:22:29 +02:00
2f1e1b5f3f fix(layout): unblock tablet topbar trigger + un-crush 1024 dashboard title
Two Bucket 1 quick-fixes from the 2026-05-22 visual audit, both
1-2-line CSS changes with outsized visual impact.

PageHeader stack point: lg → xl
The earlier sm → lg revision (commit 6d665d0) fixed the 768 tablet
crush but introduced a SECOND crush at exactly 1024: that's where
the desktop shell mounts (sidebar = 256px) AND lg:flex-row kicks
in, leaving the title cell to compete with a 4-button action row
in only ~720px of content. Title degraded to "(" and "Last 30
days" wrapped three-deep ("Last / 30 / days"). Moving to xl
(1280) keeps the strip stacked through tablet AND the narrowest
desktop width. Verified via Playwright at 1024 — title now reads
cleanly with the action row stacked below.

Topbar tablet logo trigger:
AppShell mounts a logo button in Topbar's leadingSlot prop on
tablet (the design intent: click logo → sidebar Sheet slides in).
Live screenshot at 768 showed zero affordance — search bar started
at the very left edge of the visible viewport. Two root causes,
both fixed:
- center grid column was minmax(420px, 800px) which starved the
  left column to ~100px at 768 width (no sidebar present).
  Changed to minmax(280px, 800px) at base, minmax(420px, 800px)
  only at lg+.
- search container had unconditional sm:-translate-x-...
  shifting it 128px LEFT to compensate for a sidebar that isn't
  present at tablet, pulling the search input over the leading-
  slot. Gated the translate to lg: so it only kicks in when the
  sidebar is actually inline.

Verified via Playwright at 768 — hamburger icon now appears in
the top-left corner; search bar sits to its right without overlap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:02:57 +02:00
6d665d0113 feat(layout): add tablet viewport tier (mobile/tablet/desktop)
Previously the app used a binary matchMedia split at 1023.98px, so iPad
portrait + half-screen-on-13"-Mac both fell into the mobile shell —
neither is really mobile. The tablet tier fills that gap.

- `use-is-mobile.ts` gains `useViewportTier()` returning
  'mobile' | 'tablet' | 'desktop' (mobile < 768, tablet 768-1023,
  desktop ≥ 1024). Backed by useSyncExternalStore so render reads
  stay pure. `useIsMobile()` retained as a back-compat alias =
  `tier !== 'desktop'` so existing call sites don't have to change
  in lockstep.

- `app-shell.tsx` now renders three branches. Mobile + desktop
  unchanged. Tablet renders the desktop shell, but the Sidebar lives
  inside a left-side `<Sheet>` opened by a new leading logo button
  in the Topbar. SheetContent width matches `--width-sidebar` so the
  open state reads consistent. Children subtree position stays
  invariant across tier flips so inline-edit drafts survive a resize.

- `topbar.tsx` accepts an optional `leadingSlot` rendered before the
  back button + breadcrumbs in the LEFT column. AppShell mounts a
  port-logo button in that slot on tablet (or a three-bar menu icon
  when the port has no logo yet) that triggers the sheet.

- `page-header.tsx` was the dashboard "title card looks bad on
  tablet" surface — the actions row was forced no-wrap at sm (640px)
  which crushed the title on iPad-portrait. Stack point moved from
  sm to lg, so tablet stacks vertically (title above, actions
  below); desktop returns to side-by-side.

tsc clean, 1454/1454 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:37:23 +02:00
bb7a371d1f feat(navigation): persist last-port for next-login + root → /dashboard
Login routing previously always landed at the user's first port-role.
With a multi-port operator (super-admins, multi-tenant ops) the active
port reverted on every login, breaking the "I was working in X
yesterday" continuity.

- PortProvider PATCHes `/api/v1/me` with `preferences.defaultPortId =
  currentPort.id` whenever the active port changes (URL or explicit
  switch). Ref-keyed dedupe; fire-and-forget so navigation isn't
  blocked by a transient PATCH failure.
- UserMenu's port-switcher also writes the preference on click so the
  preference is captured even for users who never re-render through
  PortProvider.
- /dashboard resolver checks `preferences.defaultPortId` first, falling
  back to first-port-by-name (super-admin) or first-role (everyone
  else). The preference is verified against current access before being
  honoured — a stale id from a revoked role or archived port can't
  strand the user on a 403.
- Add /src/app/page.tsx that redirects `/` → `/dashboard` so the
  middleware's `redirect=/` post-login parameter doesn't dump users on
  an empty 404. The existing /dashboard handler then routes them on to
  their resolved port.
- UserMenu sign-out: replace `router.push('/api/auth/sign-out')` (which
  issued a GET against better-auth's POST-only endpoint, causing Safari
  and Comet/Arc to land the JSON response as a `sign_out` download)
  with `signOut()` from the auth client + an explicit redirect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:06:48 +02:00
8fcbe45d36 feat(uat-batch-17): layout polish — DocumentsHub flush-left, breadcrumb wrap fix, viewport-centered topbar search
- DocumentsHub root container gains `sm:-mx-6 sm:-mt-3 sm:-mb-6` to
  escape the AppShell main padding (`px-6 pt-3 pb-6`). The folder
  column now sits flush against the global app sidebar, reading as an
  extension of navigation rather than a card-inside-a-page. Mobile
  layout retains the AppShell padding.
- Breadcrumbs: each crumb + its trailing separator now share a single
  `<BreadcrumbItem>` instead of being separate `<li>`s. Flex-wrap can
  no longer strand an orphan separator at end-of-line above a wrapped
  child crumb. Drops the standalone `<BreadcrumbSeparator>` usage from
  the consumer; the primitive is still exported for backcompat.
- Topbar search visually centered against the full viewport via a
  `translate-x:calc(-var(--width-sidebar)/2)` shift. Grid middle slot
  bumped from `minmax(360px, 640px)` → `minmax(420px, 800px)` and the
  search wrapper from `max-w-md` → `max-w-2xl` so reps actually have
  room to read long results.

tsc clean. 1419/1419 vitest pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:31:32 +02:00
449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:56:11 +02:00
b4bf9cca3f feat(branding): multi-tenant brand naming + per-port email shell + auth UI continuity
Removes the last hardcoded "Port Nimara" references so a tenant cloning
the deploy with a fresh slug sees their own brand throughout.

Browser + native chrome:
- `generateMetadata` reads `branding_app_name` from the first port row
  so the browser tab title, apple-web-app title, and template literal
  reflect the tenant (fallback "CRM" until DB is seeded).
- Mobile topbar derives the brand-mark initials from the port slug
  ("port-nimara" → "PN", "marina-alpha" → "MA") — no code edit on clone.
- `documenso-payload` default redirect URL is `""` so Documenso falls
  back to its own post-sign page instead of routing every tenant's
  signers to portnimara.com; per-port `redirectUrl` setting still wins.
- Server-startup log uses generic "CRM server listening".

Email + auth shell:
- New `auth-shell-branding.ts` resolves logo / background / appName once
  per request from `system_settings`; used by both the email shell and
  the auth-pages SSR layout.
- `auth-branding-provider` wraps `/login`, `/reset-password`, `/set-password`,
  portal `/portal/*` so the branded shell hydrates with the same assets
  the inbox sees.
- `me/email` change email uses the branded shell instead of inline HTML
  with "Port Nimara CRM" baked into copy.
- Admin branding page adds an email-preview card (POSTs to
  `/api/v1/admin/branding/email-preview`) so an admin can spot-check
  their templates before going live.
- `/api/public/files/[id]` exposes branding-category files anonymously
  so inbox images (no session cookie) can render; any other category
  still flows through authenticated `/api/v1/files/[id]/preview`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:54:10 +02:00
4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:28:50 +02:00
202e0b1bc5 refactor(layout): single-tree responsive shell (#26)
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.

New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).

Knock-on changes:
  - Dashboard layout fetches once and hands the data to AppShell;
    AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
  - Stripped the now-orphan data-shell CSS rules from globals.css —
    nothing emits the attribute any more
  - MobileLayout drops its data-shell="mobile" attribute (was the lever
    the dead CSS rules pulled)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:59:30 +02:00
7d33e73eef feat(berths): manual status catch-up wizard + reconciliation queue (#67)
Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.

Phase 1 — Status source tracking:
  - updateBerthStatus() stamps 'manual' on every user-facing write
  - berth-rules-engine.ts stamps 'automated' on auto-rule writes
  - new clearBerthOverride() helper nulls the field and stamps the
    reason "Reconciled via interest <id>" — only the wizard calls it

Phase 2 — Visual indicator:
  - Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
    AND no active linked interest (the candidates for catch-up)

Phase 3 — Reconciliation queue:
  - new service listManualReconcileBerths() with cross-port-safe
    NOT-EXISTS against activeInterestsWhere
  - GET /api/v1/berths/reconcile-queue
  - new page /[portSlug]/admin/berths/reconcile listing the queue,
    each row linking to the catch-up wizard

Phase 4 — Catch-up wizard:
  - POST /api/v1/berths/[id]/reconcile orchestrates create-client
    (optional quick-create), create-interest with primary berth link,
    and clearBerthOverride — composed via existing service helpers
  - <CatchUpWizard> dialog: existing-client or quick-create, optional
    yacht link, stage picker scoped to the current berth status, with
    contract auto-setting outcome=won

Phase 5 — Entry points:
  - sidebar Admin > "Reconcile berths" link
  - berth-list row action menu shows "Catch up…" on flagged rows

Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 23:55:22 +02:00
e7e498dedd fix(T3): copy + entry points + recommender alias
Batch of small fixes from the post-audit plan:

F11 — "Mark as won" dialog copy
  Was: "This will move the interest to Completed and stamp the outcome."
  Completed was retired in the 7-stage refactor; copy now reads
  "marks Won; stage stays where it is" with a parallel Lost variant.

F13 — Bulk-add berths wizard had no UI entry point
  Page existed at /[portSlug]/admin/berths/bulk-add but nothing linked
  to it. Added a "Bulk add" button on the Berths list toolbar, gated
  on `berths.import`. Also fixed the API route's permission key
  (was `berths.create`, a phantom — switched to `berths.import` to
  match seed-permissions).

F14 — Audit Log nav entry
  Sidebar Admin section now lists "Audit Log" → /admin/audit, gated
  by the adminRequired group rule.

F18 — Recommender `limit` param ignored
  POST /interests/[id]/recommend-berths now accepts `limit` as an
  alias for `topN`. Audit sent `{limit:3}` and silently got 8 rows
  back; both names now resolve.

Tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:59:38 +02:00
6b28459c45 feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.

Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
  three doc-status columns, two documenso-id columns, and
  date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
  interest_qualifications (per-interest state), payments (deposit /
  balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
  the new stage + doc-status + outcome shape.

Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).

v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
       the contact-log compose dialog (useVoiceTranscription hook).
- C:   berth-rules-engine wraps state writes in pg_advisory_xact_lock
       with an idempotent re-read; emits rule_evaluated audit traces.
- D:   Documenso webhook: reservation/contract sub-status stamping
       moved out of the PDF-download try-block so a download failure
       no longer swallows the stamp. New integration test coverage.
- E:   /admin/qualification-criteria CRUD page + admin component.
- F:   default_new_interest_owner exposed in System Settings.
- G:   recentActivityCount + active_engagement deal-pulse signal
       surfaced as a chip on interests + hot-deals card.
- H:   interest_assigned notification on assignedTo change (skips
       self-assign, uses a dedupe key).

Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.

Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:39:21 +02:00
05b57abf05 refactor(settings): consolidate user profile into single settings page
Drop the standalone /settings/profile route + user-profile component;
folding the same fields into user-settings means one place to update
and one menu item. UserMenu loses the Profile dropdown entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:07 +02:00
12e22d9be3 fix(ui+auth): origin-forwarding for sign-in + disable dark mode + center dialog
Three related cleanups while QA-testing on iPad:

1. Origin-forwarding bug on /api/auth/sign-in-by-identifier
   - The custom identifier-sign-in route forwarded to better-auth's
     /sign-in/email handler but did NOT preserve the inbound Origin +
     Referer headers. Better-auth's CSRF check then 403'd every login
     with MISSING_OR_NULL_ORIGIN — and the UI showed a generic
     "Invalid credentials" toast even when the password was right.
   - Fix: pass through req.headers.get('origin') and
     req.headers.get('referer') when constructing forwardReq.
   - Affects: every login attempt from any device (this isn't dev-
     only); discovered testing from 192.168.1.17 → app on the same
     LAN IP. Production users hit the same path.

2. Dark mode disabled
   - Drop the Sun/Moon toggle from user-menu, the documentElement
     class flip, darkMode from ui-store, darkMode from the user-
     preferences validator. Hardcode sonner theme="light" (was
     reading next-themes which isn't actually wired anywhere else).
   - The 10 stray `dark:` Tailwind utilities are left alone — they're
     inactive without the `dark` class on <html> so they don't ship
     anything that renders, just dead CSS.

3. Center dialog animation
   - Dialog content was sliding in from the top-right corner (slide-
     in-from-left-1/2 + slide-in-from-top-[48%]) which felt jarring.
     Drop the slide directions, keep just zoom-in-95 + the base
     fade-in/out so dialogs appear in place with a subtle scale-up.

4. Login placeholder
   - Removed the "you@example.com  or  yourname" placeholder so the
     field reads as a clean empty input below the "Email or username"
     label.

No tests added (the 1340 vitest suite passes); changes are surface-
level UI tweaks + the origin-header fix where a unit-test of the
custom route would mostly be testing better-auth's behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:20:06 +02:00
93399ea27e fix(audit-wave-11): mobile dvh + multi-port slug-first apiFetch
**mobile-pwa-auditor H4 — mobile shell uses min-h-screen**

`min-h-screen` resolves to `100vh` on iOS Safari, which is the LARGE
viewport height (URL bar collapsed). On first paint the page renders
~75–100px taller than visible, and reps see a blank strip past the
bottom tab bar until the URL bar collapses on first scroll. Swap
`min-h-screen` → `min-h-[100dvh]` in `mobile-layout.tsx`. The scanner
layout already does this correctly.

**multi-port-auditor C1 — port-switcher race / cross-port bleed**

`apiFetch` previously preferred Zustand for the X-Port-Id header and
only consulted the URL slug as a fallback. Zustand lags by one render
behind `PortProvider`'s reconcile effect; clicking from /port-A to
/port-B fired the first round of queries with X-Port-Id = port-A
while the page chrome rendered port-B → silent cross-port data bleed
in the UI.

Make the URL slug authoritative: read it first via
`window.location.pathname` + `resolvePortIdFromSlug`, fall back to
Zustand only on global routes (/dashboard) without a port slug.

**multi-port-auditor C3 — defaultPortId silently stripped**

`withAuth` reads `preferences.defaultPortId` as the X-Port-Id
fallback, but `/me` PATCH's `.strict()` schema + ALLOWED_PREF_KEYS
allow-list silently dropped the key on every write. The fallback was
therefore dead — super-admins always landed alphabetically-first.

Add `defaultPortId: z.string().uuid().optional()` to the strict
schema and include it in ALLOWED_PREF_KEYS so super-admins can
persist their last-picked port.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:08:09 +02:00
c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
4329db7fc3 fix(compiler): React Compiler safety triage — 5 categories cleared
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.

Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
  (5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
  `map`; converted to `reduce` so the slice array is built without
  re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
  mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
  countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
  `user-settings.tsx`: `useEffect(() => void load(), [])` with the
  `load` function declared AFTER the effect — relied on hoisting,
  trips Compiler's "access before declared" rule. Declared inside
  the effect.

Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
  effects (`use-realtime-invalidation`, `settings-form-card`,
  `inbox`).
- 3 `ref.current` reads during render (search totals cache,
  scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
  the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
  into the React Query cache via `setQueryData`, removing a local
  state mirror.

Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
  useEffect→fetch→setState data-fetch pattern; migration to
  useQuery tracked in BACKLOG)

Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:14:16 +02:00
0ab96d74a8 feat(deps): Tailwind 3 → 4 + swap tailwindcss-animate for tw-animate-css
Ran the official @tailwindcss/upgrade tool:
- tailwind.config.ts → @theme directive in globals.css
- @tailwind base/components/utilities → @import 'tailwindcss'
- postcss.config switched from tailwindcss + autoprefixer to
  @tailwindcss/postcss (autoprefixer baked in)
- focus-visible:outline-none → focus-visible:outline-hidden (the v3
  utility was a footgun — outline still showed in forced-colors mode)

Reverted the migration tool's over-zealous variant="outline" →
variant="outline-solid" rename on CVA prop values; that rename was
meant for the Tailwind `outline:` utility, not our Button/Badge
component variants.

Swapped tailwindcss-animate (v3-style JS plugin) for tw-animate-css
(v4-native @import). Same utility surface (animate-spin, animate-in,
etc.), one fewer JS plugin in the bundle.

Fixed the upgrade tool's malformed dark variant
(@custom-variant dark (&:is(class *)) — `class` was being parsed as
a tag) to canonical &:where(.dark, .dark *).

Verified: tsc 0 errors, eslint 0 errors (16 pre-existing warnings),
vitest 1315/1315, next build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:14:38 +02:00
660553c074 feat(admin+search): user-mgmt polish, role labels, search keyword index
Admin search now matches against per-card keyword lists so typing
"client portal", "smtp", "tier ladder" lands on the System Settings card
(which hosts those flags). The same keyword list extends the topbar
global search (NAV_CATALOG) so any setting key resolves from the cmd-K
input — settings results sort to the bottom of the dropdown beneath
entity hits.

User management:
- Third action button (Power/PowerOff) enables/disables sign-in from the
  desktop list; mobile card dropdown gains the same item. Backed by the
  existing userProfiles.isActive flag — withAuth already refuses
  disabled sessions with 403.
- UserForm collects first + last name (canonical) alongside displayName,
  with admin email-change behind a confirmation modal. On confirm we
  send the OLD address an automated "your admin changed your sign-in
  email" notice (new template at admin-email-change.ts) and rewrite
  the Better Auth user row.
- Phone field swaps the bare tel input for the shared PhoneInput
  (country combobox + AsYouType formatting + E.164 storage).
- "Manage permissions" link points to /admin/roles?focusUser=… as
  a stepping stone for the future fine-tuned-permissions UI.

Role names normalize through a new ROLE_LABELS + formatRole() helper
in constants.ts. Replaces the ad-hoc humanizeRole in sidebar and the
prettifyRoleName in role-list; user-list and user-card now render
"Sales Agent" instead of "sales_agent". Custom roles pass through
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:14:12 +02:00
0ab7055cf1 feat(dashboard): local-time greeting + timezone-drift banner
Greeting
- The "Good morning / afternoon / evening, Matt" line now derives from the
  browser's local time, computed inside a useEffect so the rendered HTML
  can't lock to the server's clock during hydration. Until the effect
  fires, the header reads "Welcome" — a neutral phrase that's correct at
  every hour and never produces a hydration warning. The phrase re-evaluates
  hourly so a rep leaving the dashboard open across a boundary (5am, noon,
  6pm) doesn't keep stale text on screen.

Timezone-drift banner
- New <TimezoneDriftBanner> on the dashboard surfaces when the browser's
  resolved timezone (Intl.DateTimeFormat().resolvedOptions().timeZone, which
  follows the OS — and the OS usually follows physical location) doesn't
  match the user's stored CRM preference. The rep gets a one-tap "Update to
  Tokyo" button and a dismiss × that's sticky per browser via localStorage.
- Why a banner rather than auto-update: the stored timezone drives reminder
  firing time, daily-digest delivery, and due-date rendering. Silently
  pinning it to a transient travel location would shift their reminder
  schedule underfoot. The banner gives them control.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:48:51 +02:00
04a594963f feat: round 2 — stage prompts, berth header, EOI inline edit, measurement units
Berth surfaces
- New compact mooring-chip header (colored plate + status pill, dock-label
  in tooltip) replaces the redundant "Berth B1 / Sold / B DOCK" stack
- Berth list gains a "Latest deal stage" column showing the most-advanced
  pipeline stage of any active linked interest (server-aggregated, ranks by
  PIPELINE_STAGES index)
- "Linked prospect" Select on the status-change dialog rebuilt as a Command
  combobox: search, recent-first sort, stage-coloured pills

Pipeline UX
- Reverting an interest to Open with linked berths now prompts: keep the
  links, unlink and reset, or cancel. Silent when no berths are linked
- Activity feed + entity-activity feed normalise enum field values via
  STAGE_LABELS / formatSource: "deposit_10pct → contract_sent" reads as
  "10% Deposit → Contract Sent"

EOI generate dialog
- Inline-editable rows for client name, nationality (country combobox), and
  yacht name — pencil affordance saves directly via clients/yachts PATCH
- Replaces the single "Edit on client's page" link with two contextual links
  framed by short copy explaining what's inline vs what needs the canonical
  page
- Backend EoiContext now includes client.id + yacht.id so the dialog can
  PATCH without an extra round-trip

Company form
- New "Connections" section lets the rep attach members (clients) and yachts
  during create. Yacht attach uses the existing transfer endpoint so audit
  log + ownership history capture the change
- Inline "+ New client" / "+ New yacht" buttons open the canonical forms
  stacked over the company sheet
- After save, the form chains to a yacht pull-in prompt (if any attached
  client owns yachts not yet linked) and an optional "Create interest" step
  pre-filled with the first attached client

Admin
- /admin landing gains a searchable index — typed query flattens groups into
  a result list matching label + description + group title
- "Documenso & EOI" card relabelled to "EOI signing service" (consistent
  with the user-facing language rename from round 1)

Measurement units (migration 0053)
- interests gains desired_*_m columns + desired_*_unit discriminators so
  the rep's literal entry (ft OR m) is preserved verbatim instead of being
  reconstructed from a single canonical column on every render
- yachts + berths gain matching *_unit columns alongside their existing
  ft + m pairs; defaults to 'ft' so legacy rows still render normally
- Interest form POST/PATCH now sends both ft + m + unit; computed m is
  derived from the ft canonical to keep the recommender SQL unchanged

Misc
- Active-deals tile + topbar type their Link href as `Route` instead of `any`
- Unused REPORT_TYPE_LABELS const dropped from generate-report-form
- Test fixtures (fill-eoi-form, documenso-payload, public-berths) updated
  to include the new id + unit fields on the EoiContext / Berth shapes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 15:28:22 +02:00
3ffee79f3f feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones
Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:50:58 +02:00
979eadae48 fix(ui): mobile + dashboard polish + dev CSRF relaxation
- filter-bar: hide select / multi-select fields when the options list is
  empty (was rendering bare "Tags" / "Status" labels above empty inputs)
- berth-detail-header: show "Berth A1" title on mobile (was hidden via
  `hidden sm:block`)
- dashboard-shell: time-aware greeting (Good morning/afternoon/evening,
  firstName) using the existing ['me'] cache; falls back to
  "Welcome back" when firstName isn't set yet
- mobile-topbar: hide UUID-segment fallback title flash on detail-page
  navigation — when the URL last segment is a UUID, walk up to the
  parent collection name ("Clients", "Yachts") until the page sets the
  real entity title via useMobileChrome
- mobile-bottom-tabs: subtle bg-primary/10 pill behind icon on active
  tab for a clear "you are here" cue
- branded-auth-shell: lock to viewport via fixed/inset-0 so the iOS
  Safari rubber-band bounce doesn't scroll the centered login card
- middleware: skip CSRF origin check in development. LAN testing
  (real iPhone on 192.168.x.x hitting the Mac dev server while a Mac
  browser tab is on localhost) trips the cross-origin defense; prod
  keeps it as-is.
- package.json dev script: -H 0.0.0.0 so the dev server is reachable
  from devices on the LAN

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:58:42 +02:00
b6f55636ab chore(documents): remove legacy /documents/files route + folder tree
The /documents/files page rendered a storagePath-prefix folder tree
disconnected from document_folders. Replaced by the unified hub
(Task 15). 301 redirect catches stray bookmarks. file-browser-store
repurposed to hold the document_folders.id selection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:47:11 +02:00
07b5756014 feat(profile): first/last name fields + collapse notification preferences
Two related cleanups for the user profile surface area:

(1) Add canonical first_name + last_name columns to user_profiles.
    Migration 0049 backfills from display_name by splitting on the
    first whitespace run; single-token names land as
    (display_name, NULL) so we never throw away existing data.
    Display name becomes an optional override (nicknames, vanity
    formatting). /api/v1/me PATCH now accepts firstName/lastName,
    and the user-settings form surfaces them as the primary inputs
    with display name as a secondary "How your name appears" field.

(2) Remove the broken Notifications card from user-settings (it called
    PATCH on an endpoint that has GET/PUT only and used a flat shape
    vs the actual array shape). Replace with the working
    NotificationPreferencesForm + ReminderDigestForm under a
    #notifications anchor. /notifications/preferences becomes a
    server-side redirect to /settings#notifications for back-compat;
    the mobile More-sheet + user-menu Bell entry now deep-link to the
    new anchor directly.

Drops the auto-generated drizzle-kit catch-up migration so we're not
sneaking accumulated schema drift into the journal — only the targeted
0049 lands here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:36:31 +02:00
19622985b5 fix(layout): mobile UX cleanup + interest-stage legend popover
Mobile UX:
- Hide ColumnPicker on `< sm` viewports (cards, no columns to toggle).
- Hide kanban toggle in interest list on mobile and snap viewMode back
  to 'table' if the persisted choice was 'board'.
- Drop dead "Inbox" link from the More-sheet (email/IMAP feature is
  deferred per sidebar.tsx note).
- Repoint Notifications nav from `/notifications` (no page.tsx — 404)
  to `/notifications/preferences` and re-label as "Notification
  preferences" (the bell stays the surface for actual notifications).
- Hide Website Analytics on both desktop sidebar and mobile More-sheet
  when Umami isn't configured for the port (`useUmamiActive()`).

Interests:
- New `<StageLegend>` popover button in the filter row decodes the
  card stripe colours to pipeline stage names, kept in sync with
  `STAGE_DOT` automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 04:11:01 +02:00
3e4d9d6310 feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.

EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.

Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.

Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.

Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).

Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).

Berth interest list overhaul:
  - Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
  - Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
  - Per-letter row tinting via colored left-border accent + dot in cell
  - Documents tab merged Files (single attachments section)

Topbar improvements:
  - Always-visible back arrow on detail pages (path depth > 2)
  - Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
    push their entity hierarchy (Clients › Mary Smith › Interest › B17)
  - Tighter spacing, softer separators, 160px crumb truncation

DataTable upgrades:
  - Page-size selector with All option (validator cap raised to 1000)
  - getRowClassName slot for per-row styling (used by berth tinting)
  - Fixed Radix SelectItem crash on empty-string values via __any__
    sentinel (was crashing every list page that opened a select filter)

Interest list:
  - Configurable columns picker
  - Stage cell clickable into detail
  - TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
  - Save view moved into ColumnPicker menu; Views button hidden when
    no views are saved
  - Pipeline kanban board endpoint at /api/v1/interests/board with
    minimal projection, 5000-row cap + truncated banner, filter
    pass-through

Mobile chrome + sidebar collapse removed (always-expanded design choice).

User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:59:28 +02:00
Matt Ciaccio
b4fb3b2ca6 fix(audit): MEDIUMs sweep — mobile More-sheet, portal profile, inline override, dialog UX, ext-EOI gate
R2-M11: mobile More-sheet missing 4 destinations. Added Reservations,
Notifications, Residential, Website analytics — anyone using mobile
chrome to triage on the go can now reach those domains.

R2-M12: portal had no profile / change-password surface. New
/portal/profile page with read-only contact details + a
ChangePasswordForm component, backed by a new POST
/api/portal/auth/change-password endpoint and
changePortalPassword() service function. Audits both ok and failure
cases at warning severity. Added Profile to PortalNav.

R2-M1: portal dashboard "My Memberships" tile had no href and no
/portal/memberships route — dead-end on tap. Hidden until a
memberships page ships; the count remains in the underlying data.

R2-M7: InlineStagePicker never sent override:true so users with
interests.override_stage couldn't actually use the perm from the
inline chip — they had to fall back to the modal picker. Now the
picker auto-detects when a transition isn't legal AND the user has
override_stage, sets override:true, and supplies a default reason.

Frontend M2: hard-delete-dialog confirm stage now has a "Send a new
code" link in case the original expired before the user could enter
it. Avoids forcing a full Cancel + reopen.

Frontend M4: audit-log-list date-range validation. From > To now
shows an inline error and skips the request rather than firing an
empty-range query that surfaces "no entries found".

R2-M6: external-EOI route now requires interests.edit AND
documents.upload_signed (defense-in-depth) — uploading a signed EOI
mutates interest state, so the upload-signed perm alone shouldn't
let a custom role flip an interest.

1175/1175 vitest passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 22:38:59 +02:00
Matt Ciaccio
8690352c56 fix(ux): mobile polish — inputMode=decimal default, dialog padding, more-sheet touch targets
Three audit-pass-#3 mobile findings, all in shared primitives so the
fix lands everywhere those primitives are used.

- Input defaults inputMode='decimal' when type='number' and the caller
  hasn't overridden. Currency/dimension/price fields across invoices,
  expenses, berth specs etc. now show iOS's numeric pad instead of full
  QWERTY. Caller can still pass inputMode='numeric' for integer-only
  fields.
- DialogContent: padding tightens to p-4 on mobile and restores p-6
  at sm+ — the previous fixed p-6 ate ~48px of horizontal width on a
  390px iPhone, crushing form-field space. Also adds a max-h-[100dvh]
  + overflow-y-auto so long modal forms scroll inside the dialog
  instead of pushing the close button off-screen.
- MoreSheet (mobile bottom-tab "More" drawer): grid-cols-3 cells now
  enforce min-h-[88px] so each Apple-HIG-sized 44pt touch target gets
  reliable hit area. Icon size bumped from 6 to 7 for visual weight at
  the larger cell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:16:33 +02:00
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
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
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
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
868b1f40c0 fix(nav): drop dedicated EOI route + alerts sidebar entry, fix paginated-URL bug
Trimmed two surfaces that didn't earn their nav weight:

  - The /[port]/documents/eoi route added in the previous commit was
    redundant with the per-interest EOI status milestones already on
    the interest detail and the existing eoi_queue tab inside the
    Documents hub. Removed the route + the "EOI queue" sidebar entry.
  - The Alerts sidebar entry was promoting a mostly-empty page that
    duplicated the dashboard alert rail. Dropped the entry; the
    /[port]/alerts route stays accessible via the dashboard rail's
    "View all" link and the topbar bell, which is enough for the
    audit-trail use case.

While testing the EOI tab, found and fixed a real bug: usePaginatedQuery
was producing malformed URLs like `…?tab=eoi_queue&signatureOnly=true?page=1&limit=25`
(two `?` separators) when the endpoint string already carried query
params. The API rejected those with 400, so the EOI tab in the
documents hub was silently broken. The hook now uses `&` when the
endpoint already contains a `?`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:30:27 +02:00
Matt Ciaccio
ba5fb6db5e feat(sales): EOI queue route + invoice→deposit auto-advance + won/lost outcomes
Three independent strengthenings of the sales spine that the prior coherence
sweep made it possible to do cleanly.

  1. EOI queue page

     - Sidebar entry under Documents → "EOI queue".
     - Route /[port]/documents/eoi renders DocumentsHub with the existing
       eoi_queue tab pre-selected (filters in-flight EOIs only).
     - .gitignore: tightened root-only `eoi/` ignore so the documents/eoi
       route is no longer silently excluded.

  2. Invoice ↔ deposit link

     - invoices.interestId (FK, ON DELETE SET NULL) + invoices.kind
       ('general' | 'deposit'). Indexed on (port_id, interest_id).
     - createInvoiceSchema requires interestId when kind === 'deposit';
       the service validates the linked interest belongs to the same port
       before insert.
     - recordPayment auto-advances pipelineStage to deposit_10pct (via
       advanceStageIfBehind) when a paid invoice is kind=deposit and has
       an interestId. No-op if the interest is already further along.
     - "Create deposit invoice" link added to the Deposit milestone on the
       interest detail. Links to /invoices/new?interestId=…&kind=deposit;
       the form prefills the billing entity from the linked interest's
       client and shows a context banner.

  3. Won / lost terminal outcomes

     - interests.outcome ('won' | 'lost_other_marina' | 'lost_unqualified'
       | 'lost_no_response' | 'cancelled') + outcomeReason text +
       outcomeAt timestamp. Indexed on (port_id, outcome).
     - setInterestOutcome / clearInterestOutcome services + POST/DELETE
       /api/v1/interests/:id/outcome endpoints (gated by change_stage
       permission). Setting an outcome moves the interest to `completed`
       in the same write; clearing reopens to `in_communication` (or a
       caller-specified stage).
     - Mark Won / Mark Lost icon buttons on the interest detail header,
       plus an outcome badge that replaces the stage pill once a terminal
       outcome is set, plus a Reopen button.
     - Funnel + dashboard math updated to exclude lost/cancelled outcomes
       from active calculations (KPIs.activeInterests, pipelineValueUsd,
       getPipelineCounts, computePipelineFunnel, getRevenueForecast).
       The funnel now also returns a `lost` summary so callers can
       surface leakage without polluting conversion percentages.

Schema changes shipped via 0019_lazy_vampiro.sql; applied to dev DB
manually via psql because drizzle-kit push hits a pre-existing zod
parsing issue on the companies index. Dev server may need a restart
to flush prepared-statement caches.

tsc clean. vitest 832/832 pass. ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 00:01:33 +02:00
Matt Ciaccio
a75d4f5d69 feat(mobile): redesign topbar + collapse cumbersome page-header on mobile
Topbar (mobile-topbar.tsx):
  - Bumped to 56px to match standard mobile-app proportions.
  - Deep-navy gradient surface (#1e2844 -> #171f35) with white type —
    matches the desktop sidebar identity, gives the app a premium
    finish instead of generic white-with-text.
  - Brand "PN" wordmark mark on the left when no back affordance is
    needed (rounded brand-blue square, inset highlight + drop shadow).
  - Soft glow shadow underneath for elevation depth instead of a hard
    bottom border.
  - White-on-navy back arrow with active-state translucent fill.

PageHeader (page-header.tsx):
  - On mobile, the gradient hero strip + duplicate title + description
    block now collapses entirely — the topbar already shows the title,
    so duplicating it in the body wasted a third of the viewport.
  - The actions slot remains rendered as a flush right-aligned row so
    primary buttons (date-range pickers, "+ New X") stay accessible.
  - Desktop rendering is untouched.

Mobile shell (mobile-layout.tsx):
  - Top buffer 16px below the topbar so content doesn't ride flush.
  - Bottom buffer 32px above the tab bar so the last card breathes.

CSS (globals.css):
  - Hide the react-query-devtools floating button below lg: — it was
    overlapping the bottom-tab bar's "More" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:34:28 +02:00
Matt Ciaccio
7e8110b2ff feat(mobile): show entity name in mobile topbar on detail pages
Detail pages (clients, yachts, companies, berths, invoices, expenses)
now push their entity name + a back-button toggle to the mobile
topbar via useMobileChrome, replacing the URL UUID fallback that was
rendering before.

Supporting changes:
  - useMobileChrome() no longer throws when called outside the
    MobileLayoutProvider — desktop-tree consumers get a no-op
    setChrome so callers don't have to branch on shell type.
  - setChrome is now stable across renders (useCallback) so callers'
    useEffect dependency arrays don't infinite-loop.
  - DetailPageShell now also pushes its entityName + cleans up on
    unmount, and hides its desktop-only sticky header on mobile so it
    doesn't double up with the topbar (no current callers, prep for
    Phase 4 migration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:46:32 +02:00
Matt Ciaccio
ac7f1db62c fix(mobile): add horizontal padding to mobile shell main
Content cards/lists were rendering edge-to-edge on mobile because the
mobile shell's <main> had no horizontal padding (only safe-area top/
bottom). Adds px-4 to match the breathing room desktop gets from p-6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:45:20 +02:00
Matt Ciaccio
19bc2f2a54 feat(mobile): mount MobileLayout alongside desktop shell, remove legacy sidebar mobile-drawer 2026-04-29 14:18:28 +02:00
Matt Ciaccio
b0a11f1785 feat(mobile): add MobileLayout shell composing topbar + content + bottom tabs + more sheet 2026-04-29 14:16:30 +02:00
Matt Ciaccio
3cbf2444fe feat(mobile): add MoreSheet (3-column grid of long-tail nav items in a bottom drawer) 2026-04-29 14:15:25 +02:00
Matt Ciaccio
210360738d feat(mobile): add MobileBottomTabs with 5 fixed tabs (Dashboard/Clients/Yachts/Berths/More) 2026-04-29 14:13:09 +02:00
Matt Ciaccio
4df04e1a58 feat(mobile): add MobileTopbar with title, back-button, and primary-action slots 2026-04-29 14:12:15 +02:00
Matt Ciaccio
0c3baf04c5 feat(mobile): add MobileLayoutProvider context + useMobileChrome hook 2026-04-29 14:11:27 +02:00
Matt Ciaccio
f52d21df83 feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.

PR4  Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
     date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5  Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
     right-rail, three-tab page (active/dismissed/resolved), socket-driven
     invalidation. Bell lazy-loads list on popover open to keep cold pages
     fast in non-dashboard routes.
PR6  EOI queue tab on documents hub — filters to in-flight EOIs, count
     surfaces in tab label.
PR7  Interests-by-berth tab on berth detail — replaces the stub.
PR8  Expense duplicate detection — BullMQ job runs scan on create, yellow
     banner on detail w/ Merge / Not-a-duplicate, transactional merge
     consolidates receipts and archives the source.
PR9  Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
     its own (scanner) group with no dashboard chrome, dynamic per-port
     manifest, OpenAI + Claude provider abstraction, admin OCR settings
     page (port-level + super-admin global default w/ opt-in fallback),
     test-connection endpoint, manual-entry fallback when no key is
     configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
     existing GIN index, cursor pagination, filters for entity/action/user
     /date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
     real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
     socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
     cleanly without their gate envs so CI stays green.

Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
Matt Ciaccio
5f9bbb97bd fix(sidebar): replace floating circular collapse button with blended row
User feedback: the circular toggle floating off the sidebar's right
edge looked tacked-on. Replaced with a flush full-width row above the
user footer (right-aligned 'Collapse <' chip when expanded; centered
chevron when collapsed). Same nav-item hover treatment so it merges
visually with the sidebar palette. The <aside> no longer needs to
host an overhanging button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:00:20 +02:00