Commit Graph

446 Commits

Author SHA1 Message Date
7aa639f195 fix(audit): criticals C1 (currency-scoped deposit gate), C2 (outcome-aware berth rule), C4 (/q/ allowlist)
C1: getDepositTotalForInterest now filters to the interest's
depositExpectedCurrency for the auto-advance gate, so a wrong-currency
payment can no longer satisfy the deposit expectation (and mark the berth
Sold). C2: setInterestOutcome fires interest_completed only for 'won';
lost/cancelled fire a new 'deal_lost' rule that frees the berth instead of
flipping it to 'sold'. C4: add '/q/' to proxy PUBLIC_PATHS so tracked
links in outbound mail reach external recipients.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:54:36 +02:00
58203ca8ea feat(reports): financial hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:13:42 +02:00
8b7099c4c1 feat(reports): sales hasData existence flag (service + route)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:12:54 +02:00
10b3b68851 feat(reports): thread Area filter + add area-options/hasData helpers (operational service)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:10:33 +02:00
3d9084c94b feat(reports): parseOperationalFilters pure parser (Area scope)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:08:16 +02:00
b690fb8d56 feat(reports): Financial report (Initiative 1 Phase 4)
Builds the Financial report on the canonical payments + expenses tables
(the CRM records money received; it does not invoice — invoices module
is off, dev DB has zero invoice rows). The invoice-centric spec is
reframed onto the payments model: "outstanding AR" → expected-deposit
shortfall on active deals; "AR aging" → outstanding deposits bucketed by
deal age.

Service (financial.service.ts):
- 7 KPIs: revenue collected (net of refunds), deposits, balance,
  pipeline expected, outstanding deposits, expenses, net contribution
- 6 chart datasets: revenue by month (deposit/balance), collection
  funnel (EOI→deposit→contract→won), expected-deposit aging, cash flow
  (inflow vs outflow), expense breakdown by category
- 4 tables: outstanding deposits, recent payments, refund log, expense
  ledger
- every money figure normalised to port currency via a shared
  resolvePortCurrency/normalizeAmount helper (new reports/currency.ts)

UI (financial-report-client.tsx): KPI strip + recharts (stacked bar /
horizontal bar / line / donut) + month/quarter/year toggle + branded
empty states; DateRangePicker + Templates + Export wired. Un-hidden the
Financial card on the reports landing.

Plumbing: added '1y' (trailing 12mo) preset to the shared range system
(financial trends want a year); added 'financial'/'marketing' to the
report-template kind enum for template parity.

TDD: 6 financial-math unit tests (aging buckets, month keys/range, net
contribution). tsc clean; full unit suite green except pre-existing
Redis/storage-dependent integration tests. Browser-verified against live
data: API 200, KPIs correct ($5,849 expenses / -$5,849 net, $0 revenue
correct given 0 payment rows), expense ledger + breakdown populate,
payment-derived sections show graceful empty states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:43:36 +02:00
b97f6e945c feat(reports): rep + source multi-select filters on Sales report
Closes the two cross-cutting filter gaps in launch-readiness (rep
multi-select + source multi-select). The Sales detail tables can now be
narrowed by assigned rep and lead source alongside the existing stage /
lead-category / outcome filters.

- service: thread `assignedTo` + `sources` through the 5 filtered Sales
  queries (rep-performance, stalled, closing-this-month, recent-wins,
  lost-reason); add `getRepFilterOptions` for the rep dropdown's stable
  option list (distinct assigned reps port-wide, window-independent).
- route: extract param parsing into a pure, unit-tested
  `parseSalesFilters` helper (source allowlisted against SOURCES;
  assignedTo passed through as free user-id list); return `repOptions`
  in the payload.
- ui: static Source filter (SOURCES) + dynamic "Assigned to" filter
  (from payload repOptions, hidden until loaded); decouple the query
  builder from dynamic options via a stable FILTER_KEYS list.

TDD: 8 new parseSalesFilters unit tests (allowlist drop, free-list
passthrough, combine). tsc clean; 12/12 reports unit tests; browser-
verified both filters fire `source=`/`assignedTo=` → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:24:27 +02:00
c7325010e6 feat(import): commit runner + undo + wired BullMQ worker
Third importer increment — the write path, fully testable without UI.

- commit.ts: commitBatch streams classified rows, applies insert/update per
  the conflict policy via the adapter (each row in its own try/catch so valid
  rows still land), records every action in import_batch_rows, and keeps live
  counts on the batch header. undoBatch hard-deletes a batch's inserted rows
  (port-scoped); a delete blocked by a dependent FK is reported, not forced,
  and the batch flips to `undone` only when every inserted row was removed.
- import worker: replaced the no-op placeholder with the real processor —
  loads the batch, re-reads the uploaded file from storage, parses, and runs
  commitBatch under the batch's mapping + policy. Marks the batch failed on
  error. Concurrency 1 so imports don't race each other's dedup lookups.

Tests: commit (skip/insert/error counts + per-row ledger + real inserted
entity), undo (removes exactly the inserted row, flips status), and
update-matches overwrite. 2 passing.

Engine is now functional end-to-end at the service layer: parse → map →
dry-run → commit → undo. Remaining: 4 FK adapters, API routes + permission,
wizard UI + history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:36:42 +02:00
3cf12b3015 feat(import): engine core + companies/clients/berths adapters
Second importer increment: the generic engine + the three no-FK adapters,
fully unit + integration tested.

