- new report-render.service.ts: renderReportRun(reportRunId) +
emailReportRun(reportRunId). Render path fetches the run row,
advances status to 'rendering', resolves the kind→fetcher+template
pair from REPORT_RENDER_MAP (dashboard→pipeline, clients→activity,
berths→occupancy, interests→revenue), generates the PDF, uploads to
storage, mirrors onto `files` so the standard download/attachment
surfaces serve it, and stamps storageKey + sizeBytes + status='complete'.
Failure path stamps 'failed' + errorMessage + compensating
storage.delete to keep blobs from orphaning. Email path resolves the
schedule's recipients + the rendered file via the standard
resolveAttachments port-isolation check, sends one message per
recipient via the existing sendEmail helper, and stamps emailedAt.
- reports worker (src/lib/queue/workers/reports.ts) gains 3 jobs:
- 'report-schedules-poll': scans report_schedules where enabled=true
AND nextRunAt <= now, mints a report_runs row per due schedule via
createReportRun (triggeredBy='schedule'), advances next_run_at via
nextRunFor() BEFORE enqueue so a downstream failure doesn't pin the
schedule on the same tick, then enqueues report-run-render.
- 'report-run-render': calls renderReportRun + auto-cascades into
report-run-email when the run was schedule-triggered.
- 'report-run-email': calls emailReportRun.
These coexist with the legacy 'report-scheduler' + 'generate-report'
jobs operating on scheduled_reports/generated_reports.
- scheduler.ts registers 'report-schedules-poll' on a 1-minute cron so
the system catches due schedules even when no API event nudges them.
- POST /api/v1/reports/runs now enqueues 'report-run-render' after
createReportRun. Enqueue failures are logged + swallowed so the API
still returns 201; the schedule poll picks pending rows up as a
safety net.
Verified: tsc clean, 1493/1493 vitest.
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>
- derivePublicStatus gains optional hasActivePermanentTenancy flag;
precedence updated to "sold > under_offer > available" where
Sold can come from EITHER berths.status='sold' (admin set) OR an
active permanent-class tenancy (only when module enabled).
- Permanent-class tenure types defined in one place
(isPermanentTenureType): permanent | fee_simple | strata_lot.
Seasonal / fixed_term tenancies do NOT flip — they fall through to
the existing under_offer / available precedence.
- /api/public/berths (list) + /api/public/berths/[mooringNumber]
(single) both gate the lookup on isTenanciesModuleEnabled(portId).
Disabled module = lookup skipped entirely, preserving pre-module
behaviour for ports that haven't opted in.
- 8 new unit tests covering: flip from available, flip from under_offer,
explicit sold idempotency, false-flag fallthrough, default-omit pre-
module behaviour, permanent-class membership for each tenure type,
and null/undefined/unknown rejection.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- berth-tenancies.service.ts: autoCreatePendingTenancies(portId, interestId, opts)
loops over interest_berths WHERE is_in_eoi_bundle=true and mints ONE
pending tenancy per in-bundle berth. Wrapped in pg_advisory_xact_lock
per port + idempotent skip when a (pending|active) tenancy already
exists for the berth (webhook retry-safe). Each insert audit-logged
+ emits berth_tenancy:created socket event.
- createPending: same advisory-lock + tx pattern, additionally calls
enableTenanciesModule(portId) so the FIRST manual tenancy in a port
lazily flips tenancies_module_enabled=true (idempotent UPSERT, no-op
on subsequent inserts).
- handleDocumentCompleted: branch on reservation_agreement completion
gates on isTenanciesModuleEnabled, then calls autoCreatePendingTenancies
with the just-committed signedFileId. Per design §"When disabled":
stage advance + reservationDocStatus flip still fire when the module
is off; only the tenancy mint is skipped.
- 5-case integration test covering bundle expansion, idempotent retry,
empty-bundle no-op, missing-interest no-op, and the first-insert
module-enable side effect.
Verified: tsc clean, 1485/1485 vitest (5 new cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds the API + service layer the P1 schema migration 0084 set up:
- src/lib/validators/reports.ts: new schemas for list/create on runs +
full CRUD on schedules. Locked enums for kind / output / cadence /
status so the route layer can reject invalid combinations early.
- src/lib/services/report-runs.service.ts: list with kind/status/template
filters, create with cross-port template guard + config.kind
discriminator check, updateReportRunStatus for the future P3 worker to
flip status through pending/rendering/complete/failed.
- src/lib/services/report-schedules.service.ts: full CRUD plus
nextRunFor() deterministic cadence math. nextRunAt is recomputed on
cadence change or on re-enable (off->on) but left untouched on no-op
edits so a mid-cycle recipient swap doesn't slip the fire-time.
- /api/v1/reports/runs (GET + POST) + /api/v1/reports/runs/[id] (GET)
- /api/v1/reports/schedules (GET + POST) +
/api/v1/reports/schedules/[id] (GET + PATCH + DELETE)
- tests/integration/report-runs-schedules.test.ts: 9 cases covering the
cross-port FK guard, the config.kind cross-check, listing filters,
cadence math for all three v1 cadences, the no-op-doesn't-slip rule,
and the ON DELETE SET NULL contract on schedule deletion.
Permission gating: list/get on reports.view_dashboard (read), all mutations
on reports.export (write). Matches the existing /reports/templates routes.
P3 (the BullMQ render+email queue) is the next slice; it'll consume the
pending rows produced here.
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>
Part of the locked Reports page design (docs/reports-page-design.md).
This PR is the data foundation — API routes, UI builder, scheduler,
and rendering pipeline land in subsequent PRs.
What ships:
- Migration 0084: extends report_templates with description + visibility
+ archived_at, softens the unique-name index to skip archived rows,
adds report_runs (append-only audit log) and report_schedules
(BullMQ recurring scheduler) tables with full indexes.
- Schema TypeScript additions in src/lib/db/schema/reports.ts:
reportSchedules + reportRuns table definitions with strongly-typed
recipients / config / status enums.
Behaviour today: no UI changes; existing /api/v1/reports/generate
keeps working unchanged. Saved templates can be archived via
report_templates.archived_at once the templates CRUD API lands in P2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of the locked Tenancies module design (docs/tenancies-design.md).
This PR is the gating infrastructure — the actual table rename
(berth_reservations -> tenancies) + self-FKs + perm-rename + sidebar
entry land in subsequent PRs.
What ships:
- `system_settings.tenancies_module_enabled` registry entry (port-scoped
boolean, default false). Surfaces in the registry-driven admin form
+ the resolveForAdminAPI chain.
- `src/lib/services/tenancies-module.service.ts` with:
* isTenanciesModuleEnabled(portId) — checks the admin setting AND
the lazy "any berth_reservations row exists" sentinel
* enableTenanciesModule / disableTenanciesModule — idempotent
upserts on the system_settings row
* assertTenanciesModuleEnabled — throw-on-disabled helper for
route handlers (NotFoundError -> 404)
- Three admin endpoints under /api/v1/admin/tenancies-module/
(status / enable / disable), all gated on admin.manage_settings.
Behaviour today: with the module off (default), nothing changes.
Sidebar, entity tabs, top-level page, webhook auto-create branch,
and dashboard widgets all continue to read the same flag and stay
hidden until either an admin toggles it ON or the first auto-create
flips it via the lazy "row exists" sentinel.
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
N46 (a147cbc) shipped the drag-drop UI + optimistic mutation, but the
PATCH body was being silently stripped by the user-preferences Zod
validator — `dashboardWidgetOrder` wasn't in the schema, so Zod's
default strip-unknown-keys behaviour dropped it before the DB write.
Symptom: drop the widget in a new position → UI reflects the order
optimistically → onSettled invalidates + refetches → GET returns the
unchanged-on-disk order → dashboard snaps back to the original
layout.
Added the field to updateUserPreferencesSchema with the same loose
shape (array-of-string) the schema declared 100+ lines earlier.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same client-server boundary bug class as adf4e2b. berth-range.ts
imported `logger` (-> request-context.ts -> node:async_hooks) for
two debug/warn calls. external-eoi-upload-dialog.tsx is a client
component and imports formatBerthRange — Turbopack chunked
async_hooks into the client bundle and crashed with:
Code generation for chunk item errored
Caused by: the chunking context (unknown) does not support
external modules (request: node:async_hooks)
Surface was the entire interest detail page on every viewport: dev
shell rendered the Turbopack overlay instead of the actual UI, so
the planned visual audit couldn't take any meaningful screenshots.
Replaced logger.debug + logger.warn with a single console.warn that
summarises non-canonical moorings. console.warn is safe in both
server and client contexts and the formatter's failure mode is
non-critical (verbatim passthrough — no data loss).
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>
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>
Branding URLs were baked with env.APP_URL=http://localhost:3000 at
upload time and stored verbatim in system_settings, so any logo/
background loaded from a non-localhost origin (an iPhone hitting the
Mac's LAN IP) failed to resolve. Same pattern bit Socket.IO (CORS +
client connection target) and the portal logout redirect.
- Branding: getPortBrandingConfig normalizes localhost/private-LAN
hosts to path-only; both upload routes store path-only going
forward; email shell re-absolutizes via absolutizeBrandingUrl() so
inboxes (no app origin) still get fetchable URLs. DB backfilled to
strip http://localhost:3000 from existing rows.
- Socket.IO: client connects to window.location.origin (io() with no
URL); server CORS allows localhost + private-LAN ranges in dev,
stays locked to APP_URL in prod.
- Portal logout: redirect target built from the request URL instead
of env.APP_URL.
- next.config: allowedDevOrigins widened from a hardcoded IP to
192.168/10/172.16-31 wildcards so HMR works across networks
without an edit per-network. (Without HMR the login form's React
click handler never hydrates and the form falls back to GET,
leaking the password into the URL.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
R62, T64, T65 from the 2026-05-21 plan. U66 deferred with reasoning.
Shipped:
R62 Documenso-first templates (list endpoint + admin route).
New `listTemplates(portId)` in documenso-client paginates
through every visible template on the configured instance
(5-page cap at 100/page = 500 templates which comfortably
covers every observed Documenso deploy). Handles v1 + v2
endpoint shapes; normalises to `{ id, name }` summaries.
New `GET /api/v1/admin/documenso/templates` route exposes
the list to the admin UI (gated on `admin.manage_settings`).
Powers the upcoming admin template picker — the field-mapping
editor + sync-now button + per-template badges stay as the
picker-UI follow-up. Data path is in place; UI surface
lands in a dedicated PR alongside the field-mapping editor.
T64 Duplicate E17 + missing partial unique index. Migration 0082
deduplicates any existing (port_id, mooring_number) collisions
by archiving all but the canonical row (prefers price-bearing
rows, then earliest-created; archived rows carry an explicit
`archive_reason` noting the migration). Adds partial unique
index `uniq_berths_port_mooring_active` on (port_id,
mooring_number) WHERE archived_at IS NULL so archived
moorings can be reissued but live duplicates can't be
created in the first place. Migration applied to dev DB.
T65 Stage-advance gate. `changeInterestStage` now blocks any
non-override transition into eoi / reservation / deposit_paid
/ contract when the primary berth has no price (NULL or 0)
— these stages all render the price in templates / merge
fields and a $0 generation is a real production gotcha.
Override path (sales-manager fix) stays open and records
the reason in audit log per the existing override-reason
gate.
Deferred:
U66 EOI bundle UX rework (10-14h) — multi-berth picker inside
the EOI generate dialog. Schema (`interest_berths.isInEoiBundle`)
and the rendered bundle-range preview row both exist; the
remaining work is the picker UI + re-deriving merge tokens
per selection state. Best done as a focused session with
Documenso-side verification.
Verified: tsc clean, vitest 1454/1454, migration applied.
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>
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>
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>
F27–F29, G30, G31, H32, H33 from the 2026-05-21 plan.
Shipped now:
F28 Past-milestones expandable history. The Past strip on the
Interest overview becomes an <Accordion> — each row collapses
to the same one-line summary as before, expands to render the
full <MilestoneSection> (steps list, sub-status, inline doc
actions). Reuses the existing MilestoneSection so no new
per-milestone rendering needs to be maintained.
F29 Watchers configurable at document creation time. The unified
create-document wizard gets a Watchers section with a
multi-select checkbox list backed by /api/v1/admin/users/picker.
Selected user ids are sent in the `watchers` array on the POST
(replacing the prior hardcoded `[]`). UI matches the
post-creation WatchersCard so reps see the same identity rows
regardless of entry point.
G30 /admin/invitations merged into /admin/users. The Users page
now wraps the existing UserList + InvitationsManager in a
Tabs control (Active users / Invitations). The standalone
/admin/invitations route returns a redirect to the merged page
for bookmark back-compat. Removed nav catalog entry +
admin-sections-browser tile; extended the Users catalog
keywords with "invitations / pending invites / onboarding"
so command-K search still lands on the right surface.
G31 /admin/ai picks up the berth-PDF-parser section + a "planned
AI surfaces" placeholder. Berth PDF parser remains
env-configured today; the page now documents it so admins
don't hunt for the controls. Closes the "where do I configure
AI?" loop.
H32 Email settings explainer panel above the SMTP cards. Spells
out why noreply + sales have separate credentials and which
workflows ship from each mailbox. Existing field titles
gained the "(noreply)" suffix so the model maps cleanly.
H33 Supplemental-info-request email rebuilt to use the shared
branded shell (logo + blurred overhead background + max-
width 600 table layout) instead of the prior plain-HTML
page. Per-port branding (logo / primary color / background /
header / footer) flows from getPortBrandingConfig. CTA
button picks up the port's primary color.
Already shipped (verified pre-shipped):
F27 DocumentsHub root view already hides the breadcrumb via
`selectedFolderId !== undefined` conditional.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
D24 + D25 + E26 from the 2026-05-21 plan. All three shipped.
Shipped now:
D24 BulkAddBerthsWizard ft/m toggle. Step 2 header gets a small
monospaced ft/m button that flips the dimension entry unit
wizard-wide. Cell values stay as-typed; on submit a single
`inputToFt(v)` helper converts m→ft (1 m = 3.28084 ft) before
posting the canonical feet payload. Column headers update
Length/Width/Draft labels to reflect the active unit.
D25 BulkAddBerthsWizard dock-letter expansion. Replaced the
Select-of-A–E with a chip group + free-text "Other…" input.
Common letters (A-E) are quick-pick chips; reps can type any
uppercase letter sequence (AA, BB, F, …) for ports whose dock
layout extends past the five-letter shortlist. New
`handleGenerate` validation rejects empty / non-uppercase
inputs with a toast. Custom-input path uppercases + strips
non-letters as the rep types so the canonical
`^[A-Z]+\d+$` mooring regex always matches.
E26 Supplemental-info Regenerate / Resend / history.
Service: new `listTokensForInterest(portId, interestId)`
returns the latest 20 issuances with expired/consumed flags;
new `getTokenForResend(portId, interestId, tokenId)` snapshots
a specific token back into the issue-shape so the route can
re-email without minting a fresh token.
Route: GET lists the issuances (gated on `interests.view`);
POST accepts an optional `tokenId` for the Resend branch
(forces `sendEmail=true` since the rep clicked with intent)
and returns `resent: true/false` on the success payload.
UI: button card now shows three actions — Generate /
Regenerate link, Generate + email (or "New link + email"
when a usable token exists), and Resend current (only when
there's an active unconsumed unexpired token). Issuance
history list shows Active / Submitted / Expired per row.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C20–C23 from the 2026-05-21 plan.
Shipped now:
C21 Dimensions ft/m column toggle persisted to user prefs.
`TablePreferences.dimensionUnit` ('ft' | 'm') added to the user-
profiles JSONB. `useTablePreferences` returns `dimensionUnit` +
`setDimensionUnit` alongside hidden/density. New
`getBerthColumns(unit)` factory rewrites the dimensions /
nominalBoatSize / waterDepth cells when ft is requested
(waterDepth converts on-the-fly from the canonical meters
column at 3.2808 ft/m). Berth-list toolbar gains a small
ft/m toggle button next to the density toggle.
C22 ft/m switching on Berth Requirements rows.
`interest-tabs.tsx` Berth-requirements section now honours
`interest.desiredLengthUnit`. Labels flip to "(m)" when set;
value reads from `desired*M` columns; on save, both the chosen-
unit and the canonical counterpart columns are PATCHed (3.28084
ratio) so downstream surfaces (recommender, EOI merge fields)
stay in lockstep. `InterestPatchField` widened with `desired*M`
variants.
C23 Berth list bulk-edit affordance.
New `POST /api/v1/berths/bulk` (mirror of /interests/bulk):
discriminated union of `change_status` / `change_tenure_type` /
`add_tag` / `remove_tag` / `archive`, 500-id cap, per-row
failure reporting, single `berths.edit` permission gate
(no separate `archive` perm exists on berths today). Status
mutations route through `updateBerthStatus` so under-offer /
sold transitions still trigger the primary interest_berths
auto-link + the rules-engine evaluation.
BerthList toolbar wires `bulkActions` on the DataTable —
Change status (Select dialog), Change tenure (permanent /
fixed-term), Add tag, Remove tag, Archive (destructive +
confirmation). Each dialog uses the same `bulkMutation` so
toast + cache-invalidation behaviour is consistent across
actions.
Already shipped (verified):
C20 Berth list rates / pricing valid columns hidden by default —
already in `BERTH_DEFAULT_HIDDEN`.
Verified: tsc clean, vitest 1454/1454.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
B13–B19 from the 2026-05-21 plan. Five new ships; two items already in
place from earlier work but flagged for verification.
Shipped now:
B14 Interest Overview Email + Phone rows: new <ClientChannelEditor>
combobox. Primary value renders inline (free-text for email,
<InlinePhoneField> for phone with country picker). Chevron opens
a popover listing every contact in the channel — promote to
primary, delete non-primaries, or inline-add a new contact.
Backed by the existing /clients/[id]/contacts CRUD + promote-
to-primary endpoints. Wired into the Email + Phone rows on
interest-tabs.tsx Overview.
B15 Inline phone editor: the phone branch of <ClientChannelEditor>
uses <InlinePhoneField> (country code + national-format split).
interests.service.ts now returns `clientPrimaryPhoneCountry` so
the editor can preserve the ISO-3166-1 alpha-2 round-trip.
B16 Client Overview interest summary: PanelVariant of
<ClientPipelineSummary> renders a one-line "Wants L × W × D ·
Source" under each interest's header when constraints / source
are captured. Hidden when both are empty.
<ClientInterestRow> type extended with the new fields; the
/api/v1/interests query already returns them.
B17 Notes Latest-note teaser stage pill: stage-badge chip next to
the "5 minutes ago · Matt" line. Shows the deal's CURRENT
pipelineStage — a stage-at-note-time lookup would require a
per-render audit_logs read, over-engineered for a context hint.
B18 InterestBerthStatusBanner names + links the competing deal:
reuses /berths/[id]/active-interests endpoint shipped in 292a8b5;
one query per conflicting berth via useQueries. Picks the
isPrimary competing interest (falls back to first non-self
row); renders an inline <Link> to the competing detail page.
Already shipped (verified pre-shipped):
B13 Inbox Reminders embedded filter row — `embedded` prop already
wired in reminder-list.tsx.
B19 Qualification auto-confirm intent at stage ≥ EOI — already
handled by computeAutoSatisfied's `stageIdx > qualifiedIdx`
gate (covers eoi / reservation / deposit_paid / contract).
Verified: tsc clean, vitest 1454/1454.
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>
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>
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>
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>
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>
- `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>
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>
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>
Two coordinated UX changes that finally make the rep's manual-stage-
jump workflow legible:
- Milestone phase classifier introduces a "stage-owning milestone"
rule. When the rep manually advances the deal to Reservation+ but
earlier sub-statuses are still un-signed, the current-stage
milestone now stays marked `'current'` (no longer collapses into
the past-strip / upcoming-accordion based on completion alone).
Earlier-than-stage milestones bucket to `'past'` so the rep can
backfill them; later slots stay `'future'`. The previous
firstIncompleteKey-driven rule still applies in stages without an
owning milestone (enquiry / qualified / nurturing).
- Skip-ahead backfill control `<MilestoneBackfillButton>` lands in
the past-milestones strip whenever a milestone's date column is
null. Opens a DatePicker popover (today default, accepts any past
date) and PATCHes the relevant date_* column directly via
useInterestPatch — no stage transition fires.
- `InterestPatchField` extended with the five milestone date keys;
validator gains `dateDepositReceived` (was the only missing one).
Together this means: a deal manually-advanced from EOI Sent → Deposit
no longer hides Reservation under upcoming-milestones AND the rep can
record the EOI/reservation signing dates without re-triggering the
stage transition.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes to the per-interest qualification checklist
that collectively trim it from a noisy gate into an out-of-the-way
audit log once the deal moves forward.
- Auto-confirm `intent_confirmed` once `pipelineStage > qualified`.
Signing an EOI (or later) is the strongest signal of intent; the
checklist no longer requires a redundant explicit tick. Evidence
string reads "Stage advanced past Qualified".
- `dimensions` becomes derived-only — explicit ticks no longer
override removed evidence. When the rep deletes a yacht link or
clears desired dims, the row un-ticks immediately. Judgement-based
criteria keep the OR semantic so a manual confirmation survives an
evidence change.
- Checklist auto-collapses when fully confirmed: header shows ✓ All
confirmed (label · label) with a chevron; rep clicks to expand and
inspect or untick. Forced-expanded whenever an item is still
outstanding. ARIA-controlled.
- `qualification.service` gains a `pipelineStage` column-select and
threads it through `AutoCtx`; `DERIVED_ONLY_KEYS` Set sentinel
drives the new merge semantic.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- InterestEoiTab history link renamed "Open" → "Open in Documents"
so the cross-section nav target is unambiguous.
- DocumentDetail Interest link sub-text now shows the derived
`berthLabel` (formatBerthRange of the in-EOI-bundle subset, falling
back to primary, then all linked berths). The link no longer
duplicates the Client name; falls back to clientName or "No berths
linked" when no berths exist.
- New /<port>/residential/page.tsx redirects to /residential/clients
so the breadcrumb's Residential link works.
- Residential interests list — whole row is now a Link target (was
hidden behind a trailing "View" link); hover + border accent on the
full row.
- Expenses PageHeader description "Track and manage port expenses" →
"Track and manage business expenses" (drop the redundant "port",
same audit pattern flagged in the queue).
- DropdownMenu base content capped at `max-h-96` (was the Radix
available-height variable, which stretched menus edge-to-edge); the
existing internal scroll handles overflow.
- Yacht Overview Notes block: replaced the legacy single-field
textarea with the threaded `<NotesList entityType="yachts">` for
parity with clients/interests/companies. Legacy `yacht.notes`
column stays in schema for EOI/contract merge-field path.
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the freetext CSV signer-names field with a structured recipient
editor (name / email / role per row). Service now persists each
non-CC signatory as a `document_signers` row pre-stamped
`status='signed'` so the document-detail "X / Y signed" badge counts
correctly for manually-uploaded EOIs.
- ExternalEoiInput gains a structured `signatories` field; legacy
`signerNames` retained for back-compat. Role enum:
`client | developer | rep | witness | cc`.
- uploadExternallySignedEoi inserts `document_signers` rows for every
non-CC entry inside the existing transaction.
- documentEvents.completed event records both shapes for full audit
fidelity.
- POST /api/v1/interests/[id]/external-eoi parses the `signatories`
JSON multipart field defensively; malformed payloads fall back to
signerNames.
- Dialog UI: per-row Name / Email / Role inputs with add / remove.
Seeds from interest's clientName + clientPrimaryEmail via a
signatoriesOverride/null pattern (React-Compiler safe — no
setState-in-effect).
tsc clean. 1419/1419 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>