Commit Graph

49 Commits

Author SHA1 Message Date
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
Matt Ciaccio
3a7fef59b0 fix(visual): dark-mode-safe borders + sidebar relative + ring-background
Code-review follow-up to PR10b-e:
- DetailHeaderStrip + KPITile: border-slate-200 → border-border so dark
  mode doesn't paint a bright halo around the gradient strip.
- Topbar avatar: ring-white → ring-background so the 2px ring tracks
  the surface (matches the sidebar footer pattern).
- KpiTileSkeleton stripe: bg-slate-100 → bg-muted for parity with
  shadcn skeleton tokens in dark mode.
- Sidebar <aside>: add `relative` so the absolute-positioned
  collapse-toggle button anchors to the sidebar itself rather than
  the nearest positioned ancestor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:24:14 +02:00
Matt Ciaccio
c7ca7c1f96 style(layout): sidebar stripe + topbar gradient + bell spring + search ring
Sidebar active items: 4px brand left-edge stripe (rounded-r-full) replacing the
border-l-2 + bg shift; section header smaller-caps + brand-200 colour; user-footer
avatar gets shadow-sm + ring-2 ring-white/30.

Topbar '+ New' uses bg-gradient-brand with shadow-sm + scale-1.02 hover. User
avatar trigger gets shadow-sm + ring-2 ring-white. Notification badge gets
gradient-brand fill + ring-2 ring-background + animate-badge-pop spring keyframe
(retriggers on count change via key={unreadCount}). Command search gets shadow-xs
inset + brand focus ring (ring-4 ring-brand/15).

Adds badge-pop keyframes to tailwind config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 12:10:11 +02:00
Matt Ciaccio
cda44e721b fix(layout): hoist TooltipProvider to wrap full sidebar tree
The collapsed-state user-footer renders a Tooltip that was outside the
TooltipProvider — the provider only wrapped the nav. Once the sidebar
toggled to collapsed, the footer Tooltip threw "Tooltip must be used
within TooltipProvider", surfacing as console errors in exhaustive
click-through tests.

Move TooltipProvider up one level so every Tooltip in the sidebar tree
(nav items + user footer) is covered.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 05:08:01 +02:00
Matt Ciaccio
1791dd7319 fix(ui): resolve yacht owner names server-side, real user in topbar
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m1s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Yachts list page rendered each row's Current Owner via OwnerLink, which
fired its own /api/v1/clients/{id} or /companies/{id} fetch — N+1 round-
trips per page load (12+ for the harbor-royale fixture). Worse, until
those fetches resolved each cell showed "Client c68da7..." style raw IDs.

Fix: listYachts now resolves the polymorphic currentOwnerName in two
batched in-array queries after the page query (mirrors the listClients
yachtCount/companyCount pattern), and OwnerLink accepts an optional
preloadedName prop that suppresses the per-row fetch when supplied.

Topbar: show real user name + avatar initial from session/profile, and
expand the My-Account dropdown header to include the user's email.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:54:04 +02:00
Matt Ciaccio
0ccc66833d fix(ui): admin settings loading-loop, real user name, expanded admin nav
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m0s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
SettingsFormCard
- Parent components pass `FIELDS.slice(...)` inline, so the prop reference
  changes on every render. The fetch callback's useCallback re-created
  itself, useEffect re-fired, and loading flicker meant the form never
  rendered. Capture fields in a ref so the callback is stable.

Sidebar
- Show real user name + avatar initial from session/profile, replacing
  the hardcoded "User Name" / "U" placeholder.
- Default the admin-section to expanded so its items are reachable on
  first page load (was collapsed behind a chevron).

Dashboard layout
- Pass {name, email} from the session/profile through to <Sidebar />.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:44:04 +02:00
Matt Ciaccio
e8d61c91c4 feat(platform): residential module + admin UI + reliability fixes
All checks were successful
Build & Push Docker Images / lint (pull_request) Successful in 1m2s
Build & Push Docker Images / build-and-push (pull_request) Has been skipped
Residential platform
- New schema: residentialClients, residentialInterests (separate from
  marina/yacht clients) with migration 0010
- Service layer with CRUD + audit + sockets + per-port portal toggle
- v1 + public API routes (/api/v1/residential/*, /api/public/residential-inquiries)
- List + detail pages with inline editing for clients and interests
- Per-user residentialAccess toggle on userPortRoles (migration 0011)
- Permission keys: residential_clients, residential_interests
- Sidebar nav + role form integration
- Smoke spec covering page loads, UI create flow, public endpoint

Admin & shared UI
- Admin → Forms (form templates CRUD) with validators + service
- Notification preferences page (in-app + email per type)
- Email composition + accounts list + threads view
- Branded auth shell shared across CRM + portal auth surfaces
- Inline editing extended to yacht/company/interest detail pages
- InlineTagEditor + per-entity tags endpoints (yachts, companies)
- Notes service polymorphic across clients/interests/yachts/companies
- Client list columns: yachtCount + companyCount badges
- Reservation file-download via presigned URL (replaces stale <a href>)

Route handler refactor
- Extracted yachts/companies/berths reservation handlers to sibling
  handlers.ts files (Next.js 15 route.ts only allows specific exports)

Reliability fixes
- apiFetch double-stringify bug fixed across 13 components
  (apiFetch already JSON.stringifies its body; passing a stringified
  body produced double-encoded JSON which failed zod validation)
- SocketProvider gated behind useSyncExternalStore-based mount check
  to avoid useSession() SSR crashes under React 19 + Next 15
- apiFetch falls back to URL-pathname → port-id resolution when the
  Zustand store hasn't hydrated yet (fresh contexts, e2e tests)
- CRM invite flow (schema, service, route, email, dev script)
- Dashboard route → [portSlug]/dashboard/page.tsx + redirect
- Document the dev-server restart-after-migration gotcha in CLAUDE.md

Tests
- 5-case residential smoke spec
- Integration test updates for new service signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:54:32 +02:00
Matt Ciaccio
64d7b5c765 feat(ui): company list page with columns, filters, and sidebar entry 2026-04-24 14:05:24 +02:00
Matt Ciaccio
4f56c2bdfd feat(ui): add Yachts entry to sidebar navigation 2026-04-24 13:48:37 +02:00
082d4f20e3 Fix all TypeScript errors: restore proper types and typed route casts
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m16s
Build & Push Docker Images / build-and-push (push) Failing after 4m42s
- Restore `as any` casts for Next.js typedRoutes on dynamic routes
- Use proper types for PDF templates, invoice/expense data, DB schema
- Fix PgColumn casts in sort helpers for expenses/invoices
- Add null guards for optional port/client in record-export
- Fix vitest config (remove invalid poolOptions)
- Lint: 0 errors, TypeScript: 0 errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:29:55 +01:00
4c20bcffcd Fix all ESLint errors: remove unused imports, replace any types
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m10s
Build & Push Docker Images / build-and-push (push) Has been skipped
Build & Push Docker Images / deploy (push) Has been skipped
- Remove ~60 unused imports and variables across 88 files
- Replace ~80 `any` type annotations with proper types (unknown,
  Record<string, unknown>, or specific types)
- Prefix unused callback args with underscore
- Fix unescaped JSX entities
- Lint now passes cleanly (0 errors, 2 intentional img warnings)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:06:18 +01:00
67d7e6e3d5 Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00