- types: ImportAdapter contract (targetFields, matchKey, findExisting,
  resolveForeignKeys, insert/update) + engine types.
- mapping: fuzzy header → target-field auto-suggest (exact / alias / edit
  distance, one header per field) + applyMapping (drops empty cells).
- classify: per-field zod + cross-field extraValidate, FK resolution hook,
  natural-key dedup, and the conflict-policy matrix
  (skip-matches / update-matches / error-on-match) → row outcomes + summary.
- engine: CSV (papaparse) + XLSX (ExcelJS) parse into a uniform
  {headers, rows} of trimmed strings.
- adapters (delegating to existing create/update services for audit +
  validation): companies (name dedup, update), clients (flat email/phone →
  contacts[], email-or-phone dedup, insert-only), berths (canonical mooring
  dedup, numeric coercion, update).
- registry: implemented adapters in dependency order.

Tests: 11 unit (mapping/validation/matchKey/parse) + 3 integration
(dedup + all three conflict policies on a seeded DB). 14 passing.

Next increments: FK adapters (yachts/interests/tenancies/expenses),
commit runner + worker, API routes + permission, wizard UI + undo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:32:19 +02:00
372b585bf9 feat(import): data model for the bulk CSV/XLSX importer
First increment of the importer (docs/superpowers/specs/2026-06-01-bulk-import-design.md):
the three port-scoped tables, no changes to entity tables.

- import_batches — one row per run: entity_type, filename, storage_key,
  status, conflict_policy, mapping_json, live counts, created_by, timestamps.
- import_batch_rows — per-row action ledger (inserted/updated/skipped/errored)
  with entity_id + error; partial index on inserted rows powers Undo.
- import_mappings — saved column mappings, unique per (port, entity, name).

Migration 0090 applied via psql; schema re-exported from the index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:23:50 +02:00
a343eaa257 feat(migration): old-LOI EOI recovery, folded berth-links, contactless flag
Three polish items so the legacy seed is one-shot and complete:

- backfill-documents: recover the ~10 pre-Documenso "LOI process" EOIs
  whose signed PDF lives only as a NocoDB attachment in the `database`
  MinIO bucket (the pipeline keys EOI-doc creation off documensoID, so it
  never created rows for them). Reads EOI_Document attachment metadata
  from the local nocodb_legacy dump, pulls the PDF (read-only) from the
  `database` bucket, and CREATES the document + file + folder, linking the
  signed PDF. Idempotent via a `nocodb_eoi_document` ledger entry.
