useBerthPatch invalidated ['berths', berthId] (plural+id), but the
berth detail page reads ['berth', berthId] (singular+id). Cache key
never matched, so the PATCH landed in the DB but the visible field
reverted to its pre-edit value on re-render. Realtime invalidation
covered for it via 'berth:updated', but Socket.IO is unavailable
in some dev environments.
Switch to the correct singular key + keep the plural-list invalidation
so list views (BerthList, bulk-edit sheet) also refresh.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands the UI half of the bulk-price feature (backend already shipped).
Reps with berths.update_prices can retune pricing without unlocking the
rest of the berth schema, both one-at-a-time and in bulk.
- berth-columns: PriceCell wraps InlineEditableField, gated by
can('berths', 'update_prices'). Click → input → save through
PATCH /api/v1/berths/[id]/price. stopPropagation so row click
doesn't navigate while editing.
- bulk-price-edit-sheet: right-side Sheet listing selected berths from
the React Query cache. Per-row price + currency inputs with dirty-
highlight. "Set all to" + "Adjust by %" shortcuts. Diff-only POST to
/bulk-update-prices reports updated/unchanged/missing. Body is keyed
on the selection so useState initializes fresh per open.
- berth-list: new "Update prices" bulk action gated by the same
permission, sits between Remove tag and Archive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per docs/superpowers/audits/alpha-uat-master.md Bucket 3 #1. When a
yacht is linked to the interest the rep can flip a per-interest toggle
so the berth recommender reads dimensions off the yacht record instead
of the rep-entered desired_* columns.
- Migration 0087 + interests.useYachtDimensions boolean (default false).
- Validator (createInterestSchema) accepts the new field; service insert
+ update paths spread it through automatically.
- berth-recommender.service.loadInterestInput dual-source resolution:
when toggle=true AND yachtId is set AND the yacht has at least one
measurement on file, the recommender uses the yacht's length / width /
draft instead of the desired_* values. Falls back to the desired
columns whenever any precondition fails (no yacht link, toggle off,
or the yacht carries no measurements). Returned InterestInput gains
a `dimensionsSource: 'interest' | 'yacht'` trace field.
- Interest form: under the "Berth size desired" section, when a yacht
is linked, a checkbox surfaces — "Use the linked yacht's dimensions
for the recommender". When checked, the three dimension inputs grey
out (DimensionInput gains a `disabled` prop) so the rep can't
accidentally edit the now-overridden values. Hint text spells out
the fallback behaviour.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder grows an optional Cover-page brand picker
surfaced only when can('admin', 'manage_settings') AND the user has
access to >1 port. Pulls ports from PortContext; default option is
"Use active port brand", remaining options are the other ports the
user can reach. Choice persists in config.coverBrandPortId; threaded
through preview, download (/reports/generate), and queue
(/reports/runs) payloads.
- render-report.service.ts: when run.config.coverBrandPortId resolves
to an accessible port, the cover-page logo + portName come from THAT
port's brand kit. Falls back to the source port silently when the
override port is missing or stale. Source-port DATA stays — only the
cover branding swaps. Useful for cross-port leadership decks.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- renewTenancy service:
- permanent / fee_simple / strata_lot → mutate-in-place (startDate
moves forward, endDate may extend or null out)
- fixed_term / seasonal → end the current row at its existing endDate
+ mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
transferDate, mint fresh active row with transferredFromTenancyId
pointing back. New client + yacht cross-validated against port +
ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
src/lib/validators/tenancies.ts.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Migration 0086: berth_tenancies.previous_tenancy_id +
transferred_from_tenancy_id self-FKs + partial indexes. Per
docs/tenancies-design.md these chain renewal / transfer successors
to predecessors for fixed-term and seasonal lineage. Schema mirrored
in tenancies.ts with AnyPgColumn typed-import.
- POST /api/v1/tenancies (generic create): accepts berthId in the
body so client + yacht tab entry points don't have to bounce through
/api/v1/berths/[id]/tenancies. Same createPending service helper.
- TenancyCreateDialog: <TenancyCreateDialog open clientId? yachtId?
berthId? /> with all three pickers; pre-fills the carrier from the
parent entity. POSTs to /api/v1/tenancies; "Create" and
"Create and activate" CTAs both wire to the new endpoint.
- Mounted on ClientTenanciesTab + YachtTenanciesTab behind
<PermissionGate resource="tenancies" action="manage"> so reps can
mint tenancies directly from those tabs without bouncing through
the berth page.
- TenancyEditDialog: edit metadata only (start/end dates, tenure type,
notes) via the new action='update' branch on the [id] PATCH route.
Status transitions stay on activate/end/cancel. Wired into the
tenancy detail page header. Outer wrapper unmounts on close so the
form re-initialises from current row data without setState-in-effect.
- updateTenancy service helper + PATCH action='update' branch added.
Audit-logged + emits berth_tenancy:activated to invalidate detail
query caches.
Renew + Transfer dialogs deferred — both need lineage UX decisions
(tenure-aware mutate-in-place vs new-row spawn; client/yacht swap
semantics) and the self-FK columns this commit lands are the
underpinning. Next sub-task.
Verified: tsc clean, 1493/1493 vitest, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PlacedField gains optional defaultValue + fieldMeta carriers. The
field-placement submit threads fieldMeta verbatim through the FormData
payload (only when populated), where the API route + service +
Documenso client already accepted it (v2 field/create-many honours
fieldMeta per row).
- FieldSidePanel grows a FieldMetaSubPanel that renders per-type
controls in the right rail:
- TEXT — default text, label, required toggle
- NUMBER — format string, min, max, required
- CHECKBOX — multi-select option editor with per-option `checked`
- RADIO — single-select option editor (mutually-exclusive default)
- DROPDOWN — single-select option editor
Each writes shallowly into field.fieldMeta so Documenso v2's
create-many endpoint receives the shape it expects. SIGNATURE /
INITIALS / DATE / EMAIL / NAME render nothing (no per-instance
config today).
- ChoiceMetaEditor extracted as a top-level component so the option
list doesn't recreate its DOM subtree on every keystroke
(react-hooks/static-components rule).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DashboardReportBuilder gains an optional Subtitle input alongside
Title. Persisted in the config payload sent to /api/v1/reports/runs
+ /api/v1/reports/generate + threaded through the preview payload's
useMemo dep list so live preview reflects the override.
- Cover-page brand picker (admin-only) — deferred. Today the renderer
uses the active port's brand kit; cross-port branding swap needs a
permission gate, port-pick UI, and a renderer override and is queued
for a follow-up. Subtitle alone covers the most common ad-hoc need
(custom cover-page subtext like "Board pack — March 2026").
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- report-render.service.ts: KindRenderer now carries a per-kind toCsv
serializer alongside the PDF renderer. renderReportRun branches on
run.outputFormat — 'pdf' (existing path), 'csv' (new), 'png' (throws
with a clear "deferred" message so the run lands as 'failed' without
a partial blob). Storage path, mime type, filename + extension all
pick up the output-format suffix; the file row mirror records the
matching mime so the standard download surface serves it correctly.
- csvCell / rowsToCsv helpers: RFC-4180 escaping (always double-quoted,
doubles internal quotes, CRLF newlines).
- 4 per-kind serializers:
- dashboard: stage-count + top-interests + meta as 3-col CSV
- clients: activity log rows (id/createdAt/action/entityType/entityId/userId)
- berths: occupancy metrics (totalBerths + occupancyRate + status counts)
- interests: revenue metrics (completed + forecast + per-stage breakdown)
- DashboardReportBuilder + SimpleReportBuilder gain an Output-format
toggle (PDF | CSV). DashboardReportBuilder threads it into the queued-
run POST; SimpleReportBuilder threads it directly. Synchronous PDF
download path (Dashboard "Download PDF" button) stays PDF-only since
/api/v1/reports/generate returns a blob, not a run row.
PNG remains deferred — flagged with a follow-up TODO inside the render
branch + the builder selector deliberately omits PNG so reps don't pick
it and watch a run fail.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P4 — landing + builder:
- /[portSlug]/reports — new landing page with 4 build-kind cards
(dashboard / clients / berths / interests), 3 library cards
(Templates / Runs / Schedules), and the pre-P4 reports list
preserved under "Legacy library" so historical PDFs stay accessible.
- /[portSlug]/reports/[kind] — kind-aware builder route.
- dashboard: refactored the existing export dialog body into
DashboardReportBuilder (page-mounted; same widget grouping +
date-range + SavedTemplatesPicker + preview). New "Queue + go to
Runs" CTA enqueues a report_runs row via /api/v1/reports/runs
(Reports P3 path); "Download PDF" keeps the synchronous /generate
fallback for ad-hoc one-shots.
- clients / berths / interests: SimpleReportBuilder — date-range +
enqueue to /api/v1/reports/runs. Kind-specific filters land
alongside dedicated renderers in P6+.
- Dashboard "Export as PDF" button rewired: no longer opens an
in-dashboard Dialog. Becomes a Link → /reports/dashboard?from=...&to=...
carrying the currently-active range through search params so the
builder pre-fills it. Removes the dialog body (~290 lines) from the
button file; the same UI lives in DashboardReportBuilder.
- ?from=YYYY-MM-DD&to=YYYY-MM-DD search params pass the range into the
builder page.
P5 — sub-pages (functional, backed by P2 CRUD endpoints):
- /reports/runs — paginated table of report_runs with status badges,
auto-polls every 5s while any row is pending/rendering, per-row
Download (file by storageKey) + Re-run actions.
- /reports/templates — saved template grid. Clicking the name links to
the builder with ?templateId=… so it pre-applies.
- /reports/schedules — schedule table with cadence labels (weekly /
monthly / quarterly), next-run timestamps, recipient counts, and a
per-row enable Switch (PATCH /api/v1/reports/schedules/[id]).
Verified: tsc clean, 1493/1493 vitest, dev-server compile clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MUST-FIX:
- src/app/api/v1/admin/users/[id]/permission-overrides/route.ts:70 — the
PUT allowlist still gated `reservations: {view,create,activate,cancel}`.
Stale: would reject valid `tenancies.{view,manage,cancel}` writes and
silently accept ghost `reservations.*` writes that never land. Replaced.
- src/lib/services/alert-rules.ts:68 — `reservation.no_agreement` alert
emitted `entityType: 'reservation'`. Every other tenancy-related
audit/socket/dashboard label is `'berth_tenancy'`. Inconsistent dedupe
+ activity-feed label miss.
- tests/e2e/exhaustive/08-portal.spec.ts:6 — hardcoded /portal/my-reservations
navigates to a 404 every run.
- tests/e2e/exhaustive/03-reservations.spec.ts — entire spec renamed to
03-tenancies.spec.ts; tab + button locators updated to match renamed UI.
SHOULD-FIX (consistency):
- src/components/clients/client-detail.tsx — useRealtimeInvalidation only
caught 3 of the 4 berth_tenancy:* events; added the `:created` listener.
- src/lib/services/client-merge.service.ts — MergeResult.movedRows.reservations
+ snapshot.reservations + local loserReservations / movedReservations
renamed to tenancies / loserTenancies / movedTenancies. No external
consumers grep-confirmed.
- src/lib/services/gdpr-bundle-builder.ts — GdprBundle.reservations field
renamed to .tenancies; user-facing HTML section "Reservations" → "Tenancies";
local reservationRows → tenancyRows.
- 6 UI copy strings: gdpr-export-button, bulk-archive-wizard,
bulk-hard-delete-dialog, hard-delete-dialog, admin-sections-browser ×2,
admin/import/page, won-status-panel — all "reservations" prose updated
to "tenancies" (occupancy-record sense).
- tests/integration/api/tenancies.test.ts — handler import aliases
`createReservationHandler` etc renamed to `createTenancyHandler` etc.
- tests/unit/services/berth-tenancies.test.ts — local helper makeReservation
→ makeTenancyLocal (avoids shadow of the renamed factory).
- scripts/audit-permissions.ts — stale allowlist entry for
/berth-reservations/[id]/route.ts removed (path no longer exists).
- docs/runbooks/permission-audit.md — stale row for same path removed.
- docs/tenancies-design.md — fixed factual error
("tenancies.service.ts" → "berth-tenancies.service.ts").
Verified: tsc clean, 1493/1493 vitest.
Dev-server note: the running `next dev` process started before P2 and
shows Turbopack cached compile errors against the renamed schema files.
Source is correct (./tenancies); restart `next dev` to clear the cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- tenancy-reports.service.ts: 4 read-only query functions backing the
widgets. Heatmap uses a months×areas SQL grid with date-range overlap;
renewals-at-risk filters active tenancies whose end_date is inside a
90d window with NO successor pending/active row already minted on the
same berth; revenue forecast buckets active tenancies by their
end-date quarter; tenure breakdown is a simple GROUP BY status='active'.
- 4 new API routes under /api/v1/dashboard/tenancy-*:
- tenancy-occupancy (heatmap)
- tenancy-renewals (at-risk list)
- tenancy-revenue (forecast)
- tenancy-tenure (breakdown)
Each prepended with assertTenanciesModuleEnabled so a port without
the module gets 404 instead of an empty payload.
- 4 widget components:
- TenancyOccupancyHeatmapWidget — areas × months table with shaded
cells (5-tier emerald ramp by occupancy %)
- TenancyRenewalsAtRiskWidget — top-10 list, 30-day urgency badge
- TenancyRevenueForecastWidget — horizontal bar list by quarter,
currency-formatted totals
- TenancyByTenureTypeWidget — proportional bars, color-coded per
tenure type
- WidgetIntegration union extended with 'tenancies_module'; the
useDashboardIntegrations hook reads it off PortProvider (no extra
fetch). All four widgets register with selfGates=true +
requires='tenancies_module' so the picker AND render path filter
them out when the module is off.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PortProvider exposes tenanciesModuleByPort + a useTenanciesModuleEnabled()
hook that returns the flag for the currently-active port. Synchronous
read off context (server-resolved in the dashboard layout), so no
fetch latency / hydration flicker when the rep flips ports.
- buildBerthTabs / getClientTabs / getYachtTabs gain a
tenanciesModuleEnabled option. When false, the Tenancies tab is
filtered out entirely. When true, it slots into the entity-specific
position (after Interests on berth + yacht; after Companies on client).
- BerthDetail / ClientDetail / YachtDetail pass the hook value through.
Hook call ordered above the early-return so React's rules-of-hooks
stays satisfied. Existing read-only tab content (Active tenancy card
+ History + the berth-side BerthReserveDialog "Create tenancy" CTA
from P2) stays untouched — it just becomes visible when the module
is on.
Deferred (separate ship): generic TenancyCreateDialog that pre-fills
clientId / yachtId from the parent entity context, so client / yacht
tabs can mint a tenancy without bouncing through the berth detail page.
Today client/yacht Tenancies tabs are read-only (the create entry-point
is the berth tab); the generic dialog will land alongside the Edit /
Renew / Transfer / End dialogs (design § P6 sub-tasks).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dashboard layout resolves tenanciesModuleByPort server-side (one
isTenanciesModuleEnabled call per port the user has access to) and
passes the map through AppShell → Sidebar. Atomic SSR — no
flicker of the nav entry in/out after hydration.
- Sidebar gains NavItemGated.requiresTenanciesModule. The Tenancies
entry (KeyRound icon, immediately below Berths) only renders when
the currently-active port has the flag flipped on. Per-port live
switch fires when the rep toggles ports without reload.
- /[portSlug]/tenancies + /[portSlug]/tenancies/[id] both call
isTenanciesModuleEnabled and notFound() when disabled — guards
against direct URL access even when the sidebar is hidden.
- API routes (/api/v1/tenancies, /[id], /berths/[id]/tenancies)
prepended with assertTenanciesModuleEnabled — matches design §
"All routes ... return 404 when off". NotFoundError maps to 404.
- Existing tenancy API tests get a makePortWithTenancies() helper
(calls enableTenanciesModule after makePort) so the gate is
satisfied. Affects 2 test files (16 tests retargeted).
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the form-error rollout the prior session shipped on the 6
highest-impact forms (client/interest/yacht/company/berth/expense). Adds
the scroll-to-first-error wrapper + the top-of-form summary banner to:
- src/app/(auth)/login/page.tsx
- src/app/(auth)/reset-password/page.tsx
- src/app/(auth)/set-password/page.tsx
- src/app/(auth)/setup/page.tsx
- src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
- src/components/berths/berth-detail-header.tsx (status-change dialog)
- src/components/companies/add-membership-dialog.tsx
- src/components/invoices/invoice-detail.tsx (record-payment form)
- src/components/reservations/berth-reserve-dialog.tsx
- src/components/yachts/yacht-transfer-dialog.tsx
Each call site: hook wraps handleSubmit, FormErrorSummary renders only
when 2+ errors fire (no visual change otherwise), and per-form `labels`
prop translates field names to human-readable strings. invoice-line-items
is a sub-form via useFormContext, so it inherits from the parent.
1471/1471 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When ExternalEoiUploadDialog mounts on an interest with a non-terminal
generated EOI (status sent / partially_signed / draft), it now surfaces
an amber banner naming the active envelope and offering two paths via
radio:
- "Cancel the generated envelope and replace it" (default + recommended):
upload posts cancelActiveDocumentId; the service voids the upstream
Documenso envelope + flips the local doc row to cancelled BEFORE the
new external-EOI doc lands. Audit-log on the new doc carries
metadata.replacedDocumentId so reps can trace cause + effect.
- "Keep both records (advanced)": legacy behaviour - leaves two EOIs on
the deal. Useful only for backfilling intentionally-parallel records.
Cancel runs outside the upload transaction so a Documenso void error
doesn't block the upload the rep has already photographed. The dialog
already shares cache + envelope shape with InterestDetail, so the recent
B4 #4 fix means opening the dialog no longer blanks the page.
cancelMode='delete' is hardwired in the replace path (kill the upstream
envelope on void). Pairs with the existing keep_remote affordance on the
manual Cancel-document flow shipped earlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EoiGenerateDialog gains an inline "Include on EOI" checkbox in the
Section 3 header (renders only when ctx.yacht is set; defaults ON so
existing behaviour is unchanged). When OFF, the generate-and-sign POST
flips includeYachtDetails=false on the body; service blanks
eoiContext.yacht before either pathway runs:
- Documenso template payload: buildDocumensoPayload reads no yacht so
yacht.* and owner.* merge fields ship empty. Existing template tolerates
blanks per the "left blank if absent" copy.
- In-app PDF fill (pdf-lib): generateEoiPdfFromTemplate sees no yacht so
AcroForm field writes for the yacht block are skipped.
Persists the rep's choice in the document-create audit log
(metadata.includeYachtDetails) so an audit trail records explicit opt-outs
even though documents has no JSONB metadata column today.
ft/m unit toggle in the Section 3 header now hides when Include is OFF
(unit choice is meaningless without yacht details).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a 'radio' SettingType the registry-driven admin form can render. Same
shape as 'select' (options list, enum validation, resolved/source badges),
but renders inline radio cards instead of a dropdown so each option's
consequences sit side-by-side for the admin.
Adopted on the two highest-stakes Documenso behaviour toggles:
- `eoi_send_mode` — Manual vs Auto signing-invitation dispatch
- `documenso_signing_order` — Parallel vs Sequential recipient flow
Both choices are binary and materially different (one auto-sends mail, the
other doesn't; one routes signing serially, the other in parallel), so the
upfront comparison beats a hidden dropdown.
`documenso_redirect_url` keeps its url-input — it's already a single
free-text field with no enum.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to prior commit — the untracked file-icon.tsx that both
EntityFolderView and FileGrid now import.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extract FileIcon mapping to `src/components/files/file-icon.tsx` (single
source of truth for mime→icon+colour palette; was previously inline in
FileGrid only).
- EntityFolderView file rows now render the type-specific icon (PDF/red,
Image/blue, Sheet/green, Video/purple) instead of a generic FileText —
multi-deal clients become scannable at a glance.
- Add an inline "Signed" pill on rows where signedFromDocumentId is set so
reps can distinguish a signed-from-workflow copy from a vanilla upload
without hovering for "View signing details".
- Tighter hover treatment (row picks up a subtle bg on hover) for affordance.
- FileGrid refactored to consume the shared FileIcon so both surfaces stay
in lockstep on future mime additions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three B4 bug fixes shipped together:
- **#4 Upload-signed-copy blank body** — ExternalEoiUploadDialog used
queryKey=['interests', interestId] but didn't unwrap the {data} envelope
while the parent InterestDetail (same key) does, so opening the dialog
clobbered the cache with a wrapped shape and blanked the detail page
("Unknown Client" + empty tab body). Dialog now unwraps to match.
- **#2 Legacy-stage canonicalization regression test** — new integration
test locks in the external-EOI advance gate: canonical pre-EOI stages
(enquiry/qualified/nurturing) advance to 'eoi' on upload; at-or-past-EOI
stages stay put while metadata still writes. 7/7 passing. Backfill
script intentionally not shipped — dev DB is test data, prod cutover
is manual.
- **#3 Global-search dropdown translucent rows** — defensive opaque
background on the popover wrapper (bg-white dark:bg-popover) guards
against the subtle transparency UAT captured on the Berths page.
Live-browser repro still needed to identify the exact bleeding row;
this defense makes the surface unambiguously solid in light mode
regardless of which class wins tailwind-merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locked decision from the audit: bump every Sheet width uniformly so
content-dense drawers (EoiGenerateDialog, InterestForm, ClientForm,
…) get more horizontal room without per-site overrides. Adds a
lg:max-w-xl tier so wide viewports get extra breathing room while
the sm tier stays tight on tablets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B2 Wave A (visual breakpoints):
- Documents Hub folder rail: widen ResizablePanel default 20→22%,
min 14→18%, add min-w-[180px] CSS floor so names don't truncate
at tablet 768
- Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6
at xl so "Visit duration" stops truncating in the 1024+sidebar
layout
- Pipeline Value tile per-stage rows: compact $3.5M format on
sm- breakpoint (responsive sm:hidden / hidden sm:inline pair)
B2 Wave D (form-error UX rollout):
- useFormScrollToError + FormErrorSummary wired into 5 high-impact
forms: client-form, interest-form, yacht-form, company-form,
berth-form. Validation failures now scroll the first errored
field into view + render a top-of-form summary banner when ≥2
errors exist. Remaining ~23 form surfaces queued for follow-up.
B2 Wave B (Umami follow-ups):
- TopList primitive: add onExpandRange + expandRangeLabel props
for the empty-state nudge ("Try last 30 days" button). Callers
can opt in to drive the page-level DateRange.
B2 Wave C (FieldLabel + admin tooltip audit):
- Verified FieldLabel primitive already exists + is adopted in
custom-field-form. Registry-driven-form renders entry.description
inline below labels for every entry — the broad sweep across
15-20 admin pages is deferred to a focused polish session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
EOI uploads from 'qualified' silently skipped the stage flip. Now also
writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
pdf/templates/{interest,client}-summary, interest-picker, timeline route
all route through canonicalizeStage / stageLabelFor.
Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
(deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
pipeline-column (kanban), interest-columns (list), interest-card,
interest-detail (breadcrumb), client-pipeline-summary +
client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.
Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
canonical BERTH_STATUSES); cleaned from dashboard.service,
dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
"Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
more 2-line wraps on "needs date range"); accepts initialRange?:
DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
rangeToBounds.
Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
berths (where the only active deal touching the berth IS this same
interest). Waits for all competing-queries before committing the
count. Was showing "3 berths unavailable" when only 1 actually had a
competitor.
Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
instead of firstAt so visible timestamp matches the sort key.
Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.
EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
one batched getAllBerthMooringsForInterests call across all groups.
AggregatedFile type + EntityFolderView render the badge linking back
to the parent interest.
External EOI upload dialog
- Title input pre-fills from the derived default via controlled
displayTitle = title || defaultTitle (no setState-in-effect).
EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
with tooltip: the primary IS the canonical "berth for this deal",
excluding it is semantically nonsense.
Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
whenever is_primary=true; update path coerces back to true when the
caller tries to set false on a primary. Backfilled 7 existing rows.
Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
documenso_redirect_url → public_site_url → null. Operators with
public_site_url configured (most ports) now get sensible signer
landing without setting two settings.
World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
filtered Clients page via router.push instead of copying a URL to
clipboard.
Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
folder has children. Lets reps drill into subfolders from the main
content area, not only via the sidebar tree.
Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
param). Interest list passes updatedAt desc so the table header
surfaces the active sort visibly + most-recently-added/edited bubble
to the top.
Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
— explicit input → port's default_new_interest_owner setting →
creator (when not super-admin). Super-admins skipped since they often
create on behalf of other reps.
Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
flipped to true.
Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed
Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Finishes B4 #8 by completing the UI half of the per-interest filing
model. Backend foundations (files.interest_id column, ensureEntityFolder
for 'interest', upload-zone scope radio, outcome rename hook, backfill)
shipped earlier in this audit cycle.
- listFiles validator + service: optional interestId filter
- listFilesAggregatedByEntity: routes entityType='interest' to a new
helper that returns "THIS DEAL" + "FROM CLIENT" + symmetric-reach
company/yacht groups
- InterestDocumentsTab: Attachments section now renders two cohorts
via two paginated queries, with client-side de-duplication so files
filed under this deal don't double-count under "From client"
- FileRow type exposes the optional interestId so the de-dupe filter
doesn't need a re-fetch
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.
- UploadForSigningDialog: interestId now string | null; new entity?,
folderId?, onCreated? props. Generic path POSTs to new endpoint
/api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
lookup, pipeline-stage advance, doc-status flip on the generic path.
Routes file FK + auto-filed folder via either interest.clientId or the
caller-supplied entity. Validation enforces the matching invariant
(generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
gated by documents.send_for_signing.
Existing unit tests for the service still pass (validation paths unchanged).
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
Two days, two modals, both touching widget layout - collapsed into
one. The separate "Rearrange" button + RearrangeWidgetsDialog from
54c5d0f are gone; the Customize modal now does both jobs:
- Two sections in the body: "On dashboard (N)" and "Hidden (N)"
- Visible rows are sortable (drag handle on the left, position number,
switch on the right). Single SortableContext, vertical strategy.
- Hidden rows are toggle-only (no drag handle - order doesn't matter
for off-dashboard widgets). Flipping the switch on appends to the
bottom of the visible section.
- Both visibility toggles and reorder commits optimistically via
useDashboardWidgets so the dashboard reflows in the background.
dashboard-shell: removes the Rearrange button + RearrangeWidgetsDialog
import + setOrder destructure. rearrange-widgets-dialog.tsx deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-place drag (N46 / a147cbc) had two failure modes:
- Bucket constraints: each layout group (charts / rails / feed) was
its own SortableContext; drops outside the active group silently
no-op'd, so any cross-region drag did nothing.
- Long drags lost their drop target: dnd-kit's closestCenter
collision detection on a sparse grid would intermittently null
out `over` mid-drag, which presented as the dragged tile snapping
back to its original slot.
Switched to a single-flat-list modal:
- New <RearrangeWidgetsDialog>: opens from the "Rearrange" button,
shows every visible widget as a row with a drag handle and a
position number, single vertical SortableContext, Save commits.
- Dashboard shell strips the DndContext + per-bucket SortableContext
wrappers + the SortableWidget cell + all dnd-kit imports related
to the canvas drag. Each widget renders as a plain <WidgetCell>.
- Rearrange button now opens the dialog instead of toggling a drag
mode. Disabled when there's fewer than 2 visible widgets.
The drag persistence fix from ee4d5c8 still applies — the dialog's
Save calls the same setOrder() that PATCHes preferences.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported: "when I refresh the page with this size viewport it
switches between tablet and desktop view." The root cause was the
two-step tier resolution:
1. Server renders shell based on User-Agent (mobile vs desktop only).
2. Client mounts with that hint, useEffect runs matchMedia, may flip.
When the UA says "desktop" but the viewport is actually 900px (so
matchMedia says "tablet"), the chrome visibly switches mid-render.
Most painful on macOS Safari dragged below 1024.
Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on
every matchMedia evaluation. The dashboard layout reads the cookie
and prefers it over the UA classifier for `initialFormFactor`. First
visit can still flicker (no cookie yet); every subsequent reload uses
the resolved tier and renders the correct chrome on first paint.
The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's
initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by
design — AppShell's useEffect resolves the actual tier client-side
from matchMedia). 'tablet' from the cookie collapses to 'desktop' on
SSR; AppShell's useEffect re-resolves to tablet immediately. The
fluent path on cookie hit is desktop -> tablet (no flicker because
both shells render the desktop tree; only the sidebar Sheet wrapper
differs, and that's invisible until opened).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported the search bar dropping to a second row + the top-right
buttons (+ New / Inbox / Avatar) going missing as they resized the
browser. Playwright probe confirmed: at every width 780-1280 the
search bar's intrinsic `max-w-2xl` (672px) forced the topbar's
center grid column to expand to that width, leaving the right
column too narrow to hold "+ New + Inbox + Avatar" without
overlapping the search OR going off-screen.
Two coordinated fixes:
1. Grid template `auto_1fr_auto` instead of `1fr_minmax(280,800)_1fr`.
Side columns now size to their actual content (logo + breadcrumbs
on the left; New + Inbox + Avatar on the right); the center
column takes whatever's left. No more "intrinsic content forces
the column to grow" behaviour.
2. Search wrapper max-width scales by tier: max-w-md (448px) at
base, lg:max-w-xl (576px), xl:max-w-2xl (672px). Generous enough
on wide screens, restrained enough on narrow ones so the side
columns always get the space they need.
Verified via Playwright probe at 780/900/1023/1024/1100/1280 —
"+ New" button now lands inside the header at every width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final Bucket 1 visual-audit follow-up. Audit of all 4 useIsMobile
callers:
- pipeline-chart.tsx + pipeline-funnel-chart.tsx → keep useIsMobile
(short x-axis stage labels apply on tablet too — bar charts can't
fit full "Reservation" / "Deposit Paid" text at narrow widths).
- date-picker.tsx + date-time-picker.tsx → migrate to useViewportTier.
Tablet (768-1023) has plenty of room for the desktop Popover
Calendar; only the smallest phone widths now fall back to the
native datepicker input.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two Bucket 1 quick-fixes from the 2026-05-22 visual audit, both
1-2-line CSS changes with outsized visual impact.
PageHeader stack point: lg → xl
The earlier sm → lg revision (commit 6d665d0) fixed the 768 tablet
crush but introduced a SECOND crush at exactly 1024: that's where
the desktop shell mounts (sidebar = 256px) AND lg:flex-row kicks
in, leaving the title cell to compete with a 4-button action row
in only ~720px of content. Title degraded to "(" and "Last 30
days" wrapped three-deep ("Last / 30 / days"). Moving to xl
(1280) keeps the strip stacked through tablet AND the narrowest
desktop width. Verified via Playwright at 1024 — title now reads
cleanly with the action row stacked below.
Topbar tablet logo trigger:
AppShell mounts a logo button in Topbar's leadingSlot prop on
tablet (the design intent: click logo → sidebar Sheet slides in).
Live screenshot at 768 showed zero affordance — search bar started
at the very left edge of the visible viewport. Two root causes,
both fixed:
- center grid column was minmax(420px, 800px) which starved the
left column to ~100px at 768 width (no sidebar present).
Changed to minmax(280px, 800px) at base, minmax(420px, 800px)
only at lg+.
- search container had unconditional sm:-translate-x-...
shifting it 128px LEFT to compensate for a sidebar that isn't
present at tablet, pulling the search input over the leading-
slot. Gated the translate to lg: so it only kicks in when the
sidebar is actually inline.
Verified via Playwright at 768 — hamburger icon now appears in
the top-left corner; search bar sits to its right without overlap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior fix (c1daed1) collapsed the JSX onto one line so the
eslint-disable-next-line directive correctly targeted the `as any`
cast. Lint-staged's prettier ran on the next commit and reflowed the
attribute back across multiple lines, separating the directive from
the cast and re-triggering @typescript-eslint/no-explicit-any.
Cast to `Route` (typed-routes' own escape hatch) instead of `any`.
No eslint-disable required, and prettier can reflow freely without
breaking the lint contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two findings + a stale comment crossed the production build threshold
because the eslint-disable-next-line directives didn't actually cover
the line that triggered the rule.
- clients-by-country-widget.tsx: the disable on line 96 targeted the
JSX `href={` opener on line 97, but the `as any` cast lived on
line 98. Collapsed to one line so the directive applies to the
cast directly.
- use-form-scroll-to-error.ts: single disable above the type alias
targeted the type's name line, not the `any` typed params two lines
below. Moved per-param disables next to each `any`.
`pnpm lint`: 3 errors -> 0 errors (41 warnings unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the app used a binary matchMedia split at 1023.98px, so iPad
portrait + half-screen-on-13"-Mac both fell into the mobile shell —
neither is really mobile. The tablet tier fills that gap.
- `use-is-mobile.ts` gains `useViewportTier()` returning
'mobile' | 'tablet' | 'desktop' (mobile < 768, tablet 768-1023,
desktop ≥ 1024). Backed by useSyncExternalStore so render reads
stay pure. `useIsMobile()` retained as a back-compat alias =
`tier !== 'desktop'` so existing call sites don't have to change
in lockstep.
- `app-shell.tsx` now renders three branches. Mobile + desktop
unchanged. Tablet renders the desktop shell, but the Sidebar lives
inside a left-side `<Sheet>` opened by a new leading logo button
in the Topbar. SheetContent width matches `--width-sidebar` so the
open state reads consistent. Children subtree position stays
invariant across tier flips so inline-edit drafts survive a resize.
- `topbar.tsx` accepts an optional `leadingSlot` rendered before the
back button + breadcrumbs in the LEFT column. AppShell mounts a
port-logo button in that slot on tablet (or a three-bar menu icon
when the port has no logo yet) that triggers the sheet.
- `page-header.tsx` was the dashboard "title card looks bad on
tablet" surface — the actions row was forced no-wrap at sm (640px)
which crushed the title on iPad-portrait. Stack point moved from
sm to lg, so tablet stacks vertically (title above, actions
below); desktop returns to side-by-side.
tsc clean, 1454/1454 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes plan item 66 part (c). Parts (a)+(b) shipped earlier (05e727f
defaults flip + linked-berths-list rename); this is the
picker-inside-generate-dialog that the rep sees at the moment the
"which berths does this EOI cover?" question is actually live in their
head, instead of relying on them having visited LinkedBerthsList
toggles upstream.
EoiGenerateDialog gains:
- A new useQuery against /api/v1/interests/[id]/berths returning every
linked berth + its current isInEoiBundle / isSpecificInterest flags.
- A local Map<berthId, {isInEoiBundle, isSpecificInterest}> seeded
once from the server snapshot and isolated from subsequent refetches
(so a background refetch doesn't wipe pending checks). Resets when
the dialog closes.
- A new "EOI scope" section in the body listing every linked berth
with two checkboxes ("In EOI" / "Public map"), primary-marked
visually, plus a one-line legend explaining the bundle-vs-public
distinction (matters more post-(a) since the two flags routinely
diverge).
- handleGenerate diffs the picker state against the server snapshot
before kicking off the envelope; only changed berths get PATCHed,
and we wait for all PATCHes to settle (so a 5xx surfaces before the
EOI fires). Cache invalidation extended to bounce the new
['interests', id, 'berths'] queryKey so the LinkedBerthsList tab
picks up the new state on navigation.
The "Manage linked berths" cross-link below is preserved — the picker
is the in-dialog fast path, not a replacement for the full management
surface.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
export-dashboard-pdf-button.tsx imported PDF_DASHBOARD_WIDGETS +
PdfDashboardWidgetId from dashboard-report-data.service.ts. JS modules
evaluate their imports eagerly, so the button transitively pulled in
that file's top-level `import { getKpis } from './dashboard.service'`,
which pulled in `@/lib/db`, which pulls in `postgres`, which crashed
the client bundle with:
Module not found: Can't resolve 'fs'
./node_modules/.../postgres/src/index.js [Client Component Browser]
Split the pure data + types into the new file
src/lib/services/dashboard-report-widgets.ts and re-export from the
original service for backwards compatibility. The button now imports
from the pure file; the server-only route (reports/generate) keeps
using the resolver as before.
tsc clean, dashboard loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends Phase 3 from the M43 commit to yacht detail:
- New /api/v1/yachts/[id]/field-history endpoint joins through
interests.yachtId (no schema migration needed) and filters to
'yacht.%' paths so client-scoped overrides on the same interest
don't bleed into the yacht surface.
- FieldHistoryScope.type accepts 'yacht'; provider URL routing
generalised to /api/v1/<type>s/<id>/field-history.
- yacht-tabs OverviewTab wrapped in the provider; Name + the three
ft-dimension rows get historyPath wired (m-dimension rows skipped —
they're a unit-converted view of the same source value, and the
supplemental writer only ever stores ft).
Addresses tab on Client detail intentionally left unwired — would
need AddressesEditor (a shared component) to surface icons per row,
which is more than the 5-min scope.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes plan item 43 (Form-template fields bind to Interest/Client data —
autofill, override-preservation history, dual-surface audit trail).
Phase 1 — Editor:
- New bindable-fields catalog (src/lib/templates/bindable-fields.ts):
client/yacht/interest paths, each tagged with the entity, column, and
default input type. Source of truth for what can bind + what
interest_field_history.field_path strings the writers should use.
- formFieldSchema gains optional bindTo, validated against the catalog
as an allow-list (no arbitrary paths sneak through).
- form-template-form admin sheet: per-field "Bind to" dropdown grouped
by entity, auto-derives label/key/type when a binding is picked,
shows "Autofills from + writes back to {label} . {path}" badge.
Phase 2 — Runtime + history writes:
- supplemental-forms.service.applySubmission already wrote
interest_field_history rows for client name/email/address from the
earlier 0081 migration session. Extended to also capture phone +
yacht (name, length, width, draft) diffs that were silently going
to the entity without an audit row, and to push insert-path
overrides for the no-existing-address case.
- Field paths aligned with the bindable-fields catalog so detail-page
lookups work via exact-match WHERE field_path = ?.
Phase 3 — Inline history surface:
- New /api/v1/clients/[id]/field-history (mirror of the existing
interests endpoint).
- shared/field-history: FieldHistoryProvider wraps a detail tab and
fires a single keyed GET; FieldHistoryIcon consumes the context and
renders a small clock affordance only when at least one override
exists, opening a popover with the reverse-chrono diff list.
- Client + Interest detail Overview tabs wrapped in the provider;
EditableRow gains an optional historyPath prop; ContactsEditor
renders the icon next to the canonical primary email/phone.
1454/1454 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Q58, Q59, Q61 from the 2026-05-21 plan. Q57 + Q60 (sweep-scope) parked.
Shipped:
Q58 SelectTrigger size variant. <SelectTrigger> now accepts
`size?: 'default' | 'sm'`. Default = `h-11` so the trigger
matches <Input>'s h-11 default and the 8px height mismatch
called out in the UAT vanishes platform-wide. Existing call
sites that need the legacy compact look (FilterBar, dense
table headers) opt back in via `size="sm"`. Nothing breaks —
the default render flips height without touching any other
styling.
Q59 Table density min-widths + nowrap. DataTable cells now
default to `whitespace-nowrap` so long values (URLs, names,
addresses) don't wrap into 4-5 lines and inflate row height.
Columns that need wrapping override via the column def's
`meta.wrap = true`. Min-width comes from
`column.getSize?.()` when set so a column doesn't shrink-
wrap below readability — opt-in per column rather than a
sweeping width change.
Q61 Error message audit foundation — Documenso 401/403 path
enriched. <PortDocumensoConfig> gains `apiKeySource` +
`apiUrlSource` ('port' | 'global' | 'env' | 'default' |
'none'). `getPortDocumensoConfig` populates them based on
which layer of the resolver chain produced the value.
documenso-client's <ResolvedCreds> exposes the source flags;
the 401/403 branch surfaces them in the
`DOCUMENSO_AUTH_FAILURE` internalMessage so operators see
"api key source: env, port: <id>" instead of the prior
generic `path → 401` body. Solves the Documenso diagnosis
loop that prompted the platform-wide error audit. Same
pattern can extend to other integration error paths in
follow-ups (S3, Redis, IMAP) — the resolver-source helper
lives on PortConfig now.
Q60 Tooltip audit primitive already shipped — <FieldLabel> in
`ui/field-label.tsx` is the canonical surface with an Info
icon + Tooltip slot. One adopter live (custom-field-form);
remaining admin-form sweep is the lift that's parked.
Deferred:
Q57 recharts → ECharts migration (6-10h). Pure visual port of
8 chart components; safer as a focused session with
per-chart visual review. Pre-reqs (ECharts deps + the
transpilePackages config + the d3-geo install) are in place
so the migration can be picked up cleanly.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P56 from the 2026-05-21 plan. Foundation (phase 1) shipped in e91055f.
Shipped:
- **UploadZone scope radio.** <FileUploadZone> accepts an optional
`interestId` prop. When set (currently passed from
InterestDocumentsTab) the upload-zone surfaces a small fieldset:
"File at: ⦿ This deal | ◯ Client-level (all deals)". Default is
deal-scope so reps don't accidentally surface deal-specific docs
across every historical interest of the client. The interest FK
is forwarded to /api/v1/files/upload only when "This deal" is
selected; client-level uploads omit it and land at the client
folder.
- **Outcome → folder rename lifecycle hook.** New
`renameInterestFolderForOutcome(interestId, portId, outcome)` in
document-folders.service. Strips any prior outcome suffix from
the folder name (so re-running on a lost→won flip doesn't
accumulate parens) and appends `(Won)` / `(Lost)` / `(Cancelled)`.
Fired fire-and-forget from interests.service.setInterestOutcome
via dynamic import to dodge the circular dep with this module's
primary-berth label resolver. No-op when the folder hasn't been
created yet (first upload happens later).
- **Backfill script.** scripts/backfill-nested-document-folders.ts
iterates every (port_id, interest_id) pair in `files` that has
a non-null interest_id and calls ensureEntityFolder so the
nested `Clients/<Name>/Deal …/` folder exists. Idempotent —
`ensureEntityFolder` short-circuits when the folder is already
there. Per-port advisory lock (FNV-1a of port_id) keeps two
operators from racing. Dry-run by default; `--apply` to commit.
Deferred:
- listFilesAggregatedByEntity rewrite to show "This deal" vs "From
client" subheadings — UI polish; the per-row filing already
happens correctly via the upload-zone scope radio.
- Documents Hub tree rendering for nested interest folders — the
folder rows already exist with `parent_id` set; the tree
component picks them up automatically.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
O48, O51-O54 from the 2026-05-21 plan. Phase 4a / 3 / 5 marketing-site
work explicitly deferred — they live in the marketing repo + are
blocked on instrumentation that isn't this codebase's to ship.
Shipped:
O48 Tracked-link composer button.
New POST /api/v1/tracked-links mints a redirect-link the rep can
drop into an outgoing email. Body { targetUrl, sendId? }; returns
{ id, slug, targetUrl, url }. Gated on `email.send` (same as the
server-side check on existing send routes). `sendId` lets the
click-tracker attribute back to a specific document_sends row.
<TrackedLinkComposerButton> renders a small inline button (or a
sized default variant) that opens a dialog: rep pastes the
destination URL → Create → gets the public /q/<slug> URL with
a Copy + an "Insert into message" action that calls back to the
parent compose surface. Wired into <SendDocumentDialog>'s
Message body label row so reps can mint + insert without
leaving the dialog.
O51 Quiet-range nudge. WebsiteAnalyticsShell surfaces a small amber
banner when the active range returned <5 visitors so the rep
doesn't think the integration is broken on a fresh port or
off-season range. Threshold keeps the banner off legitimate
traffic.
O52 Apple Mail privacy disclaimer. The sends-log "Not opened" badge
carries an inline tooltip explaining that Apple Mail's privacy
protection routes opens through Apple's proxy and can suppress
this signal even when the recipient read the email.
O53 Open-rate column on the document_sends list. SendRow type
extended with `trackOpens` / `openCount` / `firstOpenedAt`; the
sends-log card chrome renders an "Opened × N" badge with the
first-open timestamp in the title, or "Not opened" when tracking
is on but no opens yet, or no badge at all when tracking was
disabled for that send.
O54 Click-to-filter world map. VisitorWorldMap already supported
`onCountryClick`; wired it through to copy the
`/<portSlug>/clients?nationality=<ISO>` deep-link to the
clipboard with a toast on click. Inline filtering of the
analytics view itself stays parked alongside Phase 5 — the
useUmami* hooks don't yet accept a country filter.
Deferred (not in this repo or blocked):
O47 Phase 4a marketing-site instrumentation — marketing repo work.
O49 Phase 3 Events tab — blocked on 4a.
O50 Phase 5 Funnels + Journeys — blocked on 4a.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
N44, N45, N46 from the 2026-05-21 plan.
Shipped:
N44 Pipeline Value tile respects dashboard timeframe. Tile accepts
optional `range` prop and threads it through
/api/v1/dashboard/kpis?range=<slug> + /forecast?range=<slug>.
Service functions accept optional {from,to} bounds and scope
the pipeline-value SQL to interests created within the window.
New parseRangeSlug helper inverts rangeToSlug. Widget registry
forwards the active dashboard range to the tile.
N45 Clients by country widget. New GET
/api/v1/dashboard/clients-by-country groups non-archived
clients by nationality_iso. <ClientsByCountryWidget> renders a
compact ranked list with mini-bars; rows link to
/clients?nationality=<ISO>. Registered as default-visible rail.
N46 Drag-and-drop dashboard widgets. New
preferences.dashboardWidgetOrder?: string[] on user_profiles;
useDashboardWidgets sorts visibleWidgets by the order
(unlisted ids fall through to registry order) and exposes
setOrder(nextOrder) that PATCHes optimistically.
DashboardShell wires @dnd-kit/core + sortable: Rearrange toggle
turns on per-widget grip handles + sortable-context wraps each
group (charts / rails / feed) so drops stay in-group.
PointerSensor 8px activation distance, KeyboardSensor for a11y.
New <SortableWidget> wraps the render — zero footprint when
off.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M42, M43 from the 2026-05-21 plan.
Shipped:
M42 FilePreviewDialog now handles seven preview kinds via a single
previewKindFor() router (mime + filename fallback). Image and
PDF stay on the existing lightbox + pdf viewer; plain text
(.txt / .md / .csv / .tsv / .json / .xml / .log / .yaml / .ini
/ .html — text/* and application/json and friends) renders via
a new <TextPreview> that fetches via the presigned URL and
caps the body at 1 MB with a "showing first 1 MB" banner.
Audio / video render through native HTML5 <audio> / <video>
elements with preload="metadata". Office documents (.docx /
.xlsx / .pptx / .odt / .ods / .odp + the official mime variants)
embed via Microsoft's hosted Office viewer (view.officeapps
.live.com/op/embed.aspx) — presigned download URLs carry the
token so the embed works without making the file world-public.
Unknown mime types render a friendly "preview not supported"
block with a Download CTA instead of an empty pane.
M43 Field-level override history foundation. Migration 0081 adds
`interest_field_history` (id, port_id, interest_id?, client_id?,
field_path, old_value, new_value, source, submission_id?,
created_at, created_by) with port-scoped indexes on
(interest_id, created_at desc) and (client_id, created_at desc).
Drizzle schema + index exports added. supplemental-forms
applySubmission now collects an `overrides` array as it diffs
each field against the current entity state and writes them all
in one batch insert at the end of the transaction, so the
rep-facing Field history panel can surface every override the
client made via the form. New
`GET /api/v1/interests/[id]/field-history` endpoint returns
the rows newest-first (100-cap). Source on supplemental-info
submissions is hardcoded to 'supplemental_form'; future
channels (form-templates, AI extraction) drop new source
values into the same table.
The full form-template editor UI (Field-history panels on
Interest + Client detail, autofill from the bound entity on
the public form, drag-bind builder in /admin/forms) is queued
as the next-layer follow-up; the data model + audit trail
this commit ships are the necessary foundation for it.
Verified: tsc clean, vitest 1454/1454, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
L41 from the 2026-05-21 plan.
Shipped (4 sub-tasks):
- **Dialog width**: already fixed in an earlier session
(max-w-[1400px] w-[95vw] on the DialogContent).
- **Draft persistence to localStorage**: scoped per
interest+documentType (`pn-crm.upload-for-signing.draft.v1:<id>:<type>`),
versioned for future shape evolution. Persists step / title /
recipients / fields / invitationMessage with a 500ms debounce so
rapid edits (typing the custom note, dragging a field) don't
hammer storage. The PDF File object itself is NOT persisted
(large blobs + browser quota); on reopen the rep re-picks the
file but every other piece of state survives. Pristine "no
progress yet" state actively clears any stale draft. Header
surfaces a "Draft saved" indicator + Discard button when a
draft exists. Successful submission clears the draft so the
shadow doesn't outlive the doc.
- **PDF preview error handling + zoom**: `onLoadError` now sets
`pdfLoadError` and replaces the spinner with a useful failure
block (error message + re-pick guidance) so reps don't see an
infinite loading state on a broken file. Toolbar gains zoom
controls (50–200% in 25% steps); field coordinates stay in %
of page dimensions so placements scale automatically with the
canvas.
- **Field-placement keyboard shortcuts**: window-level keydown
handler responds to Delete / Backspace (remove selected field),
arrow keys (nudge 0.5% per press, Shift + arrow = 5% per press).
Ignored when focus is in a real input / textarea / contenteditable
so the shortcuts never steal typing.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
J38, J39, K40 (core) from the 2026-05-21 plan.
Shipped:
J38 EntityActivityFeed sentence rendering surfaces the new value
inline. Was "<actor> updated the X"; now "<actor> set X to
<value>" when the audit row carries `newValue`. Field-level
diff line underneath keeps showing the old → new strikethrough
for context. Truncates inline value at 60 chars to keep long
notes / descriptions from blowing out the row.
J39 Client → Companies tab CTA. Empty state gains a "Link to a
company" action; populated state grows a top-right "Link to
company" button. New <LinkCompanyDialog> wraps the existing
<CompanyPicker> + a membership-role select + an "is primary"
checkbox, then POSTs to /api/v1/companies/[id]/members.
Empty-state copy dropped "Add a membership from a company's
detail page" — the rep can act inline now.
K40 OnboardingChecklist resolver-chain. The auto-check no longer
reads raw `/admin/settings` rows (which miss env fallbacks).
Resolved endpoint widened to accept `?keys=k1,k2,...` so the
checklist can batch-resolve any heterogenous set of registry
keys through port → global → env → default in one round-trip.
Checklist captures the dominant source per step ("env fallback",
"global default", "built-in default") and surfaces it inline
under the green tick so super-admins see when a step is
relying on env rather than a per-port override. Compound-key
gates report the weakest sub-key's source so a partially-env
config still flags clearly.
Topbar banner / dashboard tile / weekly nudge / celebration
sub-items remain queued — the core resolver-chain gap was
the actual cause of the "step never ticks" UAT complaint.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
I34–I37 from the 2026-05-21 plan.
Shipped:
I34 Residential client header layout parity. Email / Call /
WhatsApp action buttons mirror the main ClientDetailHeader.
WhatsApp number resolves from phoneE164 (preferred) or strips
the free-text phone to digits. Header surfaces "Linked to
main client" chip when the auto-link matcher (I37) finds a
counterpart in the main CRM.
I35 Residential interests list rebuilt for parity with the main
InterestList. New ResidentialInterestCard +
getResidentialInterestColumns + residentialInterestFilter-
Definitions; the list page drives DataTable + FilterBar +
ColumnPicker + SavedViewsDropdown + bulkActions. List
endpoint validator widened to accept pipelineStage as a
string OR string[] and added a source filter. Service post-
fetches client names via a single IN-list lookup so the
table renders fullName in column 1 without N+1.
New /api/v1/residential/interests/bulk supports
change_stage + archive (100-id cap). Kanban view deferred.
I36 Residential inquiries auto-forward to partner email(s).
New registry entry residential_partner_recipients (comma-
separated) under section residential.partner.
createResidentialInterest fires
forwardResidentialInquiryToPartner after the row lands.
Helper uses the same branded shell other transactional
emails use. Failures log + never block create. The
/admin/residential-stages page picks up a registry-driven
card so admins manage recipients alongside stages.
I37 Auto-link residential ↔ main client. Migration 0080 adds
residential_clients.linked_client_id (nullable FK, SET NULL
on cascade) + partial index. New findAndLinkMatchingMainClient
service matches by email first (case-insensitive client_contacts
lookup) then by E.164 phone. First exact match wins. Fires
fire-and-forget from createResidentialClient. Header surfaces
the link via a "Linked to main client" chip. Backfill script
+ reverse-direction link from main ClientDetailHeader stay
as follow-ups.
Verified: tsc clean, vitest 1454/1454, migration applied.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>