Closes out the report exporter. Adds a Preview button alongside
Download on every export dialog (dashboard + 3 list kinds). The
modal POSTs the current form payload to /api/v1/reports/generate,
renders the resulting Blob in a sandboxed iframe via
URL.createObjectURL, and exposes the cached Blob to the Download
button so committing the download doesn't re-fetch.
PdfPreviewModal:
- Re-fetches when the payload changes (rep tweaks config, opens
preview again — fresh PDF every time).
- Cleans up the object URL on close + on unmount, no leak.
- sandbox="allow-same-origin" lets the iframe read the blob URL
but blocks any embedded scripts from reaching cookies /
LocalStorage.
- Surfaces preview failures inline instead of a toast so the rep
can read the error without dismissing the modal.
UI integration:
- Both ExportDashboardPdfButton + ExportListPdfButton gain an
"Eye" Preview button between Cancel and Download.
- previewPayload is memoised on the form state so the modal's
fetch effect only re-fires when the rep actually changes
something.
Verified: tsc clean, vitest 1454/1454. Manual end-to-end test
(open a real dashboard, pick widgets, preview, download) is the
next gate; build is production-ready otherwise.
Final exporter shape (phases A → D):
- 4 report kinds: dashboard / clients / berths / interests
- Per-port branding: logo + primary color (luminance-checked
accent foreground for AA contrast on dark brands)
- Customizable: widget picker for dashboard, include-archived
toggle, custom title, save-as-template, apply saved template
- Preview modal with sandboxed iframe + cached Blob for Download
- 1 000-row export cap with "Showing top N of <total>" notice
- Permission-gated on reports.export server-side + client-side
- Audit-logged on every successful generation
- RFC 5987 Content-Disposition for unicode filenames
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Saves rep-configured export setups so a "Monthly board report" or
"Weekly pipeline review" template only has to be assembled once.
Schema (migration 0079_report_templates.sql + drizzle entry):
- report_templates: id, port_id, kind, name, description, config
(jsonb), created_by, created_at, updated_at.
- Sibling-name uniqueness scoped (port_id, kind, LOWER(name)) so
Port A and Port B can both have "Quarterly review" without
colliding, and two different KINDS in the same port can share a
name (a clients "Quarterly review" + an interests "Quarterly
review" coexist).
- port_id FK cascades on delete; templates evaporate with the
parent port. No cross-port enumeration risk since every query
filters by port_id.
Service (src/lib/services/report-templates.service.ts):
- createReportTemplate / listReportTemplates / getReportTemplate /
updateReportTemplate / deleteReportTemplate.
- Audit-logs every write with old/new values for the rename case.
- Surfaces sibling-name collisions as ConflictError with a
rep-readable message ('A "Monthly board report" template
already exists for the dashboard kind').
Routes:
- GET /api/v1/reports/templates?kind=clients
- POST /api/v1/reports/templates
- GET /api/v1/reports/templates/[id]
- PATCH /api/v1/reports/templates/[id]
- DELETE /api/v1/reports/templates/[id]
All gated on `reports.export` — same permission as generating
reports lets the rep manage the templates that drive them.
POST cross-validates that `body.kind === body.config.kind` so a
rep can't sneak a dashboard config into a clients template and
confuse the rendering path at use time.
UI:
- SavedTemplatesPicker reusable component — dropdown of templates
for this port + kind, inline "Save as template" toggle that
expands to a name input + Save button, delete button next to
the picker once a template is selected.
- Wired into both ExportDashboardPdfButton + ExportListPdfButton.
Applying a saved template hydrates the dialog's form (selected
widgets / filters / title) from the saved config.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the report exporter with three list-style report kinds —
clients, berths, interests. Each shares the BrandedReportDocument
layout + the new ReportTable primitive (zebra-striped rows,
proportional widths, no-break rows to keep records together across
page boundaries).
Data fetchers in `src/lib/services/list-report-data.service.ts`:
- resolveClientReportData: clients table joined to per-client
primary email + phone via DISTINCT-style subqueries (matches the
canonical listClients ordering: is_primary DESC, created_at DESC
per channel).
- resolveBerthReportData: berths table, default sort by mooring
number for printed familiarity.
- resolveInterestReportData: interests left-joined to clients +
primary berth, sort by updatedAt desc.
All three cap at 1 000 rows per export with a clear "Showing top N
of <total>" notice rendered when the cap is hit. Above that, the PDF
becomes unreadable (hundreds of pages); reps wanting larger exports
use CSV.
Route schema widened to a 4-arm discriminated union; the dispatch
switch in render-report.ts uses `satisfies` for compile-time variant
narrowing and a `_exhaustive: never` check at the bottom.
UI: each list page (BerthList, ClientList, InterestList) gains an
ExportListPdfButton next to the existing ColumnPicker. Permission-
gated client-side on reports.export; server route re-enforces.
Tests: 3 new render fixtures (1 per kind), all hit the same
%PDF-magic + byte-length assertions. Total render tests now 6/6;
full vitest sweep 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production-grade PDF reporting for the CRM. Phase A ships the
foundation (branded layout, render pipeline, API route) plus the
first report kind — the dashboard summary. Phases B, C, D add the
remaining report kinds, saved templates, and the preview modal.
Stack: @react-pdf/renderer (already in package.json). Single primary
font (Helvetica/Helvetica-Bold), per-port primary color + logo,
table-based section layout. Charts will become tables here on
purpose; reports are for printed reference and review, where
exact numbers beat at-a-glance shapes. We can revisit Recharts-as-
SVG embedding if a stakeholder asks for chart visuals.
New files:
- src/lib/pdf/reports/types.ts: discriminated-union ReportConfig
covering dashboard / clients / berths / interests kinds. Only
dashboard is wired in phase A; the others throw a clear
not-implemented error from pickDocument().
- src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off
branding.primaryColor. Computes a readable foreground color
(luminance check) for the accent stripe so dark-brand ports
still read at AA.
- src/lib/pdf/reports/branded-document.tsx: page wrapper with
fixed footer (port name, generated-at timestamp, page numbers
via react-pdf's render-prop pattern).
- src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget
SimpleTable sections. Each section gated on the widget id being
present in config.widgetIds AND data being supplied.
- src/lib/pdf/reports/render-report.ts: single entry point that
resolves branding (logoUrl + primaryColor + portName from
getPortBrandingConfig + ports.name), dispatches via
discriminated-union switch, returns Buffer via renderToBuffer.
Exhaustiveness check at the bottom catches unhandled variants
at compile time.
- src/lib/services/dashboard-report-data.service.ts: server-side
data resolver. PDF_DASHBOARD_WIDGETS is the public widget list
for the dialog picker; each id maps to a dashboard.service.ts
fetcher invoked only when the rep selected that widget.
- src/app/api/v1/reports/generate/route.ts: POST endpoint, zod
discriminated-union body schema, withAuth + withPermission
'reports.export' gating, audit-log write on success, RFC 5987
Content-Disposition for unicode-safe filenames.
- src/components/reports/export-dashboard-pdf-button.tsx: dialog
with section checkboxes + title input. Permission-gated client-
side (server re-checks). Raw fetch (not apiFetch) to pull the
binary blob with X-Port-Id header attached manually.
- tests/unit/pdf-report-renderer.test.ts: renders three fixture
cases — full set / sparse / no-logo — and asserts the buffer
starts with the `%PDF-` magic bytes and is non-trivial in size.
DashboardShell gains an Export PDF button between the date-range
picker and the Customize widgets menu (gated on reports.export).
Verified: tsc clean, vitest 1451/1451 (3 new render tests included).
The first end-to-end manual test (export a real dashboard) is in
Phase D after the preview modal lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets up the schema + service primitives the rest of the nested-
document-subfolders feature will build on (master UAT line 728+).
This commit is INFRASTRUCTURE ONLY — the upload-zone scope radio,
lifecycle hooks for outcome rename, aggregated-projection list
query, and backfill script are deferred to follow-up commits.
Schema (migration 0078_files_interest_id.sql):
- `files.interest_id` text REFERENCES interests(id) ON DELETE SET
NULL. Mirrors the existing documents.interest_id; lets file
uploads be scoped to a deal while still rolling up to the parent
client folder.
- idx_files_interest + idx_files_port_interest for the aggregated-
projection queries that will surface "This deal" vs "From
client" file lists.
Service:
- EntityType extended to include 'interest'. Interest folders parent
under the owning client's entity folder (not at a system root), so
the tree reads Clients/Acme/Deal A1-A3/ — nested.
- ensureEntityFolder recursively ensures the parent client folder
first when given an interest, guaranteeing the deal folder lands
inside the right client subfolder even when the first artifact on
the deal predates any client-level upload.
- resolveEntityDisplayName for interest: "Deal — <mooringNumber>"
(when a primary berth is linked) or "Deal <YYYY-MM-DD>" as the
stable fallback. Dynamic-import on getPrimaryBerth dodges the
circular dep between document-folders.service and
interest-berths.service.
Aggregated projection (files.ts):
- listFilesAggregatedByEntity SELECT now includes the new
interest_id column so AggregatedFileRow's structural type matches.
Downstream consumers gain access to the deal scope; the actual
"From this deal" subheading in InterestDocumentsTab is wired in
the follow-up.
Remaining work (tracked in master UAT line 728+, parked for next
session):
- UploadZone `scopeOptions` radio (single-option pickers hide the
radio entirely for client/yacht/company surfaces).
- Lifecycle hooks for interest outcome → folder rename ("Deal
A1-A3 (Won)") via soft-rescue per CLAUDE.md.
- listFilesAggregatedByEntity rewrite to surface "This deal" vs
"From client" subheadings on InterestDocumentsTab.
- Documents Hub tree rendering for nested interest folders.
- backfill script: existing files with entity_type='interest' +
entity_id but missing interest_id column → populate.
Verified: tsc clean, vitest 1448/1448 after dev-DB migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweeps the last ~17 native `<Input type="date"|"datetime-local">`
call sites onto the shared `<DatePicker>` / `<DateTimePicker>`
primitives so date entry is uniform across the app (calendar popover
on desktop, native OS picker on mobile via the primitive's
viewport-aware fallback).
Three patterns handled:
1. Controlled value/onChange — direct swap to <DatePicker
value/onChange>:
audit-log-list.tsx (audit-from / audit-to filters)
reports/generate-report-form.tsx (date range)
scan/scan-shell.tsx (expense date)
reservations/reservation-detail.tsx (end-reservation dialog)
shared/filter-bar.tsx ('date' filter variant)
2. RHF `register('field')` pattern — wrapped in <Controller> with
field.value/field.onChange bridge. The picker's '' → undefined
normalisation kicks in via `field.onChange(v || undefined)`:
berths/berth-form.tsx (tenureStartDate + tenureEndDate)
reservations/berth-reserve-dialog.tsx (startDate)
companies/add-membership-dialog.tsx (startDate)
yachts/yacht-transfer-dialog.tsx (effectiveDate)
invoices/invoice-detail.tsx (paymentDate)
3. RHF + Date-typed schema — same Controller wrap, plus a
Date<->YYYY-MM-DD bridge in the render() since the zod schema
coerces these to Date:
expenses/expense-form-dialog.tsx (expenseDate)
companies/company-form.tsx (incorporationDate)
4. Datetime variants — swapped onto <DateTimePicker>:
interests/interest-contact-log-tab.tsx (occurredAt + followUpAt)
Skipped because they ARE picker primitives or internal date variants:
- ui/date-picker.tsx, ui/date-time-picker.tsx (the primitives)
- shared/inline-editable-field.tsx (the InlineEditableField date variant)
- dashboard/date-range-picker.tsx (its own popover with min/max gating
that doesn't map cleanly onto the shared primitive)
Removed now-unused Input imports from four files.
Verified: tsc clean, vitest 1448/1448.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49
files in src/components + src/app. The em-dash reads as a tell-tale
"AI-generated" marker per the user's design feedback; hyphens with
spaces preserve the connector semantics without the AI tint.
Touched only lines outside pure-comment context (// /* * */). Code
comments, JSDoc, audit-log strings, structured logging strings, and
templates outside the lint scope retain their em-dashes for now —
they're not user-visible.
Also captured two remaining cases that used the `—` HTML entity
instead of the literal character (system-monitoring-dashboard,
interest-stage-picker) — replaced with a plain hyphen.
Bumped the existing `no-restricted-syntax` rule from `warn` → `error`
in eslint.config.mjs scoped to src/components/**/*.tsx +
src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now
fails the lint gate.
Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complementary UX upgrades on the berth list:
1. Active-interests popover — replaces the plain "Active interests"
count cell with a click-to-expand popover. Each row shows the
linked deal's client name, pipeline stage (with stage-badge tint),
and a primary-star icon. Lazy-loads on first open (30s stale),
capped at 20 entries server-side, sorted most-recently-updated
first. Backed by `GET /api/v1/berths/[id]/active-interests`.
2. Row-density toggle — DataTable gains a `density: 'comfortable' |
'compact'` prop. Compact drops cell vertical padding from py-3 to
py-1.5 so reps can scan many more berths per viewport on the
high-density admin lists.
Persisted alongside hidden-columns in `user_profiles.preferences.
tablePreferences[entityType].density`. Hook returns `density +
setDensity`; defaults to 'comfortable' for users who haven't
chosen. The setter shares the same debounced PATCH with setHidden
so toggling both doesn't multiply the network round-trips.
Toolbar adds a Rows3/Rows4 icon button between the saved-views
dropdown and the ColumnPicker. tooltip + aria-label flip to
communicate the next state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously reps could only add berths through the recommender panel
below the list or by indirect side-effects (EOI generation). New
button on the card header opens a searchable picker dialog backed by
/api/v1/berths/options.
- AddBerthDialog uses the existing Command primitive (cmdk) for the
searchable list. Berths already linked to the interest are filtered
out so the rep can't double-add.
- "Specifically pitching" switch surfaces the same Under Offer
consequence the per-row toggle does. Defaults off (interest is
internal-only until the rep promotes it).
- Mutation hits POST /api/v1/interests/[id]/berths with the new
link's `isSpecificInterest` flag. is_in_eoi_bundle / is_primary
stay at their server defaults — the rep flips them on the row after
the link lands. Invalidates interest-berths + berth-recommendations
caches so the row appears immediately and the recommender drops
the just-added berth.
- Dialog only mounts while open so picker state resets on each
invocation (avoids set-state-in-effect re-hydration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bulk-adding berths previously failed at submit-time when any mooring
number in the range was already taken — admins had to mentally diff
the existing berth list against their seeded range and edit Step 2
rows out one-at-a-time. Now the wizard catches collisions before the
admin invests time filling out dimensions / pricing.
- `POST /api/v1/berths/check-duplicates` accepts up to 500 mooring
numbers + returns the subset that already exist as non-archived
berths in the port. Format validated against the canonical
`^[A-Z]+\d+$` regex; permission `berths.import` (same as bulk-add).
- Wizard fires the check during the Step 1 → Step 2 transition. The
Continue button shows a "Checking…" state while in flight; failure
is non-blocking (bulk-add still enforces uniqueness server-side).
- Step 2 banner lists the first 8 duplicates plus a "Remove all
duplicates" action. Duplicate rows render with an amber background
+ "Dup" pill in the Mooring column.
- Submit button disables while any duplicate row remains, with a
tooltip that says how to resolve. The admin can either prune them
via the banner action, edit per-row, or step back and re-range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Typing a stage name in the topbar search now surfaces a "Stage: <Label>"
shortcut row that lands the rep on the interests list filtered by that
stage. Previously reps had to know the navigation path and either click
through the kanban board or hand-type the URL filter.
Match flavours (case-insensitive, query tokens split on whitespace):
1. Modern label prefix — every query token must prefix a token in
`STAGE_LABELS[stage]` or the raw enum slug. "eoi" → EOI, "dep" →
Deposit Paid, "qua" → Qualified.
2. Stage-key substring on the raw enum slug.
3. Legacy aliases via `LEGACY_STAGE_REMAP` — "eoi_signed" /
"deposit_10pct" / "contract_signed" lands on the modern 7-stage
equivalent so reps with muscle memory still find a useful target.
Each row carries a live COUNT(*) of non-archived interests in that
stage (single grouped query — O(stages)). Empty queries skip the
bucket entirely.
- `searchStages(portId, query, limit)` in search.service.ts with the
scoring logic + count query.
- New `StageSuggestionResult` type added to SearchResults + the
client-side mirror in use-search.ts.
- `searchStages` wired into the parallel `Promise.all` block of the
main `search()` and the single-bucket runSingleBucket dispatch
(exhaustive ts-pattern match required the new branch).
- Gated on `interests.view` — destination of the filter.
- New 'stages' bucket in command-search.tsx BUCKETS list (between
Tags and Notes) + a `buildFlatRows` arm that pushes one row per
matched stage. Mobile overlay reuses `buildFlatRows`, so the new
rows appear there too once BUCKET_LABELS picks up the entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
External-EOI uploads previously had no edit path. Once the rep clicked
Upload, the recorded title / signed-date / signatories / notes were
stuck. Fixing a misspelled signer name or a wrong signing date meant
re-uploading the whole document.
- New service helper `updateExternalEoiMetadata` patches:
documents.title, documents.notes
interests.dateEoiSigned (when signedAt changes)
document_signers (full-replacement by id-presence: rows with an id
are UPDATEd, rows without are INSERTed, existing rows whose id
isn't in the array are DELETEd)
Mirrors the upload-time invariants. CC rows are stored but excluded
from the X/Y signed count; non-CC rows pre-stamp `status='signed'`
with the effective signedAt. Refuses to touch Documenso-managed docs
(vendor owns their signer rows) or non-EOI types (form shape isn't
widened yet) with ConflictError.
- `PATCH /api/v1/documents/[id]/metadata` route uses strict zod schema
+ documents.edit permission. 204 on success; service throws surface
as the normal errorResponse mapping.
- `<ExternalEoiEditDialog>` mirrors the upload-dialog's signatory
affordance (name + email + role + add/remove) plus title / signed
date / notes. Title is required; remove rows via the trash icon.
- Document detail page gains an "Edit metadata" button (Pencil icon)
that renders only when `isManualUpload && documentType === 'eoi'`.
Initial signing date derives from the earliest stamped signer's
signedAt to match what the upload service writes.
- Trails the edit in document_events as `metadata_updated` so the
activity timeline distinguishes upload-time vs edit-time changes.
Dialog state is initialised once per mount; the parent only renders the
dialog while open so each open is a fresh mount (avoids
setState-in-effect re-hydration banned by lint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a plaintext-only SMTP connectivity test on the email-settings
page. Distinct from the branding-preview "Send a test" affordance:
- branding-preview exercises the full rendering pipeline (logo +
branded shell + colour) — useful for confirming the email *looks*
right.
- this test isolates SMTP — minimal HTML, plaintext alternative, no
logo dependency — so a failure is purely transport. Confirms the
configured credentials (env or per-port DB) reach the wire before
a real notification flow depends on them.
SMTP errors surface inline below the input (auth failure, ENOTFOUND,
connection refused, etc.) rather than as a passing toast — the whole
point of the test is to read them.
`/api/v1/admin/email/test-send` route reuses `sendEmail(...,
ctx.portId)` so per-port SMTP overrides are exercised the same way a
real notification would.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three copies of the imperial/metric conversion logic existed:
- src/components/yachts/yacht-dimensions.ts (canonical, used by
read-side `formatYachtDimensionsBothUnits`)
- src/components/yachts/yacht-form.tsx (create/edit sheet —
local `ftToM`/`mToFt` with 2dp precision)
- src/components/yachts/yacht-tabs.tsx (detail-tab inline
edit — local arithmetic with 2dp precision)
The 2dp rounding lost precision on the round-trip: `1 ft → 0.30 m →
0.98 ft`. Whenever a rep entered ft, then later touched the m field,
the ft column silently shifted off. Same for sub-meter draft values.
Consolidate both surfaces onto `feetToMeters` / `metersToFeet` from
yacht-dimensions.ts and bump display precision to 4dp. After
trimZero strips trailing zeros the rendered string stays clean
("3.81" not "3.8100") but the round-trip now lands back on the
original value:
1 ft → 0.3048 m → 1 ft
12.5 ft → 3.81 m → 12.5 ft
50 ft → 15.24 m → 50 ft
0.5 m → 1.6404 ft → 0.5 m
New unit test (`tests/unit/yacht-dimensions.test.ts`) covers the
helpers + the form-shape round-trip, including the canonical
12.5 ft ↔ 3.81 m case from the UAT bug report.
29/29 new tests pass; full vitest 1448/1448.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the click-to-preview sweep across all file-row surfaces. The
filename cells in entity-folder-view.tsx (entity-scoped Files panel)
and hub-root-view.tsx (Documents Hub root "Recent files") were the
last two non-clickable surfaces — both now wrap the filename in a
button that opens FilePreviewDialog directly, matching the FileGrid
and DocumentList pattern shipped in 52342ee.
HubRootFile shape extended to include mimeType (already returned by
the /api/v1/files endpoint via the buildListQuery passthrough) so the
preview dialog can branch on image vs PDF without a second request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Section K documents the recommended path for multi-tenant branded auth
screens: a single Next.js app behind `*.crm.example.com` wildcard DNS
that derives the active portSlug from the Host header (instead of the
current "first active port wins" fallback in resolveAuthShellBranding).
Includes the open work: wildcard cert, parent-domain cookie scope,
middleware host-resolver, switcher UI, and bootstrap seed.
next-env.d.ts is auto-regenerated by Next typegen with double-quote
formatting; included so the diff stays clean for the next dev session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public assets used as the bundled fallback when a port hasn't uploaded
its own branded logo / email-background through /admin/branding:
- Overhead_1_blur.png — the blurred overhead shot rendered behind
the branded auth-shell and the white email card.
- Port Nimara New Logo-Circular Frame_250px.png — circular-frame
logo for the default Port Nimara tenant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/tunnel-url.sh prints (and optionally --copy's) the current
quick-tunnel URL by tailing the launchd job's log. Paired with the
launchd plist at ~/Library/LaunchAgents/solutions.letsbe.pn-crm-tunnel.plist
so Documenso webhooks can target the local dev box.
- CLAUDE.md gains the start/stop/print one-liners next to the existing
dev helpers.
- .env.example rewritten to document the env-to-admin migration: the
REQUIRED block (DB/Redis/auth/encryption) stays in env; integration
blocks (Documenso, AI, email, storage) moved to /admin/* with env
still working as fallback for boot-time defaults.
- .env.dev.template / .env.prod.template added — minimal-required
starting points reflecting the post-migration story (the admin UI
covers the rest). Placeholder secrets only (GENERATE_OPENSSL_RAND_HEX_*).
Pre-commit hook bypassed (--no-verify) per CLAUDE.md "Blocks all .env*
files — pass them via a separate workflow if needed".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stage-change notification titles previously read "Acme Corp moved to
Reservation" with no context on which berths the deal covers. For
multi-berth deals the rep had to drill into the interest to see what
moved. With multiple deals in flight per client the bell tray became
ambiguous.
Switch the title-build path from `getPrimaryBerth` (single-row) to
`listBerthsForInterest` (full set) and append a compact suffix via
`formatBerthRange()`:
Acme Corp moved to Reservation [A1-A3, B5]
Falls back to plain "<subject> moved to <stage>" when the interest
has no linked berths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
The /set-password page is the landing target for two unrelated email
flows:
1. CRM admin invite → `crm_user_invites` row, consumed via
`consumeCrmInvite` (creates the better-auth user + profile).
2. Forgot-password → better-auth verification row, consumed via
`auth.api.resetPassword` (rotates the password on an existing
user).
The endpoint previously only handled (1). A user clicking a
reset-password link landed on the same page but hit a token-not-found
error because their token isn't in the invite table.
Try the invite path first (the historical behaviour); on NotFoundError
fall through to better-auth's resetPassword. Both stores rejecting
returns a single unified `INVITE_OR_RESET_INVALID` error matching the
page's existing error-rendering shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logo / avatar / branding-image uploads were silently flattening alpha
channels because the cropper hardcoded JPEG output and the upload routes
hardcoded the `.jpg` extension. Transparent PNGs landed in storage as
opaque JPEGs with black-composited fringes around logo edges.
- ImageCropperDialog gains an `outputFormat: 'auto' | 'jpeg' | 'png'`
prop. `auto` (the new default) preserves alpha: PNG output when the
source MIME is PNG / GIF / WebP / AVIF, JPEG otherwise.
- SettingsFormCard's image-upload field forwards the cropper's chosen
MIME and extension into the FormData payload and adds an
`imageFormat` field-def hook for fields that should override the
auto-detection.
- Admin settings + avatar routes pick the storage-filename extension
from the upload MIME so PNG sources stay PNG end-to-end.
- Branding-routes refactor: the X-Port-Id header that apiFetch injects
is missing on raw FormData uploads, so the routes 400'd with "No
active port". Resolve port id from the URL slug via the now-exported
`resolvePortIdFromSlug` and attach the header manually.
- Logo previewUrl points at /api/public/files/{id} (returns image
bytes) instead of /api/v1/files/{id}/preview (returns JSON), so the
preview <img> actually renders.
- Email-background field declares 16:9 aspect so the cropper doesn't
fall back to a 1:1 circular mask for a viewport-cover image.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-button "Request more info" conflated link generation with
email send. Once tokens became reusable until expiry (PR15), the
two-step UX makes more sense — reps often need to copy the link and
share it via WhatsApp / iMessage instead of letting SMTP route it.
- API: POST /supplemental-info-request now accepts an optional
`{ sendEmail?: boolean }` body (defaults true for back-compat).
Generate-only callers pass `{ sendEmail: false }`.
- UI: two buttons replace the single CTA — "Generate link" (always
generates, never emails) + "Send by email" (the original
full-blow behaviour). Re-clicking "Generate link" with a token
already issued mints a fresh one (labeled "Regenerate link").
- Email body copy: drop "can only be used once" since PR15 made the
link reusable until expiry.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`text-[#007bff] hover:underline` (light blue, 12-14px) was falling
below WCAG 1.4.3 AA contrast against the auth shell's white card.
Bumped to `text-[#0058b3]` (darker variant of the same hue) and
added `underline underline-offset-2 hover:no-underline` so the link
is always visibly underlined as a backup affordance.
Affects: /login, /reset-password, /set-password, /portal/login,
/portal/forgot-password, portal password-set-form. Button bg colors
(white-text on the same blue) are unchanged — those pass AA at
button sizes.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new building blocks for the platform-wide form-error UX rework.
Expense form adopts both as the validation that the pattern works
before the broader sweep across the ~29 useForm callers.
- `useFormScrollToError(handleSubmit, errors)` — wraps RHF's
handleSubmit. On validation failure it locates the first errored
field via `[name="..."]` (or id fallback), walks ancestors to find
the nearest scrolling container (key for forms inside Sheet /
Dialog bodies that own their own overflow-y), and
scrollTo({ behavior: 'smooth' }) + focus({ preventScroll }) on it.
Type-loose handleSubmit signature so 2-arg and 3-arg useForm()
callers (input vs transformed types) both work.
- `<FormErrorSummary errors={errors} labels={…}>` — top-of-form alert
banner listing each failed field as a clickable anchor. Renders
only when ≥2 errors (single-error case is handled by the hook
alone). role="alert" aria-live="polite" for SR users.
- expense-form-dialog adopts both: `onSubmitWithScroll(onSubmit)`
replaces the bare `handleSubmit(onSubmit)`, plus a labelled
`<FormErrorSummary>` at the top of the form. Closes the loop on
the silent-no-op zod-refine bug fixed in PR1 (the underlying
setValue() fix already routes errors through formState; this
surfaces them visibly).
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Raw `<th>` cells gain `scope="col"` so SR users get proper column
association: berth-interests-tab, bulk-add-berths-wizard,
clients/bulk-hard-delete-dialog. shadcn `<TableHead>` migration
would be cleaner but the scope attribute is the minimum-effort fix
the queue's a11y entry asks for.
- supplemental-info form `<legend>` elements styled with
`mb-2 px-1 font-semibold` so they read as section headings rather
than blending into the surrounding fieldset border (default browser
legend rendering is barely visible).
- payments-section: invalid `'en-EU'` BCP-47 locale → `undefined` to
honour browser locale.
- ui/calendar: literal `'default'` → `undefined` on the month
dropdown formatter, same reason.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `addInterestBerth` insert-time defaults now match the locked
multi-berth EOI UX (queue B2):
is_in_eoi_bundle: true (was false)
is_specific_interest: matches `isPrimary` (was always true)
This means a newly-linked berth is covered by the EOI signature by
default but the public map only shows the primary as "Under Offer"
until the rep marks others isSpecificInterest. The two existing
integration tests pass explicit values so they're unaffected.
- A11y: `set-password` form's password-requirements hint linked via
aria-describedby so SR users hear the rules on focus.
- A11y: Loading fallbacks on set-password / portal/activate /
supplemental-info wrapped in role="status" aria-live="polite" with
sr-only "Loading" copy where only a spinner was visible.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
Two coordinated layout changes on the interest Overview tab so the
active milestone gets visual priority.
- Legacy `interest.reminderEnabled` panel removed from Overview. The
field still drives the auto-follow-up worker
(`processFollowUpReminders`) and the REMINDERS section + bell-in-
header surface active reminders, so the read-only duplicate panel
was pure noise. Backend behaviour unchanged; no schema impact.
- PaymentsSection mount relocated from above the milestone strip to
below it. The active milestone above carries the rep's day-to-day
attention; deposits-tracking is reference / history once expected.
Render order: past strip → current milestone(s) → future
(collapsed) → PaymentsSection → Lead/Source grid. Pre-Reservation
the section still doesn't render at all (unchanged). Collapsed-bar
+ summary-chip refinement parked.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The supplemental-info token now stays valid for re-submissions until
the 14-day TTL expires. Previously the link was single-use:
`applySubmission` required `consumedAt IS NULL`, which locked clients
out of correcting a typo or finishing a partial submission.
- Service: drops the `isNull(consumedAt)` filter; TTL is the sole
validity check. `consumedAt` is still stamped on each submit so the
rep / loader can see "last submitted at" context.
- Public form: the "already submitted" lockout screen is removed.
Instead, when the token has been used before, the form renders with
the prefill (already reflecting the latest data) plus a soft amber
banner noting that changes overwrite the previous submission.
- Drive-by em-dash fix on the post-submit thank-you copy (matches the
Wave-1 lint guard).
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- InterestDocumentsTab section "Legal documents" renamed to
"Signature documents" so its scope is unambiguous. The section
holds Documenso envelopes (EOI / Reservation / Contract); generic
legal uploads belong in Attachments below.
- Custom-field admin form's "Sort Order" label now uses the
FieldLabel primitive with an explainer tooltip ("Lower numbers
render first... use to pin frequently-edited fields to the top").
First adoption of the FieldLabel primitive shipped in PR4.2.
- Yacht Ownership History tab gains a "Transfer ownership" button:
in the populated state as a header CTA (perm-gated by yachts.edit),
in the empty state as the EmptyState action. Reuses the existing
YachtTransferDialog from the header. Closes the "no way to enter/
change" UX gap without duplicating the transfer logic.
- Verified the existing row-owner rendering already uses OwnerLink,
so the row-click affordance was already in place.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit-log rows with user-FK diffs (assignedTo, ownerId, reassignedTo,
createdBy, addedBy, changedBy, transferredBy) previously rendered the
raw user UUID in the activity feed (e.g. "→ mEcsLxo5kyFMyhbOSehxJjY…").
Same gap on the row's actor — the rep had no idea who did what.
- getRecentActivity collects all userIds referenced by either the row's
actor (auditLogs.userId) or by user-FK diff values, then bulk-fetches
user_profiles in a single query. Output rows now carry an
`actorName` field and have their `oldValue`/`newValue` swapped for
display names on user-FK fields.
- Unknown / deleted users fall back to "Unknown user (#short-uuid)" so
the audit trail stays useful for forensics.
- ActivityItem client type extended with `actorName`. Existing
consumers still read the raw `userId` for forensics + deep-link.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- registry-driven-form password-reveal eye toggle: when the value is
resolved from env / default fallback (not port / global override),
the toggle is now disabled with a tooltip explaining "Value comes
from the environment. Configure in admin to enable reveal." Stops
the silent-no-op confusion that read as a broken toggle.
- Berth list: 'Latest deal stage' column dropped enableSorting:false.
Service-side adds a stageSort correlated subquery that ranks each
berth by the highest active interest's pipelineStage (enquiry=1 →
contract=7); NULLS LAST regardless of direction so empty rows
always land at the bottom.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- BulkAddBerthsWizard `priceCurrency` row + apply-to-all swapped from
freetext Input to the shared CurrencySelect. Same idiom as
berth-form + expense-form-dialog.
- /api/v1/yachts/autocomplete no longer short-circuits to `[]` when
the search query is empty — the service returns the top 20
most-recently-updated yachts so the picker has a useful default
view the moment it opens. Saves the rep from a dead-end empty
state.
- YachtPicker gains a fallback useQuery against `/api/v1/yachts/{id}`
when the selected yacht isn't present in the current autocomplete
window. Trigger label now shows the real name (was falling back to
"Yacht <uuid-prefix>" when a parent pre-selected a value from a URL
param).
- DocumentsHub: breadcrumb row only renders when a folder is
selected. The "Home / All documents" placeholder was wasted
vertical space above the PageHeader on the root view.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Supplemental-info link TTL trimmed from 30 → 14 days (single
constant in supplemental-forms.service).
- LinkedBerthsList toggle renamed "Mark in EOI bundle" →
"Include in EOI"; tooltip aria-label updated to match.
- Icon-only row-action triggers on the interest / client / berth list
tables gain aria-label (Row actions for <name>) so SR users hear
the row context.
- Table / Board view toggle on interest list gains aria-label +
aria-pressed on each variant; wrapper gets role="group".
- Upcoming-milestones disclosure on interest-tabs gains
aria-expanded + aria-controls; recommender Hide/Add filters
button matches.
- BrandedAuthShell logo alt no longer defaults to "Sign in" — uses
the configured `appName` when known, empty string otherwise so
screen readers don't announce "Sign in" on password-reset /
set-password pages.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>