- connect-berth-links: refactored into an exported connectBerthLinks()
  and folded into migrate-from-nocodb --apply (best-effort; skips with a
  warning if the local dump isn't restored) so the multi-berth junction is
  reconnected as part of the one-shot seed, not a separate manual step.
- migration-apply: contactless legacy clients (no email/phone across the
  whole dedup cluster) get a per-port "Needs contact info" tag so staff
  can filter + chase them, instead of being dropped.

The current dev DB's 29 contactless clients were tagged via a one-off
mirroring the pipeline logic. EOI recovery code is ready but the actual
run needs LEGACY_MINIO_* read creds supplied at the command line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:18:28 +02:00
8be7a6e29d feat(berths): ship Waiting List + Maintenance Log tabs
Both berth-detail surfaces were stubbed/hidden behind a comment in
berth-tabs.tsx. Their backing schema already existed; this wires the UI
and fills the service gaps.

Maintenance Log (was ~60% built: schema/migration/add+get service/route):
- new edit + delete: updateMaintenanceLog / deleteMaintenanceLog service
  (port-scoped tenant guard), PATCH/DELETE at maintenance/[logId], plus
  updateMaintenanceLogSchema. add schema now accepts null for cost /
  responsibleParty so the shared add+edit dialog sends one body shape.
- BerthMaintenanceTab: list (newest first) + add/edit dialog + delete
  confirm, realtime invalidation. New berth:maintenanceUpdated/Removed
  socket events.

Waiting List (un-hide the orphaned manager + next-in-line notify):
- getWaitingList now left-joins the client so the queue renders names,
  not raw ids.
- WaitingListManager rewritten: ClientPicker instead of free-text id,
  client names, manage_waiting_list gating on add/reorder/remove, and a
  "Next in line" marker on position 1.
- notifyWaitlistNextInLine: when a berth transitions to available,
  surface the #1 client to staff who hold berths.manage_waiting_list
  (mirrors the interest-based notifyNextInLine; dedupeKey-suppressed).
  Hooked into updateBerthStatus on any -> available transition.

Tests: maintenance add/get/update/delete + cross-port guard; waitlist
notify recipient-resolution / payload / empty + no-permission no-ops.
Verified end-to-end in the browser (create/render/delete for both).

Also adds scripts/dev-reset-admin-pw.ts (reset a synthetic user's
password via the better-auth hasher after a dev reseed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:55:04 +02:00
d98aa5cc8a fix(signing): route paper-signed reservation/contract uploads to the right doc type
The Reservation and Contract tabs reused ExternalEoiUploadDialog, but the
service hard-coded the EOI document type, status columns, stage target, and
berth rule. A signed contract uploaded from the Contract tab filed as an
`eoi`, flipped `eoi_status`, and advanced the stage to `eoi` - wrong doc
kind, wrong sub-state, wrong stage.

- external-eoi.service: UPLOAD_CONFIG keyed off docType (eoi | reservation
  | contract) parameterises documentType, file category, storage prefix,
  doc-status column, signed-date column, target stage, advance-from set,
  and berth rule. eoi_status is written only for docType=eoi.
- route: parse docType from the form (default eoi).
- dialog: docType prop; generalised copy; EOI-only UI (active-EOI replace
  banner, public-map flip, cancelActiveDocumentId) gated to docType=eoi.
- reservation/contract tabs: pass docType; drop the coming-soon comments.
- test: docType routing cases (reservation -> reservation_agreement +
  reservation cols; contract -> contract + contract cols; eoi_status stays
  null on both; contract idempotent at/past contract stage).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 21:28:04 +02:00
6c040a617b feat(migration): add expenses + interest EOI status to NocoDB→CRM pipeline
A single idempotent --apply now seeds the full legacy dataset:
- Expenses: fetch the separate "Expenses" NocoDB base (mxfcefkk4dqs6uq),
  transform (price→amount+currency, payment status, receipt marker), apply to
  the expenses table under a new nocodb_expenses ledger tag.
- Interest EOI display state: set interests.eoiStatus/eoiDocStatus from the
  legacy EOI Status / LOI process so deals show signed / awaiting-signature
  (in-flight) state, not only a separate documents row.
- Runner reports expenses + tags createdBy with the seeded super-admin id.

Validated via --apply on the dev DB: 239 clients (multi-deal grouping intact),
255 interests (qualified 171/eoi 51/nurturing 30/reservation 2/contract 1),
48 signed + 3 in-flight EOIs, 165 expenses (5 currencies), 41 docs + 119
signers, 45 residential. tsc clean; 67 dedup unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:18:28 +02:00
7dba1a47bb fix(migration): modernize stale NocoDB→CRM pipeline stage map to current 7 stages
The 2026-05-03 migration pipeline (src/lib/dedup/*) predates the 9→7
pipeline-stage refactor; its STAGE_MAP emitted invalid stages
(open/details_sent/eoi_sent/…) that would write bad pipeline_stage values
on --apply. Remap to the current PIPELINE_STAGES (enquiry/qualified/
nurturing/eoi/reservation/deposit_paid/contract) + a deposit-received →
deposit_paid override. Frozen-fixture test expectations updated (17/17 pass).

Validated: live --dry-run = 239 clients / 255 interests / 41 EOI docs
(matches independent snapshot analysis; pipeline is more conservative and
flags 3 borderline pairs for review).

Adds the migration design spec (source map, scope lock to Port Nimara +
Expenses bases, EOI coverage 48/48, in-flight Documenso state, remaining
gaps: interest eoiStatus, expenses, doc-blob backfill).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 19:03:32 +02:00
31ba72f344 chore(launch-prep): hide unfinished report/import surfaces, defer big builds
Ship-what's-done prep ahead of the prod cutover (launch ~today):

- Hide Financial + Marketing report cards from the reports landing
  (both were "Builder in development" placeholders gated on unbuilt
  data sources). Sales/Operational/Custom + templates/scheduling/
  exports remain live.
- Trim the Custom-report card copy to match the shipped basic builder
  (no group-by/filters yet; the builder page header was already honest).
- Hide the Bulk Import mockup from search-nav-catalog + the admin
  sections browser; /admin/import is now unreachable from the UI.
- Correct client-facing doc over-claims (waiting-list "next-in-line
  notification", Import) in features-list.md + new-system-feature-summary.md.
- Un-stale BACKLOG.md (Documenso phases 2-7 confirmed shipped).
- Log decisions + deferred work (full importer, full custom-builder,
  waiting-list, maintenance-log, paper-upload bug) to launch-readiness.md.

Deferred-importer design spec added at
docs/superpowers/specs/2026-06-01-bulk-import-design.md.

Verified: tsc --noEmit clean, eslint clean on changed files,
1512/1519 vitest pass (7 failures are Redis-down, unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:39:51 +02:00
681b94a8ef feat(reports): prior-period comparison toggle on the Sales report
Adds a "Compare to prior period" toggle to the Sales report header.
When on, the API recomputes the KPI window for the equal-length window
immediately preceding the selected range (previousPeriodBounds) behind
`?compare=1`, and the five window-derived KPI tiles (Won, Lost, Win
rate, Avg time-to-close, New leads) render colour-correct "vs prior"
deltas. Point-in-time tiles (Active interests, Pipeline value) have no
prior-window analogue and intentionally show no delta. The prior-window
query runs in parallel with the main batch and resolves to null when the
toggle is off (zero cost). Toggle state persists in the saved-template
config.

Closes the spec's "period comparison on every report" gap for Sales;
Operational already rendered period-start deltas.

Pure helpers TDD'd: previousPeriodBounds (range.ts) +
computeSalesKpiComparison (sales-comparison.ts), 7 unit tests. tsc +
lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:35 +02:00
172af02f81 feat(residential-toggle): port-level module gate for Residential
Adds a `residential_module_enabled` port setting (default ON) that
hides/disables the entire Residential surface when an admin turns it
off, mirroring the Tenancies / Invoices / Expenses module-toggle
pattern. Disabling is a soft hide — residential clients/interests are
preserved and reappear on re-enable.

Surfaces gated:
- Route guard: new residential/layout.tsx renders ModuleDisabledPage
  (covers all 5 residential pages)
- Sidebar "Residential" section + mobile more-sheet tile (SSR-resolved
  residentialModuleByPort threaded layout → app-shell → sidebar)
- Global search: residential client/interest buckets early-return at
  the shared chokepoint so disabled-port records don't dead-end
- Public intake: /api/public/residential-inquiries 404s when off
- Admin Switch in settings-manager (writes via settings PUT)

Service TDD'd (residential-module.test.ts, 6 tests) plus a
disabled-port rejection test on the public endpoint. tsc + lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:49:16 +02:00
cb8292464c feat(launch-readiness-batch): UAT drains, navigation refactor, launch infra, trackers
Bundles the rest of the in-flight work from this UAT round into one
checkpoint. Each sub-area is independent; see the headings below.

UAT polish (drained 11 findings from active-uat.md):
- Dialog primitive default bumped sm:max-w-xl/lg:max-w-3xl →
  sm:max-w-2xl/lg:max-w-4xl so multi-field forms + PDF previews
  aren't cramped at 1440-1920px.
- Notes tab badge aggregation: new countFor{Client,Yacht,Company}
  Aggregated helpers in notes.service mirror the listFor*Aggregated
  symmetric-reach joins. yacht-tabs + company-tabs render the
  badge; client-tabs already had badge support.
- Supplemental-info form polish bundle: BrandedAuthShell gains a
  `width: 'sm' | 'md'` prop (md uses min-h-dvh scroll instead of
  fixed inset-0 pin so long forms scroll naturally). Form picks up
  port branding (logoUrl + backgroundUrl + appName) via
  loadByToken. Address fields completed (street + city + region +
  postal + country). Port name eyebrow + success-state copy added.
- new-document-menu Upload-file landing toast: per-file completion
  emits toast.success with action link to the destination entity
  or folder.
- interest-tabs OverviewTab "from client" pill on Email + Phone
  rows via new EditableRow `inheritedFrom` prop.
- create-document-wizard subject picker → segmented button strip
  (5 types visible at once).

Launch infra:
- UTM column wiring (Init 1b step 4): migration
  0089_website_submissions_utm.sql adds utm_source/medium/campaign/
  term/content + composite index (port_id, utm_source, received_at)
  for per-campaign rollups. website-inquiries intake accepts the
  five fields. Residential intake intentionally untouched per audit
  scope.
- Invoicing module gate (Init 1c spike): new
  invoices-module.service + invoices layout guard + registry entry
  invoices_module_enabled (default false). Audit conclusion in
  launch-readiness.md: payments table is canonical money path;
  /invoices flow is parallel infrastructure now hidden by default.

Smart-back navigation refactor:
- Replaced breadcrumb component with history-aware Back button.
  New route-labels.ts + use-smart-back hook +
  navigation-history-tracker so back falls through to the parent
  route when there's no prior page in history.
- Sidebar / topbar / mobile-topbar adopt the new pattern; old
  breadcrumb-store kept for back-compat consumers but the
  breadcrumbs component is gone.
- 6 detail pages (admin/errors per-id + codes, invoices/
  upload-receipts, reports kind, tenancies detail, analytics
  metric, client detail) migrated.

Trackers + docs:
- docs/launch-readiness.md — master pre-launch tracker. Includes
  the reports gap audit (cross-cutting filter set, Marketing +
  Financial blockers, custom builder remaining entities, scheduled
  CSV/XLSX, template scope picker).
- docs/superpowers/audits/active-uat.md — 15 findings flipped
  OPEN → SHIPPED locally with fix-applied notes; 4 OPEN remaining
  (each blocked on user input or cross-repo).
- CLAUDE.md — minor session notes carried forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:42:37 +02:00
3bdf59e917 feat(reports-overhaul): sales + operational + custom reports, templates, schedules, exports
End-to-end reports build covering Phases 1, 2, 5, 6, 7 of Initiative 1
in docs/launch-readiness.md. Phases 3 (Marketing) + 4 (Financial)
remain deferred per the gap audit at the bottom of that doc.

Highlights:
- Sales performance report: 7 KPI tiles, pipeline funnel + stage
  velocity + win-rate-over-time + source conversion + rep leaderboard
  charts, deal-heat section, 5 detail tables, stage / lead-cat /
  outcome filters.
- Operational report: 7 KPIs, 7 charts (heatmap, status mix, tenancy
  churn, tenure histogram, signing box plot, occupancy by area, docs
  in pipeline), 4 tables. Module-OFF banner when tenancies disabled.
- Custom (ad-hoc) builder v1: 4 entities (clients, interests, berths,
  tenancies), column-whitelist composer, date filter, CSV download,
  save-as-template. Registry-only extension path for the remaining 6
  entities documented at src/lib/reports/custom/registry.ts.
- Templates: load / modify / save / save-as on Sales / Operational /
  Custom. ?templateId= URL deep-link hydration via useRef guard.
  Active-template badge clears when the user drives view-state via
  wrapped setters; raw setters used on template apply so the badge
  survives.
- Scheduled runs: BullMQ poll fires due schedules, mints report_runs,
  renders, optionally emails. Recipients optional (zero-recipient
  schedules archive without sending). PDF-only output for v1.
  Schedule dialog re-mounts via key prop on schedule.id transitions
  to avoid setState-in-effect reset patterns.
- Server-side PDF endpoint + shared payload renderer
  (lib/pdf/reports/payload-report.tsx) so client + scheduler share
  one rendering path.
- Shared currency formatter (lib/reports/format-currency.ts)
  consolidates 5 duplicated formatMoney helpers; fixes hardcoded
  'USD' in detail tables; pre-formats money rows so PDF export
  (which strips column.format callbacks at the JSON boundary)
  renders consistently with CSV / XLSX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:41:53 +02:00
909dd44605 feat(uat-p5): activity-feed module, signing-order tri-state, webhook health card
- Activity-feed: shared formatting module
  (src/components/shared/activity-formatting.ts) centralises action
  verbs, badge variants, entity-type labels, enum-value normalisation,
  shortValue, and buildDiffLine. The dashboard widget feed and the
  per-entity audit feed now both consume it - duplicate ~250 lines
  collapsed, vocabularies aligned, badge palette unified.
- Signing order setting becomes tri-state. The new
  TEMPLATE_DEFAULT value (the new default) skips overriding the
  template's own signingOrder so each Documenso template's stored
  setting wins. PARALLEL / SEQUENTIAL keep forcing the override.
- Admin Documenso page now ships a Webhook health card backed by
  /api/v1/admin/documenso-webhook/health (secret status,
  expected URL, last received event, recent secret rejections) and
  a "Test now" button that fires a synthetic DOCUMENT_OPENED through
  /api/v1/admin/documenso-webhook/test against the local receiver
  to verify the full pipeline without driving a real Documenso event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:05:14 +02:00
6caf41651f feat(uat-p5): long-tail polish - tag chips, notes counts, hub context, tenancies toggle
- StageStepper renders now carry tag chips next to the progress bar
  (client interest cards, pipeline summary, preview sheet).
- Notes tab badge on the interest detail aggregates note counts across
  the interest, the linked client, the linked yacht, and any companies
  the client is an active member of - reps see the full surface area
  at a glance.
- Admin Settings: Tenancies Module toggle wired into the Feature Flags
  card. Disabling hides nav/tabs without deleting any rows; re-enabling
  brings them back. Service layer was already complete; this surfaces
  the control on the operations page.
- HubRoot recent-files rows now show folder breadcrumb + entity badge
  (Interest/Client/Yacht/Company) so reps can tell at a glance where a
  file lives. Backed by listFiles enrichment (5 batched lookups per
  page; no per-row queries).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:20 +02:00
2592e28578 feat(uat-p4): inheritance polish - yacht dims, occupancy chip, map-flip flag
Phase 4 of the active UAT sweep wraps the inheritance/polish bucket.

- BerthOccupancyChip: new shared component that surfaces the competing
  active interest on a non-available berth as a colour-coded chip with
  a stage badge. Adopted in LinkedBerthRowItem, BerthRecommenderPanel
  recommendation card, and InterestBerthStatusBanner; the banner aligns
  query keys with the chip so React Query dedupes the network call.
- OverviewTab inheritance: getInterestById now ships a yachtDimensions
  block when the interest is linked to a yacht with dimensions. The
  Berth Requirements rows render a "↩ <value> from yacht" pill when
  the desired field is blank; clicking the pill copies the value into
  the interest. After a manual edit, a toast offers to write the new
  value back to the yacht record so the canonical truth stays in sync.
- Map-flip inheritance: ExternalEoiUploadDialog and UploadForSigningDialog
  now expose a single "Mark berth(s) as Under Offer on the public map"
  checkbox that defaults ON when any in-bundle berth already has
  is_specific_interest=true. On submit, PATCHes the in-bundle berths
  that don't already match; sister surface to the EOI generate
  dialog's per-berth picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:48:19 +02:00
fe5f98db23 feat(automate-signing): one-click invitation kickoff + auto cascade + completion broadcast
Phase 3 of the comprehensive UAT round. Implements the Automate
Signing feature per the 2026-05-26 locked decisions.

P3.1 — documents.automation_mode schema
Migration 0088 adds the column with a CHECK constraint enforcing
the three-value enum: manual / sequential_auto / concurrent_auto.
Drizzle schema picks it up; default 'manual' preserves existing
behaviour.

P3.2 — Automate Signing orchestrator service
New src/lib/services/signing-automation.service.ts. enableSigningAutomation
resolves the mode from the envelope's signing order (SEQUENTIAL ->
sequential_auto fires first signer only; PARALLEL -> concurrent_auto
fires all signers in one parallel dispatch), updates documents.automationMode,
and dispatches invitations via the same sendSigningInvitation path
the manual route uses (so the email a recipient sees is identical
regardless of trigger). ensureSigningUrls recovers v2 signing URLs
if they're missing on the local signer rows. Hard guards: envelope
must exist, status in {draft, sent, partially_signed}, ≥2 signers.
disableSigningAutomation reverts to manual; idempotent.

P3.3 — Webhook cascade
The existing sendCascadingInviteForNextSigner in documents.service.ts
already fires the next pending signer on every recipient_signed event
(mode-independent). handleDocumentCompleted already sends the signed
PDF to all recipients via sendSigningCompleted on completion. So
"automate" really means "kick off the first invitation"; the rest
is mode-independent existing behaviour. Doc comment in the new
service explains the interaction.

P3.4 — ActiveEoiCard Automate signing button + banner
- DocumentRow type extended with automationMode + documensoId.
- New automateMutation hits POST /api/v1/documents/[id]/automate;
  pauseAutomationMutation hits DELETE.
- "Automate signing" button visible when totalCount ≥ 2 AND doc has
  documensoId AND envelope is in-flight AND mode === 'manual'.
- "Automating sequentially/concurrently · N of M signed" banner
  renders when automation is active, with a Pause button that
  reverts to manual.
- Per-row Send invitation / Send reminder buttons in SigningProgress
  stay visible per the locked decision (manual override during auto).

P3.5 — Automate Signing API route + tests
- POST /api/v1/documents/[id]/automate (enables) + DELETE (disables).
- Permission: documents.send_for_signing (mirrors the manual
  send-invitation route).
- vitest covering: NotFound on missing doc, Conflict on missing
  envelope, Conflict on completed status, Conflict on already-
  automated, Conflict on <2 signers, disable is idempotent when
  already manual. All 7 cases pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:29:05 +02:00
b6c27b506d feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests
Phase 1 of the comprehensive Documenso upload audit per the
2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md.

P1.1 — persist documensoId immediately after create
Was set only at the late `status: 'sent'` commit. Any throw between
documensoCreate and the late update left an orphaned Documenso
envelope the CRM had no link to. Now the UPDATE runs right after
documensoCreate succeeds; rollback paths can find and void the
envelope.

P1.2 — pre-flight validation hard-blocks Submit
UploadForSigningDialog computes a submissionErrors memo over
recipients + fields. Submit button disabled when errors > 0. Inline
amber summary lists every issue (missing email, invalid email,
missing name, field assigned to non-existent recipient, no fields
placed). Service layer mirrors the same email + name checks so
direct API hits reject early. No override path per locked decision.

P1.3 — cancel/delete affordance audit + sweep
Document-list per-row Delete + Send for Signing actions now:
- Wrapped in PermissionGate (documents.delete + send_for_signing).
- Surface toast on success + toastError on failure (were silently
  swallowing errors).
- Use a broader predicate-based query invalidation so every doc
  list across the app refreshes, not just the local key.
EOI tab Regenerate + Cancel EOI buttons + reservation/contract
tab Cancel buttons wrapped in PermissionGate (documents.edit, the
cancel route's auth check).

P1.4 — Documenso webhook URL auto-PATCH (env-gated)
scripts/update-documenso-webhook.ts written. Reads
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise
no-op). Lists every webhook on the Documenso instance via v2 (with
v1 fallback), identifies webhooks pointing at trycloudflare.com
hosts OR /api/webhooks/documenso paths, PATCHes them to the new
tunnel URL. scripts/tunnel-url.sh chains the script after the URL
print so a re-tunnel auto-rotates the webhook (when flag set).

P1.5 — state-machine refactor with rollbackTo() helper
custom-document-upload.service.ts:
- Single try around create → send → place steps.
- state.step tracks which step is current; state.documensoDocId
  records the envelope id once we have it.
- rollbackTo(reason) composes the recovery: status='cancelled' on
  the CRM row, documensoVoidSafe on the envelope when applicable.
  Idempotent — calling twice is safe.
- Removes three independent try/catches.

P1.6 — recipient ↔ Documenso identity reconciliation
After documensoSend, validates every distinct email we sent
appears in sentDoc.recipients. If Documenso silently dropped one,
a ConflictError fires before field placement so the rollback path
triggers. Explicit message names the missing emails for the rep.

P1.7 — vitest extension + per-failure audit-log entries
- 5 new vitest cases (blank email, whitespace email, malformed
  email, blank name, duplicate-emails-OK semantic).
- rollbackTo writes a structured audit_log entry with failedStep,
  documensoEnvelopeId, errorClass, errorMessage. Post-mortem
  investigation has structured data instead of just logger lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:09:50 +02:00
8e81670b11 feat(uat-polish): live-UAT round — dialog widths, recommender polish, inline create, tenancy + notes plumbing
Compendium of polish + small-fix work captured during the 2026-05-26
live UAT session. Every change has a corresponding entry in
docs/superpowers/audits/active-uat.md with file:line evidence + root
cause + alternatives considered.

Dialog primitive width
- DialogContent default bumped from sm:max-w-lg (512px) to
  sm:max-w-xl + lg:max-w-3xl so every consumer gets a sane desktop
  default. Confirm dialogs override DOWN, content-heavy dialogs
  override UP.
- FilePreviewDialog full-viewport via w-[min(95vw,1400px)] +
  h-[85vh] so PDFs render at usable width on real desktops.

Recommender card
- Heat badge now a Popover with the score (X/100), the formula in
  plain English, the four component breakdowns (recency / furthest
  stage / interest count / EOI count), and a pointer to the admin
  weight tuning page.
- Area letter span dropped from the card header - mooring number
  already prefixes it.
- BerthRecommenderPanel + the dedicated "Berth Recommendations" tab
  both hidden when interest.desiredLengthFt is null. The empty
  guidance card was reading as noise. interest-tabs.tsx computes
  hasDesiredDims once and gates the inline mount + tab strip
  spread off it.

BerthPicker
- Drop area suffix from row labels. Mooring number already carries
  the area letter prefix; group heading conveys the same context.
  Same fix flows to every BerthPicker consumer (tenancy
  create/renew/transfer, interest form, linked-berths picker).

CreateDocumentWizard
- DOCUMENT_TYPE_LABELS constant added to constants.ts. Wizard reads
  from the map instead of naive replace(/_/g, ' '): "EOI",
  "Contract", "NDA", "Reservation Agreement", "Other".
- "Other" option surfaces a hint pointing the rep at the Title
  field so they describe what the doc actually is.

InterestForm inline client + yacht create
- ClientForm gains an onCreated(clientId) callback. Mutation
  returns { id } in create mode so onSuccess can forward.
- InterestForm renders an "Add new" Button next to the Client label
  (create mode only - hidden on edit), opens ClientForm, auto-
  selects the new client into the draft. Mirrors the existing
  inline yacht-create pattern.
- Reset path includes source: 'manual' alongside the other create-
  mode defaults; the manual flow was dropping back to a blank
  source dropdown on reopen.

Tenancy list
- ClientTenanciesTab activeTenancies query now includes status
  IN ('pending', 'active'). Was filtering to active-only; pending
  rows from manual create + webhook auto-create were invisible on
  the client detail's Tenancies tab.
- TenancyList rows are now keyboard- and click-navigable to the
  tenancy detail page (Enter/Space included). Inner links + buttons
  stop propagation so per-cell navigation works.

NotesList source badge
- Aggregated-mode source badge ("Yacht / Test Yacht") is now a Link
  to the source entity's detail page. New sourceLinkFor helper
  centralises the URL mapping across clients/companies/yachts/
  interests + residential variants.

Yacht transfer audit log
- transferOwnership emits a distinct 'transfer' AuditAction (added
  to AuditAction union in src/lib/audit.ts) with old/new owner
  names resolved at write time. EntityActivityFeed renders
  "Matt transferred owner to Jane Smith" instead of "Matt updated
  this record." formatValueForField unwraps the { name } shape so
  the audit_logs Record<string, unknown> typing stays clean.
- yacht-transfer-dialog copy: dropped "atomic" jargon. Reads "The
  change is logged in the audit history" instead.

Companies autocomplete
- /api/v1/companies/autocomplete now returns the 10 most-recently-
  updated companies when the query string is empty. Was returning
  []. CompanyPicker popover opens with results to scan instead of a
  blank dropdown.

DocumentsHub FlatFolderListing
- Uploaded files (the files table) now merge into the documents
  table view via a parallel /api/v1/files?folderId=X query +
  client-side merge into a unified row list. listFiles service
  honours the folderId filter that was already accepted by the
  validator. New renderFileRow renders file rows with an "Uploaded
  file" type pill + "Stored" status pill, links the filename to
  the download URL. Existing FolderDropZone invalidation covers
  the new query, so drag-drop and New-document-menu uploads
  refresh the list without a page reload.
- FlatFolderListing wrapped in a vertically-spaced container so
  subfolders / search row / list have consistent gap.
- Per-row chevron only renders when totalSigners > 0; empty
  placeholder column kept so grid alignment doesn't jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:07:45 +02:00
cae5d39607 feat(documenso): rejection reason + poll fallback + rollback hardening + recipient UX
Documenso reliability + signer-UX bundle from the 2026-05-26 live UAT.
Each piece detailed in docs/superpowers/audits/active-uat.md with full
file:line + root cause + alternatives.

Webhook + poll convergence
- DocumensoRecipient (webhook payload type) gains rejectionReason +
  declineReason. The DOCUMENT_REJECTED / DOCUMENT_DECLINED handler
  coalesces them at the boundary so downstream code sees one stable
  field. Empty/whitespace normalised to null.
- DocumensoDocument.recipients[] (normalized client output) gains
  rejectionReason. normalizeDocument coalesces v2 + v1 field names the
  same way so poller consumers see identical shape.
- handleDocumentRejected signature gains rejectionReason. Stored on
  document_events.eventData, persisted in audit_logs metadata, quoted
  inline in the in-CRM rep notification (truncated 120 chars; full
  reason still on the audit row). New 'transfer' AuditAction added
  alongside.
- signature-poll job now handles REJECTED / DECLINED. Previously only
  SIGNED / COMPLETED / EXPIRED were reconciled, so a missed rejection
  webhook (stale tunnel URL is the typical dev cause) left documents
  stuck in 'sent' forever. The 5-min poll cycle now closes that gap —
  webhook becomes an optimisation, not a correctness requirement.

placeFields rollback gap
- custom-document-upload.service moved the synchronous field-placement
  map() INSIDE the same try/catch that wraps placeFields(). Previously
  the map's throw bubbled past the catch-and-rollback block, leaving
  Documenso with a live envelope + recipients but no fields, and the
  CRM document row stuck in 'sent' with no signing UI for the signers.
  Logger captures looked-up email + map keys on miss for diagnosis.
- Comment documents Documenso's by-email dedupe semantic so future
  readers don't reintroduce the per-recipient-row map assumption.

UploadForSigningDialog recipient UX
- New RECIPIENT_ROLE_META + RecipientRoleBadge helpers. Placement-step
  sidebar list rebuilt as a two-line layout (name + role badge / email
  on its own line) so duplicate-named recipients are visually
  distinguishable. FieldSidePanel dropdown SelectItem mirrors the same
  stacked shape.
- "Recipient" label renamed to "Assign this field to" with an explainer
  paragraph below.

SigningProgress copy-link parity
- Copy-link button now always renders for pending signers (disabled +
  explainer tooltip when signingUrl not yet issued). Reps can copy
  even when the URL hasn't been distributed via email yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:06:12 +02:00
e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00
353a31323e fix(tenancies): unblock first-tenancy chicken-and-egg in webhook
Webhook auto-create on signed Reservation Agreement was gating itself on
isTenanciesModuleEnabled, but autoCreatePendingTenancies never enabled
the module — so the very first tenancy on a fresh port was unreachable
even though the row-exists fallback in isTenanciesModuleEnabled was
designed exactly for this lazy auto-surface case. Drop the gate; the
inserted row now flips the module on automatically via the fallback.

docs/tenancies-design.md §"When disabled" and the P3 PR-table row
updated to reflect the new contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:15 +02:00
c8869338e8 feat(berth-deal-docs): clickable rows open in-page file preview
The Interest Documents tab on the berth detail page listed deal docs
read-only with only an "Open" link to the interest detail page —
forced reps to navigate away just to see the PDF. Now every row whose
backing PDF exists opens the existing FilePreviewDialog inline.

- Service: listDealDocumentsForBerth now joins files and returns
  fileId (COALESCE(signedFileId, fileId) so completed envelopes
  prefer the signed PDF), fileName, mimeType. Drafts without a blob
  yet still appear, just non-clickable.
- UI: row title area is a button that triggers FilePreviewDialog;
  Eye affordance on hover. Falls back to a "no file yet" hint when
  the document has no backing blob. "Open" link stays as the
  secondary "go to interest" action.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:20:01 +02:00
da391b1830 feat(b3-1): interest dimensions dual-source — yacht dims for the recommender
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>
2026-05-25 17:22:57 +02:00
8998f68c0f feat(reports-p7): cover-page brand picker (admin-only)
- 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>
2026-05-25 17:18:00 +02:00
d32e557e56 feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions
- 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>
2026-05-25 17:13:34 +02:00
911b51a669 feat(tenancies-p6-followup): generic create dialog + edit dialog + self-FKs
- 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>
2026-05-25 17:10:06 +02:00
3f9c4589e0 feat(reports-p6): CSV output renderer + per-kind serializers + UI selector
- 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>
2026-05-25 16:35:13 +02:00
dd25ccfb53 fix(tenancies-audit): resolve findings from 7-agent system-wide rename audit
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>
2026-05-25 16:03:14 +02:00
e9ef5831aa feat(reports-p3): BullMQ render + email + schedule poll for report_runs
- 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>
2026-05-25 15:42:53 +02:00
db14056018 feat(tenancies-p7): 4 module-gated dashboard widgets
- 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>
2026-05-25 15:34:43 +02:00
bfb29ab619 feat(tenancies-p4): public-map status flip via active permanent tenancy
- 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>
2026-05-25 15:17:06 +02:00
20549fb22e feat(tenancies-p3): webhook auto-create on signed Reservation Agreement + first-insert flip
- 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>
2026-05-25 15:14:37 +02:00
ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +02:00
1e31ed66f1 feat(reports-p2): CRUD layer for report_runs + report_schedules
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>
2026-05-25 14:26:18 +02:00
3a1c16ae71 feat(external-eoi): auto-cancel + replace generated EOI on upload
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>
2026-05-25 13:15:22 +02:00
cd6b19e173 feat(eoi-generate): Include-yacht toggle to omit Section 3 when yacht is a placeholder
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>
2026-05-25 13:11:19 +02:00
7bdfc340ae feat(admin-settings): radio field type + adopt for Documenso signing-order + send-mode
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>
2026-05-25 13:06:04 +02:00
5d43953957 feat(reports-p1): schema + perms foundation for /reports page
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>
2026-05-25 04:00:57 +02:00
d3ec9fdb4e feat(tenancies-p1): module-enabled gate + admin toggle endpoints
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>
2026-05-25 03:58:19 +02:00
14ae41d0fa feat(uat-b1): ship Wave A-E of Bucket 1 audit findings
Wave A (Interest+EOI form quick wins):
- Auto-select yacht after inline-create from interest form
- EOI generate dialog: "View EOI" action toast
- Interest form berth picker: formatBerthRange compact label
- Remove "Generate EOI" button from Documents tab (clean removal)
- Interest auto-assign: only sales_agent/sales_manager auto-claim
  ownership on create (explicit role check via user_port_roles join)
- LinkedBerthRowItem dims: drop "D" suffix + "L × W" format
- ExternalEoiUploadDialog: prefillSignatories prop threaded from
  active EOI signers
- EOI signature progress on Overview milestone card footer

Wave B (a11y + i18n sweeps):
- aria-live on supplemental-info error state
- text-[10px] -> text-xs in client-pipeline-summary
- Currency formatter: locale default removed (Intl uses runtime)
- en-US/en-GB hardcoded toLocaleString swept across 13 components

Wave C (Primary berth always in EOI bundle):
- Service guard strengthened on update path
- Migration 0083 backfills historical primary rows

Wave D (Onboarding super_admin discoverability):
- /api/v1/admin/onboarding/status endpoint + shared service
- Topbar OnboardingBanner (super_admin, session-dismissible)
- OnboardingTile dashboard widget (rail group, self-hides at 100%)
- Celebration toast + invalidate of shared status on last tick

Wave E (Branded post-completion email idempotency):
- Verified handleDocumentCompleted already owns the email fan-out
- Added regression test for the polling path + idempotency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:40:37 +02:00
41737fa950 feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
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>
2026-05-24 20:41:27 +02:00
70d1e7e9b2 feat(docs): nested-entity 'This deal' / 'From client' split (B4 #8 phase 4)
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
2026-05-23 01:06:45 +02:00