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>
This commit is contained in:
@@ -120,6 +120,60 @@ _Copy tweaks, alignment, single-prop edits, obvious typos._
|
|||||||
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
|
> - **SHIPPED (primitive) in 8f42940:** `src/components/ui/file-input-button.tsx` lands with the shape the queue asked for + an optional `showSelectedFilename` mode. external-eoi-upload-dialog migrated. The 5 other queued sites were re-audited — they already use the hidden-input + Button-trigger pattern (no browser-default UI visible), so no migration was needed; the primitive is in place for any new caller.
|
||||||
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
|
> - **EOI empty state: add "Mark as signed without file" button (parity with Reservation + Contract tabs)** — _src/components/interests/interest-eoi-tab.tsx:553-562_ (`EmptyEoiState` only renders Generate + Upload paper-signed) — `MarkExternallySignedDialog` already supports `docType: 'eoi'` (mark-externally-signed-dialog.tsx:37-41) with full copy ("Flips the EOI sub-status to 'signed' without uploading a file…"); the reservation tab uses the same dialog via a third ghost-button row (interest-reservation-tab.tsx:378-380). EOI tab's empty state just never grew the button. Add it as a third ghost-variant Button, wired to a `setMarkExternalOpen(true)` state hook + the existing dialog. ~5-10 min. Captured 2026-05-21 from UAT. **SHIPPED in 52342ee.**
|
||||||
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
|
> - **Activity feed: "See all" link to the full audit log** — _src/components/dashboard/activity-feed.tsx_ (ActivityFeedInner, around line 175) — the card lists the most recent audit events but has no jump-off to the full audit-log page. Add a "See all" link in the card header (or as a trailing row underneath the list). Confirm the target route (likely `/{portSlug}/admin/audit-log`) and permission-gate the link by the same `audit_log.view` perm the admin sidebar uses, so non-admin reps see the card but not the link. ~10 min. **SHIPPED in 203f543:** link points at `/<port>/admin/audit` and is gated by `admin.view_audit_log`.
|
||||||
|
> - **Interest form berth-picker: render selected berths as compact range/list instead of "A1 + N more"** — _src/components/interests/interest-form.tsx:439-447_ (the PopoverTrigger label render) + reuse _src/lib/templates/berth-range.ts:48_ (`formatBerthRange`, already tested + already used for EOIs / folder names / document-detail Interest sub-label). Today: primary mooring + `" + N more"` truncation regardless of count or layout — `A1 + 4 more` for 5 consecutive berths reads worse than `A1-A5`. Fix: build the full mooring list (primary + additional), pass through `formatBerthRange()`, render the resulting string. Then apply a truncation cap **on segment count** (post-range-collapse `.split(', ').length`): ≤5 segments → render in full (`A1-A5` or `A1, A3, B5-B7, C2, C4`); >5 nonconsecutive segments → fall back to the legacy `<first segment> + N more` form so the button doesn't overflow. Consecutive runs always collapse to a single segment, so `A1-A20` (20 berths, 1 segment) renders compact while `A1, A3, A5, A7, A9, A11` (6 segments) truncates. ~10 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Primary berth must always be included in the EOI bundle (force checkbox on)** — _src/components/documents/eoi-generate-dialog.tsx_ (the "EOI scope" section shipped in ef37901 — currently lets the rep uncheck any berth including the primary), _src/lib/services/interest-berths.service.ts_ `addInterestBerth` + the EOI-generate handler (no service-side enforcement of "primary ⇒ in_eoi_bundle=true"). Today a rep can uncheck the primary berth's "Include in EOI" checkbox before generating, which produces a signed envelope that doesn't commit to the deal's canonical berth — semantically nonsense (the primary IS the berth the deal is about). Fix shape: (a) UI - in EoiGenerateDialog's scope picker AND the upcoming ExternalEoiUploadDialog berth-scope step, render the primary berth's "In EOI" checkbox as checked + disabled with a tooltip ("Primary berth is always included"); (b) Service - `addInterestBerth` and `upsertInterestBerth` should set `is_in_eoi_bundle=true` whenever `is_primary=true`, with a server-side guard rejecting any update that tries to set `is_in_eoi_bundle=false` while `is_primary=true`. Backfill: one-off SQL to flip `is_in_eoi_bundle=true` on any existing row where `is_primary=true AND is_in_eoi_bundle=false`. ~45 min - 1h. Captured 2026-05-24 from UAT.
|
||||||
|
> - **EOI signature progress on Overview: barebones SigningProgress + "View EOI" CTA** — _src/components/interests/interest-tabs.tsx_ (OverviewTab milestone strip) + reuse _src/components/documents/signing-progress.tsx_. Today the EOI milestone block on Overview only shows "EOI sent" / "EOI signed" sub-items (binary ticks). User wants the same per-signer progress widget that lives on the EOI tab, but barebones - signature order + who's signed at a glance + a "View EOI →" link to jump to the EOI tab for the rest of the actions (resend, cancel, etc.). Cheapest path: mount `<SigningProgress documentId={activeEoi.id} signers={signers} />` inside the milestone card when EOI is in `sent` / `partially_signed` / `signed`. Wrap in a small "Active EOI" subsection with a button at the bottom right linking to the EOI tab. ~30-45 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Website analytics: PDF export parallel to the dashboard report** — _new_ `src/components/reports/export-website-analytics-pdf-button.tsx` + _new section catalog_ `src/lib/services/website-analytics-report-widgets.ts` + _new resolver_ `src/lib/services/website-analytics-report-data.service.ts` + reuse the existing `/api/v1/reports/generate` route with `kind: 'website-analytics'` + chart primitives in `src/lib/pdf/reports/charts.tsx`. Today only the dashboard has a PDF export; reps want the same affordance on /website-analytics so they can ship the Umami snapshot for the period. Sections to include: realtime KPIs, pageviews chart, top pages / referrers / countries (tables), weekly heatmap, world map (geo donut), sessions list (top N). Date-range default inherits the page's active range (same pattern as the dashboard fix above). ~6-8h end-to-end. Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟡 OPEN QUESTION — promote Reports from a dashboard dialog to a dedicated page with proper UI. NEEDS DESIGN DISCUSSION before scoping.** — current surface area: `<ExportDashboardPdfButton>` lives in the dashboard header (`src/components/dashboard/dashboard-shell.tsx:174`), opens a dialog with sections checklist + date range + saved-templates picker + preview. As the catalog grew (5 widgets → 25 → likely 40+ once website-analytics export lands), the dialog UX is getting cramped: sections scroll inside a fixed-height popup, no grouping by domain, no per-section data-availability badges, no run-history / saved-template management surface, no schedule-recurring affordance.
|
||||||
|
> - **Discussion seeds (NOT a commitment — anchor for the design pass):**
|
||||||
|
> - **Q1.** Single Reports landing page at `/{portSlug}/reports` listing every report kind (Dashboard, Website Analytics, Client Summary, Interest Summary, Berth Spec, Occupancy, Expenses, …) with a "Generate" CTA per row?
|
||||||
|
> - **Q2.** Per-report builder screen with full-page layout: left panel = sections checklist grouped by domain (Summary / Pipeline / Berths / Lead sources / Operations) + per-section data-availability pills; right panel = live PDF preview that re-renders on toggle.
|
||||||
|
> - **Q3.** Saved-templates manager (rename, share with team, set default-for-this-port, archive). Today `<SavedTemplatesPicker>` is a popover inside the dialog with no management UI.
|
||||||
|
> - **Q4.** Run history: per-port log of every report generated (when, by whom, which sections, downloaded vs emailed). Drives reproducibility ("send me the same report Sarah ran last month") and audit.
|
||||||
|
> - **Q5.** Schedule recurring reports — pick a saved template + cadence (weekly Monday 9am, monthly first-of-month, quarterly) + recipients; the cron fires the report and emails the PDF. Massive value for stakeholders who want regular updates without nagging the operator.
|
||||||
|
> - **Q6.** Per-recipient delivery — email the PDF to designated stakeholders straight from the Generate screen (vs. download + manual email).
|
||||||
|
> - **Q7.** Permission model — `reports.export` exists today; do we need `reports.schedule` + `reports.manage_templates` carve-outs for the scheduling + sharing flows?
|
||||||
|
> - **Q8.** Integration with existing surfaces — keep the dashboard's "Export as PDF" button as a quick path that pre-selects the right report? Or remove it in favor of the dedicated page entirely?
|
||||||
|
> - **Q9.** Visual-design ambition — fleshing this out is also a chance to bring some polish (preview hover state, drag-to-reorder sections, save-as-template inline, schedule from the same screen).
|
||||||
|
> - **Q10.** Output formats beyond PDF — CSV export of the underlying data, Excel workbook with one sheet per section, PNG/JPEG snapshots of each chart, public share-link to a hosted HTML version?
|
||||||
|
> - **Q11.** Customisable report metadata — title + subtitle + cover-page copy + footer note. Today the PDF header is hardcoded "Dashboard summary · {date-range-line}" at `src/lib/pdf/reports/dashboard-report.tsx:195`; the render path already accepts a `subtitle` prop override but the dialog never exposes it. The dedicated-page builder should expose: report title, optional subtitle, optional intro paragraph, optional sign-off / footer (e.g. "Prepared for Board Meeting Q1 2026"). Saved-templates inherit these.
|
||||||
|
> - **Action:** schedule a design session covering Q1-Q10 with the operator stakeholder. Output a short design doc (`docs/reports-page-design.md`) covering routing, data shape, scheduler, permissions, then scope into discrete Bucket 3 items. Until then, keep iterating the dialog (badges, data-availability, currency etc.). Captured 2026-05-24 from UAT.
|
||||||
|
> - **Dashboard PDF export dialog: surface per-section data availability + don't render uninformative "n/a" rows** — _src/lib/services/dashboard-report-data.service.ts_ (per-widget resolvers) + _src/components/reports/export-dashboard-pdf-button.tsx_ (sections checklist) + _src/lib/pdf/reports/dashboard-report.tsx_ (render-time empty-state handling). Today on a fresh port (e.g. Port Nimara), the Average Sales Cycle section renders "Median: n/a · Mean: n/a" because there are 0 signed contracts to compute against. Same risk for: stage_conversion_rates (needs deals that have progressed AND won), berth_demand_ranking (needs interests on berths), reminders_summary (needs reminders in window), recent_activity (needs audit-log entries), new_clients_period / new_interests_period (window-dependent), etc. The "n/a" output is noisy + the rep wasn't warned that the section would be empty.
|
||||||
|
> - **Two-tier fix:**
|
||||||
|
> - **(a) Cheap baseline (~30-45 min):** server-side omit-when-empty. Each resolver returns `null` (or sets `data[widget] = undefined`) when the resulting payload has no meaningful content. The PDF render path already gates on `data.X ?` so the section disappears entirely. Concrete sections to add the gate to: avg_sales_cycle (sampleSize === 0 → omit), reminders_summary (no reminders → omit or render the empty state with copy), stage_conversion_rates (no advanced deals → omit), recent_activity (no events → omit), every period-cohort resolver (count === 0 → omit). When omitted, the section just doesn't appear in the PDF.
|
||||||
|
> - **(b) Dialog-time data availability (~2-3h):** new `GET /api/v1/reports/availability?widgetIds=...&dateFrom=...&dateTo=...` endpoint returns `{ widgetId: 'ok' | 'no_data' | 'needs_window' | 'partial' }` for each requested id (lightweight presence-check queries, no full resolution). Dialog calls it on open + on date-range change; each checkbox row shows a "No data yet" / "Needs date range" muted pill next to widgets that won't render. Rep can keep them checked (they'll be silently omitted) or uncheck for clarity. Same query powers a small "{N} sections will be empty" summary line at the top of the dialog.
|
||||||
|
> - **(c) Optional polish for non-omittable widgets** (e.g. KPIs that should always render even at zero): replace "n/a" with a helpful empty-state string ("No closed deals yet — first signed contract will populate this") so even when the section IS shown, the rep understands why the cell is blank.
|
||||||
|
> - **Recommendation:** ship (a) first (most reps just want clean reports), follow up with (b) when the catalog grows further. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Dashboard PDF report: hardcoded EUR currency + stale "maintenance" berth-status bucket showing 0 / 0%** — two findings UAT 2026-05-24:
|
||||||
|
> - **(a) Hardcoded EUR**: `src/lib/services/dashboard-report-data.service.ts` Revenue forecast snapshot + Pipeline value breakdown both wrote `currency: 'EUR'` regardless of the port's `ports.default_currency`. Symptom: PDF rendered "€14,672,888" on a USD-configured port (Port Nimara). **SHIPPED this session:** service reads `ports.default_currency` once at the top of `resolveDashboardReportData` and threads `portCurrency` through both money-bearing sections. Falls back to USD when null (matches schema default).
|
||||||
|
> - **(b) "maintenance" berth-status bucket**: canonical `BERTH_STATUSES = ['available','under_offer','sold']` (3 values per `src/lib/constants.ts:175`). Stale `maintenance` references rendered a "Maintenance · 0 · 0%" row in the PDF Berth Status table + a 0-value slice in the donut. **SHIPPED this session:** removed from `dashboard.service.ts:264` (service return), `dashboard-report.tsx:25-31 + 272-275 + 332` (PDF row + donut + type shape), `berth-status-chart.tsx:16+26` (dashboard donut), `occupancy-report.tsx:23+31` (defensive label/color map), `tests/unit/pdf-report-renderer.test.ts:49-55` (fixture). 'reserved' (also legacy) still has a defensive label fallback in occupancy-report — left in place since it's data-driven, not proactively rendered.
|
||||||
|
> - **Dashboard export dialog: badges look too big, especially "needs date range" wrapping to 2 lines + dialog defaults to last-30-days instead of inheriting the dashboard's active range** — _src/components/reports/export-dashboard-pdf-button.tsx:282-290 + 65-69_. Two issues caught 2026-05-24: (1) the `CHART` and `NEEDS DATE RANGE` pills use `text-[9px]` + `py-0.5` but `NEEDS DATE RANGE` word-wraps onto a second line so the visual height balloons; (2) initial dateFrom/dateTo hardcoded to last-30 even when the rep just picked Today / 7d on the dashboard. **SHIPPED this session:** badges tightened to `text-[8px] py-px leading-none whitespace-nowrap shrink-0`; ExportDashboardPdfButton accepts `initialRange?: DateRange` and dashboard-shell passes the active range through so the export dialog opens with the picker pre-filled to whatever was already in view. Also bumped the route validator's `widgetIds.max(20)` → `.max(40)` since the catalog now has 25 widgets (was throwing "Validation failed" when all sections were checked).
|
||||||
|
> - **Analytics: click-into-country drilldown across the page (world map + Top countries list + anywhere else country-keyed) — show the timeframe-scoped sessions for that country** — _src/components/website-analytics/visitor-world-map.tsx_ (`onCountryClick` already wired but copies-to-clipboard today), _src/components/website-analytics/top-list.tsx:38_ (Top countries rows render `<span>{countryName}</span>` with no click handler), _src/components/website-analytics/sessions-list.tsx_ (sessions card — needs to honor a country filter), _src/lib/services/umami.service.ts_ `getSessions(portId, range, opts)` (extend opts with `country?: string` → passed through to Umami's `/sessions` endpoint as `country` query param; v2/v3 both honour it), _src/components/website-analytics/use-website-analytics.ts_ `useUmamiSessions` (thread the country filter through). Today: country click does nothing useful in TopList; world map copies a URL to clipboard instead of navigating. User intent: clicking a country anywhere on the analytics page should scope the sessions card (and ideally other country-aware widgets) to that country for the active timeframe.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Page-wide filter state via URL search param `?country=<ISO2>`** so the filter is shareable + survives reload. `useSearchParams` reads it; clicks set it via `router.replace({ pathname, query: { country } })`.
|
||||||
|
> - **(b) TopList country click** — when `rowKey === 'country'` (or however TopList encodes the dimension), wrap each row in a button that sets `?country=<iso>` on click. Render a subtle "→" affordance + tooltip "View sessions from <country>".
|
||||||
|
> - **(c) World-map click** — `onCountryClick={(iso) => setCountryParam(iso)}` (replaces the current clipboard-write).
|
||||||
|
> - **(d) Sessions card scopes by country** — `useUmamiSessions(range, { page, pageSize, country })` passes through. The sessions-list header gains a removable "Filtered: <flag> United States [×]" chip when active; the × clears the param.
|
||||||
|
> - **(e) Other widgets that could honour the filter** (optional, second pass): top-pages, weekly heatmap, pageviews chart — country filter scopes their queries too. Not required for v1.
|
||||||
|
> - **Effort:** ~1.5-2h. ~30 min URL-param state + chip UI. ~30 min thread `country` through service + hook. ~30 min TopList click affordance. ~15 min world-map handler swap. ~15 min test pass. Captured 2026-05-24 from UAT. **Supersedes** the earlier "VisitorWorldMap click should navigate, not copy" entry (this is the proper version of that ask).
|
||||||
|
> - **Recent Sessions card: rows not in chronological order — sort by lastAt desc + display lastAt instead of firstAt** — _src/components/website-analytics/sessions-list.tsx_. Umami's `/sessions` page isn't reliably ordered by any timestamp; client-side sort by `lastAt` desc puts the most-recently-active session at the top, and switching the displayed time from `firstAt` to `lastAt` makes the visible timestamp match the sort key. Captured 2026-05-24 from UAT — **SHIPPED this session**.
|
||||||
|
> - **InterestDocumentsTab: remove or contextualize the Generate-EOI button** — _src/components/interests/interest-documents-tab.tsx_ — the "Generate EOI" button on the Documents tab is duplicated (already lives on Overview milestone strip + EOI tab). Either remove from Documents tab entirely (cleanest), OR make it stage-aware: pre-EOI shows "Generate EOI", at reservation stage "Generate Reservation Agreement", at contract stage "Generate Sales Contract". Each branch uses either the existing template-driven path OR upload-and-place-fields (the universal flow that shipped in 552b966). Reservations + sales contracts are likely to be custom-uploaded most of the time, so the dialog must remain capable of "upload doc → place fields → send via Documenso" for any signing-doc type beyond EOI. Cross-ref: B3 universal upload-with-fields finding (covers generic flow); this entry asks for the stage-bound contextual variant. ~30-45 min for (a) remove path; ~2-3h for (b) stage-aware variant. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Interest auto-assign to creator (sales-rep roles only)** — _src/lib/services/interests.service.ts_ `createInterest` — observed UAT 2026-05-24: deal-owner chip shows "Unassigned" after a super-admin creates an interest. Super-admin behaviour is correct (often acting on behalf of others), BUT for sales-rep roles (`sales_agent`, `sales_manager`) the rep should auto-claim ownership at create time. Fix shape: createInterest reads `ctx.userId` + role; when role IN sales-rep set AND `data.assignedTo` is not explicitly provided, default to ctx.userId. Optional admin setting `auto_assign_creator_to_interest` with role-list (default: enabled for sales_agent + sales_manager, off for super_admin / director / residential_partner / viewer). ~45 min - 1h including the admin toggle + audit log entry on auto-assign. Captured 2026-05-24 from UAT.
|
||||||
|
> - **FileGrid: click-to-preview on each card** — _src/components/files/file-grid.tsx:109-123_ — re-audited 2026-05-24 in the same session: the `onClick={() => onPreview(file)}` IS wired correctly on the button AND every caller (`interest-documents-tab.tsx:180+196`, `client-files-tab.tsx:98`, `company-files-tab.tsx:98`, `yacht-files-tab.tsx`) passes `setPreviewFile` and mounts `<FilePreviewDialog>`. The original "doesn't preview" symptom is most likely the file-type-coverage gap covered by the universal-file-preview Bucket 3 finding (only PDF + images render today; everything else falls through to a blank surface). Leave as-is — the click-handler half doesn't need a fix; the type-coverage half is parked under Bucket 3.
|
||||||
|
> - **EOI generation: success toast missing (especially from Overview milestone action)** — _src/components/documents/eoi-generate-dialog.tsx_ - mutation's `onSuccess` closes the dialog + invalidates queries but doesn't fire a success toast. When the rep generates the EOI from the Overview milestone action (rather than the EOI tab), they get no visible confirmation that the envelope was created and sent. Add `toast.success` mirroring the external-EOI-upload toast: "EOI generated and sent to {N} signer{plural}" - count comes from the returned envelope's recipients. Bonus: include "View EOI" in the toast that navigates to the EOI tab. ~10-15 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Branded post-completion email not firing when Documenso webhook is unreachable (polling fallback may not exercise the email path)** — _src/jobs/processors/documenso-poll.ts_ + _src/lib/services/documents.service.ts_ `handleDocumentCompleted` + any post-completion email-fan-out hook. Observed 2026-05-24: when webhooks were misconfigured (wrong URL), Documenso's OWN built-in confirmation email went to signers but the CRM's branded confirmation (with attached signed PDF) did not - even though `signature-poll` cron runs every 5 min and DOES call `handleDocumentCompleted`. Investigation needed:
|
||||||
|
> - **(a) Does `handleDocumentCompleted` actually queue the branded confirmation email**, or is the email path wired only at the webhook receiver layer (above `handleDocumentCompleted`)? If the latter, the polling fallback closes the doc-status state but skips the email - explains the symptom.
|
||||||
|
> - **(b) If (a) is true, hoist the email-fan-out INTO `handleDocumentCompleted`** so polling and webhook paths produce identical side-effects.
|
||||||
|
> - **(c) Idempotency on both paths.** Whatever marker prevents the email double-sending (probably the existing "is_already_completed" guard in handleDocumentCompleted) needs to also gate the email send so a webhook arriving 5 min after a poll-driven completion doesn't re-fan-out.
|
||||||
|
> - **Pairs with:** the Documenso redirect-URL default finding above (operators who fall into the misconfigured-webhook trap are the ones who would notice this — fix both together so misconfiguration degrades gracefully).
|
||||||
|
> - **Effort:** ~1.5-2h. ~45 min code-trace + verification. ~30-45 min hoisting + idempotency. ~30 min vitest with a poll-driven completion verifying the email queue receives the job. Captured 2026-05-24 from UAT.
|
||||||
|
> - **LinkedBerthRowItem dimensions: drop the "D" suffix + honor user's unit preference** — _src/components/interests/linked-berths-list.tsx:~778_ (LinkedBerthRowItem render) — today shows `206.7ft L · 46.6ft W · 14.5ft D`. The "D" (Draft) is opaque to sales reps and the unit is hardcoded to ft. Drop the draft from the inline strip (it's secondary for sales context; still visible on berth detail). Render length + width in whichever unit was actually entered for the data: yacht's `lengthUnit` column when a yacht is attached, otherwise the sales rep's most-recent typed unit, with a section-level toggle to flip. Pair with the existing dual-source dimension Bucket 3 finding which proposes the same yacht/desired toggle architecture. ~30-45 min. Captured 2026-05-24 from UAT.
|
||||||
|
> - **ExternalEoiUploadDialog: prefill title from derived default + signatories from active Documenso EOI when one exists** — _src/components/interests/external-eoi-upload-dialog.tsx:59_ (`const [title, setTitle] = useState('')` — starts empty even though `defaultTitle` at :110 already builds `"External EOI - {Client} - {berths} - {date}"`) + _:65-99_ (signatories seeding only adds one row from `interestData.clientName/primaryEmail`, ignores any existing Documenso EOI's signer list — which is right there at `useQuery(['documents', doc.id, 'signers'])` in the parent EOI tab `interest-eoi-tab.tsx:255-264`). Today's UX gaps the rep notices in the upload-signed-copy flow:
|
||||||
|
> - **(a) Title field renders empty** even though the dialog already has all the data to derive a sensible default. `defaultTitle` is computed and used as a fallback when the rep leaves the input blank on submit, but reps think "the field is empty, I need to type something" and don't realize a default is silently inserted at submit time. Fix: init the input value from `defaultTitle` once `interestData` + `berthsData` resolve (single effect that flips state from blank → derived default, only if the rep hasn't typed anything yet — gate on `title === ''` to avoid clobbering typed input). Apply **regardless of whether an active EOI exists** (user's "either way" framing).
|
||||||
|
> - **(b) Signatories seed from active EOI's signers when present**, not just the interest's client. Parent EOI tab already loads `signers` (the `useQuery(['documents', doc.id, 'signers'])` block that powers ActiveEoiCard + SigningProgress) — the cheapest path is to thread the active EOI's signer list through as a prop: `<ExternalEoiUploadDialog prefillSignatories={activeEoi ? signers.map(...) : undefined} />`. Dialog's `signatories` useMemo updates: if `prefillSignatories` is set AND `signatoriesOverride === null`, return the prefill; else fall through to the existing client-only seed. Maps each Documenso signer's `signerName/signerEmail/role` to a `SignatoryRow`, normalizing the role union (`'SIGNER'` → `'client' | 'developer' | 'rep' | 'witness' | 'cc'` based on the documenso-side role hint or position; if normalization is ambiguous, default to `'witness'` and let the rep correct).
|
||||||
|
> - **(c) Cross-ref:** pairs cleanly with the Bucket 2 "auto-cancel generated EOI when external uploaded" finding — the rep is told "this will replace the generated EOI" AND sees the existing signatories pre-filled, so they don't have to retype names/emails for 3 signers when the data is right there.
|
||||||
|
> - **Effort:** ~30-45 min total. ~5 min title pre-fill (single effect or initial-state-from-prop pattern). ~20-30 min signatory prefill prop + role normalization. ~10 min vitest covering the two prefill paths + the "rep edited then re-opens" cache behaviour. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Interest form: auto-select yacht after creating one via the inline YachtForm modal** — _src/components/interests/interest-form.tsx:789-794_ — the inline yacht-create modal mounts `<YachtForm initialOwner={...} />` but doesn't pass an `onCreated` callback, even though YachtForm already supports one (`yacht-form.tsx:78` — `onCreated?: (yacht: { id; name }) => void | Promise<void>`). When the rep creates a yacht from inside the interest form, the modal closes and the YachtPicker stays empty — the rep then has to find their just-created yacht in the dropdown and select it. Trivial fix: pass `onCreated={(y) => setValue('yachtId', y.id, { shouldDirty: true })}`. Mirrors the same auto-select pattern used elsewhere for inline client-create flows. ~3 min. Captured 2026-05-24 from UAT.
|
||||||
|
|
||||||
1. **Dev-mode banner dismissible** — _src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
|
1. **Dev-mode banner dismissible** — _src/components/shared/dev-mode-banner.tsx:23_ — added X close button + localStorage persistence keyed by redirect address. Fixed in this session.
|
||||||
2. **KPI tile top padding collapsing at ≥640px** — _src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
|
2. **KPI tile top padding collapsing at ≥640px** — _src/components/dashboard/{pipeline-value,active-deals}-tile.tsx_ — shadcn `CardContent` default `sm:pt-0` (assumes a `CardHeader` above) was overriding the tile's `pt-5`. Added `sm:pt-5 sm:pb-5`. Fixed in this session.
|
||||||
@@ -518,6 +572,88 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
> Total ~2.5-3 h end-to-end. Closes the multi-berth EOI discoverability gap (plan §1 + §4.6) and matches the documented workflow expectation that public map visibility is a _subset_ of EOI bundle coverage.
|
||||||
>
|
>
|
||||||
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
|
> **SHIPPED (a) in 05e727f:** `addInterestBerth` defaults flipped: `is_in_eoi_bundle: true`, `is_specific_interest: matches isPrimary`. (b) `linked-berths-list.tsx` rename + tooltip shipped in PR10. **(c) SHIPPED in ef37901:** EoiGenerateDialog gains an "EOI scope" section listing every linked berth with "In EOI" + "Public map" checkboxes; handleGenerate diffs vs server snapshot and PATCHes only changed rows in parallel before kicking off the envelope. Cache invalidation extended to `['interests', id, 'berths']` so LinkedBerthsList stays consistent.
|
||||||
|
>
|
||||||
|
> - **LinkedBerthsList: post-EOI UI changes — lock EOI-bundle toggle + add "In EOI" badge + let manual upload pick signed berths** — _src/components/interests/linked-berths-list.tsx:641+_ (the card body), _src/components/interests/external-eoi-upload-dialog.tsx_ (currently has no berth-scope step). Today the LinkedBerthsList always renders both toggles (Include in EOI + Public map status) regardless of whether the EOI is sent or signed — so a rep can still flip "Include in EOI" on a deal whose envelope is already mid-signing or signed, which is meaningless (signature scope is locked in the Documenso envelope's actual signed berths) and noisy.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Hide / disable "Include in EOI" once the EOI is sent or signed.** When `eoiStatus IN ('sent', 'signed')` OR `eoiDocStatus IN ('sent', 'signed')`, replace the toggle with a static "Included in EOI ✓" / "Not in EOI -" badge (read-only). Keep the public-map toggle editable — under-offer / reserved mutates after EOI too.
|
||||||
|
> - **(b) Per-row "In EOI" badge** on every berth that was actually in the signed/generated envelope. Source of truth: the existing `is_in_eoi_bundle` snapshot at envelope-create time (we'd lock those rows on EOI generate; the post-generate UI just reads them). Subtle pill at the row's right edge so the rep can see at a glance which berths the signed document covers.
|
||||||
|
> - **(c) External EOI upload dialog: berth scope step.** Today's ExternalEoiUploadDialog doesn't ask which berths were signed - it implicitly assumes all linked interest berths. Add a step: list every linked berth as a checkbox row (defaulting to all checked), let the rep uncheck any that weren't on the signed paper, AND let the rep add berths NOT currently on the interest (with a small picker that links + flags `is_in_eoi_bundle=true` in one shot). On submit, the service writes both the document row AND the per-berth EOI-scope snapshot, mirroring the locked behaviour of the generated path.
|
||||||
|
> - **Effort:** ~4-6h end-to-end. ~1h (a) toggle visibility gate + read-only badges. ~1-1.5h (b) per-row "In EOI" badge. ~2-3h (c) ExternalEoiUploadDialog berth-scope step + service writeback + berth-add picker. ~30 min vitest covering "lock once sent". Captured 2026-05-24 from UAT.
|
||||||
|
> - **OverviewTab "Berth size desired" section: ft/m toggle + display attached yacht's dimensions inline (edit-back to yacht record)** — _src/components/interests/interest-tabs.tsx_ (OverviewTab "Berth size desired" block) + _src/components/yachts/_ (yacht-update service / inline-editable hook). The section was supposed to have a ft/m toggle defaulting to whichever unit was originally entered (per yacht's `lengthUnit` when attached, OR the rep's last typed unit). The toggle isn't present. Additionally, the linked yacht's actual dimensions aren't shown in this section — so the rep can't see "boat is 60ft × 18ft × 5ft" while typing desired-dim values for berth shopping. Both should be displayed at a glance, and yacht-dim values should be inline-editable here with the edit propagating back to the yacht record (single source of truth — yacht stays canonical).
|
||||||
|
> - **Pairs with:** the existing Bucket 3 dual-source dimension finding which covers the persisted source-of-truth picker. This entry is the OverviewTab UI half of the same architecture.
|
||||||
|
> - **Effort:** ~1.5-2h. ~30 min unit-toggle + default-from-data resolver. ~30-45 min yacht-dim row render. ~30-45 min inline-edit wired back through the yacht service. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Multi-berth interest label sweep — every "Berth X" surface should render the full berth-range label (`A1-A3, B5`), not just the primary mooring** — _new helper_ `src/lib/templates/interest-berth-label.ts` (`deriveInterestBerthLabel(string[]) → string | null`, reuses `formatBerthRange`, truncates to "first + N more" when >5 segments) + _src/lib/services/interest-berths.service.ts_ (new `getAllBerthMooringsForInterests` batch aggregator) + _src/lib/services/interests.service.ts_ (extend BoardInterestRow + listInterests row shape with `berthMoorings: string[]`) + render-site sweep across every place the interest's identity is named:
|
||||||
|
> - **Sites to update (mapped from grep audit):** `src/components/interests/interest-detail-header.tsx:188-196` (the header user explicitly called out), `interest-card.tsx:55`, `pipeline-card.tsx:52-53` (kanban), `interest-columns.tsx:171-184` (list view), `interest-detail.tsx:135` (breadcrumb), `clients/client-pipeline-summary.tsx:187-188 + 301-302` (per-client deal rows), `clients/client-interests-tab.tsx:42-43 + 204-205`, `yachts/yacht-tabs.tsx:323-325` (yacht's deals tab), `search/search-result-item.tsx:68` + `search/command-search.tsx:970`, `shared/interest-picker.tsx:74`, `shared/berth-picker.tsx:126`.
|
||||||
|
> - **Strategy:** thread `berthMoorings: string[]` through every list endpoint that returns interest rows; render sites compute `deriveInterestBerthLabel(row.berthMoorings)` instead of reading bare `berthMooringNumber`. Bare primary mooring stays available for berth-FK queries that don't need the label (smart-archive, send-berth-pdf etc.). PDF templates (`client-summary.tsx`, `interest-summary.tsx`) also threaded.
|
||||||
|
> - **Effort:** ~3-4h. ~30 min helper + aggregator. ~30 min list-endpoint shape extension (BoardInterestRow + InterestRow). ~2-3h render-site sweep (~10-12 surfaces). ~30 min vitest covering helper truncation rules + service shape. Captured 2026-05-24 from UAT — IN-FLIGHT (this session).
|
||||||
|
> - **External EOI upload while a generated EOI is active: auto-cancel + replace (single source of truth for "the" EOI on an interest)** — _src/components/interests/external-eoi-upload-dialog.tsx_ (the entry surface) + _src/lib/services/documents.service.ts (markExternallySigned + cancelDocument)_ + _src/lib/services/documenso-client.ts (voidDocument)_. Today the upload-signed-copy path doesn't reconcile with a live generated EOI — the rep ends up with two EOI records on the interest (the in-flight Documenso envelope + the newly-uploaded externally-signed PDF), which compounds reporting noise, audit-log confusion, and the empty-tab dual-state problem. Pairs with the Bucket 4 bug (blank body on upload-signed-copy when active EOI exists).
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Detect-and-warn at dialog open:** when `ExternalEoiUploadDialog` mounts for an interest that already has a non-terminal generated EOI (status in `sent` / `partially_signed`), show a warning banner at the top: "An EOI generated on {date} is currently in flight. Uploading a signed copy will cancel the generated envelope and replace it with the upload." + a primary "Cancel & replace" button + a secondary "Keep both (legacy behaviour)" toggle (off by default, hidden behind an Advanced disclosure — most reps shouldn't need it).
|
||||||
|
> - **(b) Replace-mode service behaviour:** when the rep proceeds, the service runs in this order inside a transaction: (i) call `cancelDocument({ documentId: activeEoi.id, cancelMode: 'delete' })` to void the Documenso envelope + flip local row to `cancelled`; (ii) run the existing `markExternallySigned` flow with the uploaded file; (iii) emit one combined audit log entry ("EOI replaced by external upload — generated envelope {id} cancelled, external file {fileId} attached"). Idempotent: if there are multiple non-terminal generated EOIs (shouldn't happen but defensive), cancel all of them.
|
||||||
|
> - **(c) Edge cases:**
|
||||||
|
> - Generated EOI is already `partially_signed` (one or more signers signed) — warn explicitly: "1 of 3 signers has already signed the generated EOI. Cancelling will lose their digital signature record." Require a typed confirmation ("REPLACE") to proceed. The signing history stays in `audit_logs` even after cancel, but the Documenso-side proof is gone.
|
||||||
|
> - Generated EOI is already `completed` (fully signed via Documenso) — block the replace path entirely. Toast: "This EOI is already signed via Documenso. There's no need to upload an external copy." (The "Mark externally signed without file" path should also be blocked in this state — verify.)
|
||||||
|
> - Generated EOI is `cancelled` or `rejected` — no warning needed; proceed with the upload as today (empty-state path).
|
||||||
|
> - **(d) Audit-trail clarity:** the activity feed entry for the replace should link back to the cancelled-document ID so the rep can dig into why the replace happened later. Not critical, but useful for the "what happened to that EOI" question.
|
||||||
|
> - **Acceptance:**
|
||||||
|
> - Rep with an active generated EOI clicks "Upload signed copy" → sees the warning banner with the pending EOI's metadata (date + signed/total signers).
|
||||||
|
> - Confirming replace: generated envelope voided, external file uploaded + linked, doc status flips to `signed`, only ONE EOI row remains active on the interest. Activity feed shows the replace as a single event with cross-link to the cancelled envelope.
|
||||||
|
> - Already-completed EOIs block the path entirely with a clear toast.
|
||||||
|
> - Partially-signed EOIs require explicit typed confirmation.
|
||||||
|
> - **Effort:** ~2-3h. ~30 min dialog warning banner + active-EOI lookup. ~45 min service replace path (cancel + mark-externally-signed in transaction + combined audit entry). ~30 min completed/partially-signed gate copy + UX. ~30 min activity feed cross-link. ~30-45 min vitest covering the 4 states (no active EOI / pending / partially-signed / completed). Captured 2026-05-24 from UAT. **Pairs with:** Bucket 4 bug "Upload signed copy on ActiveEoiCard renders blank body" — same workflow, same surface area; ship them together so the same QA pass covers both.
|
||||||
|
> - **Documenso post-sign redirect URL: change default from blank → port's marketing site (today blank lets Documenso fall back to CRM login)** — _src/lib/settings/registry.ts:248-260_ (existing `documenso_redirect_url` registry entry — port-scoped, type `url`, NO `defaultValue`) + _src/lib/services/documenso-payload.ts:128_ (`DEFAULT_REDIRECT_URL = ''` — when the per-port setting is unset, the payload sends an empty redirect, which causes Documenso to fall back to its own configured default — for the operator that lands on the CRM login) + _src/lib/settings/registry.ts:502_ (existing `public_site_url` setting — port-scoped marketing site URL, already in the registry). Today the admin CAN set `documenso_redirect_url`, but most operators don't realize they need to, leaving it blank, which lands every signer on the CRM login post-signing. Signers are clients, not CRM users — they shouldn't be looking at our login.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Default resolution in the payload builder.** `documenso-payload.ts:322` currently does `options.redirectUrl ?? DEFAULT_REDIRECT_URL` (= `''`). Replace with a small resolver: per-port `documenso_redirect_url` → per-port `public_site_url` → empty (Documenso's own fallback). The marketing-site default kicks in automatically for every port where the operator has set `public_site_url` (which is most of them — see registry:502 "Used by some templates and CTAs").
|
||||||
|
> - **(b) Surface the resolved value in the admin UI.** The Documenso settings card already routes through the unified registry-driven form, which surfaces env-fallback / port-override badges per field via `/api/v1/admin/settings/resolved` (per the e33313b ship). Extend the resolver chain handling for `documenso_redirect_url` so the admin sees "Using `public_site_url`: https://example.com" as the inline source-of-truth note when no explicit override is set. Eliminates the "I set the marketing site URL, why are signers still landing on the CRM login?" diagnosis loop.
|
||||||
|
> - **(c) Description copy:** existing description ("Where signers land after completing their signature. Both v1 and v2 honour it.") gets one extra clause — "When blank, falls back to the port's public marketing site (Public site URL setting); when both are blank, signers land on Documenso's own default (typically the CRM login — not recommended)."
|
||||||
|
> - **Note on "for each party":** Documenso's `meta.redirectUrl` is **document-level**, not per-recipient — all parties hit the same URL post-signing. The current ask matches that shape (one URL, applied to all signers); if per-signer-role redirects are needed later (e.g. client → marketing, developer/approver → an internal "thanks" page), that's a separate Documenso API capability worth investigating but not in scope here.
|
||||||
|
> - **Effort:** ~30-45 min. Resolver in payload builder + small admin UI source-of-truth note + a vitest covering the three resolution states (port override / public_site_url fallback / both blank). Captured 2026-05-24 from UAT.
|
||||||
|
> - **EOI Generate dialog: "Include yacht details" toggle to omit Section 3 even when a yacht is linked** — _src/components/documents/eoi-generate-dialog.tsx:667-_ (the optional Section 3 "Optional (Section 3 - left blank if absent)" block) + _src/lib/templates/merge-fields.ts:18-39_ (yacht._ + owner._ tokens) + _src/lib/services/documenso-payload.ts_ + _src/lib/pdf/fill-eoi-form.ts_ + _src/lib/services/eoi-context.ts_. Today Section 3 in the EOI is "optional" only in the sense of "left blank if no yacht is linked" — when a yacht IS linked, the section always renders with the yacht's data. Reps sometimes want to omit Section 3 even when a yacht exists: early-stage clients still yacht-shopping (yacht on file is a placeholder), multi-berth EOIs where yacht-specific dims don't apply, or the client explicitly asked to keep it off the document. No path today besides un-linking the yacht (lossy) or generating from a custom one-off template.
|
||||||
|
> - **Two-tier fix:**
|
||||||
|
> - **(a) Baseline (cheap):** add `Include yacht details` Switch/Checkbox to the Optional-section header in EoiGenerateDialog, defaulted ON, only rendered when `ctx.yacht` is set. When OFF, the dialog submits with yacht._ + owner._ merge fields blanked to empty strings (existing template tolerates blanks per the "left blank if absent" copy). In-app PDF fill pathway (`fill-eoi-form.ts`) skips the AcroForm field writes for the yacht block. Persists per-EOI as `documents.metadata.includeYachtDetails` (false) so the audit trail shows the rep's explicit choice; nothing else changes structurally. ~1.5h.
|
||||||
|
> - **(b) Optional polish:** per-port `documenso.templates.eoiWithoutYacht` template variant. When the toggle is OFF AND the port has an alt template configured, the dialog routes to that template instead — cleaner rendering with no Section 3 heading at all (the alt template is laid out without the section). When no alt template, fall back to (a)'s blank-out. ~1-1.5h additional, optional; only worth it once a port asks for the cleaner output.
|
||||||
|
> - **UI placement:** the toggle sits in the same header row as the existing ft/m unit picker (eoi-generate-dialog.tsx:672-694), next to the "Optional (Section 3...)" label — same row, right-aligned, so it's discoverable but doesn't disrupt the field grid below. Header copy updates from "Optional (Section 3 - left blank if absent)" to "Section 3 - yacht details" with the toggle nearby (its state IS the "include or not" affordance now).
|
||||||
|
> - **Out of scope:** conditional template logic (Documenso v1/v2 merge tokens are plain substitution, no `{{#if}}`); a multi-template registry beyond the single alt variant.
|
||||||
|
> - **Effort:** ~1.5-2h for (a) alone; ~3h end-to-end with (b). Captured 2026-05-24 from UAT.
|
||||||
|
> - **🟡 OPEN QUESTION — Reservations module: re-imagine end-to-end as the final A-Z piece of the CRM. NEEDS DESIGN DISCUSSION before any implementation.** — _module surface area for reference: src/lib/db/schema/reservations.ts (berth_reservations table), src/lib/services/berth-reservations.service.ts, src/components/reservations/ (BerthReservationsList, BerthReserveDialog, ReservationDetail), src/components/{clients,yachts,berths}/...-reservations-tab.tsx (read-only consumers), src/components/interests/interest-reservation-tab.tsx (the doc-signing flow, NOT the record flow), src/app/(dashboard)/[portSlug]/berth-reservations/page.tsx (top-level list, unlinked from sidebar)._
|
||||||
|
> - **Status today (discovery, 2026-05-24):** the infrastructure exists end-to-end but the workflow is half-wired and conceptually fragmented:
|
||||||
|
> - `berth_reservations` rows are the canonical "who occupies a berth right now" record (per H-01 schema comment). Status union: `pending | active | ended | cancelled`. Tenure union: `permanent | fixed_term | fee_simple | strata_lot | seasonal`. FK chain: berth + port + client + yacht (all NOT NULL with RESTRICT) + interest (nullable, SET NULL — reservation legitimately outlives the deal).
|
||||||
|
> - Creation surface: ONLY `BerthReserveDialog`, mounted on the berth detail's reservations tab. No client/yacht/interest entry point.
|
||||||
|
> - The `/[portSlug]/berth-reservations` top-level page exists but is NOT in `sidebar.tsx` — invisible navigation-wise.
|
||||||
|
> - The interest pipeline stage `reservation` + `reservationDocStatus` ('sent' / 'signed') is a **separate concept** — it tracks the legal _agreement_ (Documenso doc), not the occupancy _record_. Today a deal can pass through `reservation` stage with a signed agreement and never produce a `berth_reservations` row.
|
||||||
|
> - Client/yacht reservation tabs are read-only listings (active + lazy-loaded history). They render empty for most entities because the creation flow isn't being used.
|
||||||
|
> - **The deeper question:** what should "reservation" actually mean in this CRM? The data model carries five concepts under one umbrella that may need to be teased apart or unified deliberately:
|
||||||
|
> 1. **Occupancy facts** — who's tied up at which berth right now (current `berth_reservations`).
|
||||||
|
> 2. **Forward bookings** — future allocations (status='pending', future startDate).
|
||||||
|
> 3. **Tenure / lease records** — permanent ownership-equivalents (status='active', null endDate, tenureType='permanent' / 'fee_simple' / 'strata_lot').
|
||||||
|
> 4. **Seasonal stays** — short-term winter haul-out / summer slots (status='active', tenureType='seasonal', fixed endDate).
|
||||||
|
> 5. **The legal agreement** — the signed Documenso doc that authorises any of the above (currently lives on `interests.reservation*` fields, not on `berth_reservations`).
|
||||||
|
> - **Open questions to resolve in the design pass:**
|
||||||
|
> - **Q1.** Is "reservation" one concept or several? Today `tenure_type` plus `status` plus `endDate` carve five slices out of one table; would it be clearer as `bookings` (future-dated) + `tenancies` (active occupancy) + `agreements` (signed contracts)? Or is the unification fine and the problem is purely UX/flow?
|
||||||
|
> - **Q2.** Where does the rep _start_ a reservation? Berth-first (today's flow — pick a berth, then assign client/yacht), interest-first (move a deal through pipeline → auto-create on agreement signed), or both? Each implies different default-population + permission shapes.
|
||||||
|
> - **Q3.** Auto-create on agreement signing — yes/no/conditional? If yes, what status: `pending` (rep confirms details after) or `active` (immediate occupancy)? What happens if the signed agreement doesn't carry an explicit startDate?
|
||||||
|
> - **Q4.** Multi-berth interests (per CLAUDE.md, `interest_berths` is source of truth). When a multi-berth EOI/Reservation Agreement is signed, do we mint one reservation per in-bundle berth, or one reservation linked to the primary with the others tracked elsewhere?
|
||||||
|
> - **Q5.** Lifecycle outside the sales pipeline — renewals, transfers (Client A's reservation → Client B), seasonal returns (same client, same berth, year after year). Do these get new rows, or do we mutate? Does the agreement need to be re-signed each time?
|
||||||
|
> - **Q6.** Public map ("Under Offer" / "Sold" precedence) — should an `active` reservation flip the public berth status to something distinct from the existing interest-driven precedence ladder?
|
||||||
|
> - **Q7.** Reporting — what views does leadership actually want? Occupancy heatmap by month? Revenue forecast by tenure expiry? Renewals at risk? The data is there; the surfaces aren't.
|
||||||
|
> - **Q8.** Permissions — who can create / mutate / cancel reservations? Today gated through generic permissions; reservations probably warrant their own permission carve-outs (esp. cancellation, which has revenue implications).
|
||||||
|
> - **Q9.** Empty-state UX on client/yacht tabs — hide-when-empty vs always-show-with-helpful-empty-copy. The hide-when-empty option is cheap (data already returned in the parent loader); the "always show with creation CTA" option requires settling Q2 first (can rep create from these surfaces, or only from berth).
|
||||||
|
> - **Q10.** Integration with invoicing / expenses — does an active reservation drive recurring invoice generation? Tenure expiry → automatic renewal nudge?
|
||||||
|
> - **Sketch of plausible shape (NOT a commitment — anchor for the discussion):**
|
||||||
|
> - **Auto-create on signed `reservation_agreement`** (via existing `handleDocumentCompleted` idempotent webhook receiver). Status `pending`, rep confirms startDate + tenureType in a follow-up modal before flipping to `active`.
|
||||||
|
> - **Sidebar Reservations entry** below Berths, gated by a new `reservations.view` permission.
|
||||||
|
> - **Hide-when-empty** on Client / Yacht tabs (cheapest discoverability win). Berth tab stays always (it's the manual creation surface).
|
||||||
|
> - **Reporting:** at minimum an Occupancy widget on the dashboard (% berths under active reservation), Renewals at risk (active reservations with endDate within next 90 days), Revenue forecast by tenure (sum of berth prices × expected duration).
|
||||||
|
> - **Possible rename for clarity:** "Reservation Agreement" → "Tenancy Agreement" (signed doc); "Reservation" → "Tenancy" (occupancy record). Aligns with marina-industry vocabulary and removes the agreement/record confusion.
|
||||||
|
> - **Why this is the A-Z final piece:** the CRM today covers lead → qualification → EOI → reservation agreement → contract → handover. Reservations are the canonical record of what the sale produced — without them filled in, the system has no answer to "which berths are taken right now and by whom?" beyond ad-hoc interest-state inference. Everything downstream (renewals, occupancy reporting, transfer flows, revenue recognition timing) hangs off this. **Worth a dedicated session to design before any implementation.**
|
||||||
|
> - **Action:** schedule a design session covering Q1-Q10 with stakeholders who care about the operational side (rep workflow + ops/leadership reporting). Output should be a short design doc (`docs/reservations-design.md` or similar) covering data-model decisions (split or unify), workflow entry points, automation rules, reporting surfaces, and a phased rollout. THEN scope into discrete Bucket 3 items. Captured 2026-05-24 from UAT.
|
||||||
|
> - **Interest create: duplicate-detection warning (overlap with existing open interest for same client)** — _src/lib/services/interests.service.ts_ (new helper `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })`) + _src/lib/services/interest-berths.service.ts_ (read-side helper) + _src/components/interests/interest-form.tsx_ (new pre-submit warning panel) + _new route_ `GET /api/v1/interests/duplicate-check?clientId=...&berthIds=...`. Today a rep can create an "A1" interest for a client and then a second "A1-A10" interest for the same client without any signal — silent data quality erosion that compounds across pipeline reports, the public map ("Under Offer" precedence), recommender tier ladder, EOI bundles. Decision: **warn, do not block** — legitimate cases exist (re-opening a lost deal, parallel scenarios, renewal alongside existing).
|
||||||
|
> - **Detection scope:** match on `client_id` + ANY berth-set intersection. "Open" = `outcome IS NULL` (or non-terminal); closed/lost/won interests are excluded from the warning. Use `interest_berths` as the source of truth (per CLAUDE.md — `interests.berth_id` does not exist post-0029); intersection is any shared `berth_id` across the candidate's berth list and any open sibling interest's berth list. Multi-tenant scope enforced via `port_id` on both sides of the join.
|
||||||
|
> - **Service shape:** `findOverlappingOpenInterests(portId, clientId, berthIds, { excludeInterestId? })` returns `Array<{ id, displayLabel, pipelineStage, currentBerths: string[], overlappingBerths: string[], updatedAt }>` — sorted most-recent first. `displayLabel` reuses the same derivation used for the document-detail Interest sub-label (berth-range via `formatBerthRange()`).
|
||||||
|
> - **UI shape (interest-form.tsx):** debounce-fire the check (300 ms) whenever both `clientId` and ≥1 berth are set; render an amber `<Alert>` (NOT `destructive`) above the submit row with copy `Possible duplicate — this client already has {N} open interest{plural} that overlap{s} with the selected berth{s}` + a list of conflicting interest chips (each linking out to `/{portSlug}/interests/{id}` in a new tab). No blocking — Submit stays enabled. On edit (existing interest), pass the interest id as `excludeInterestId` so the form doesn't flag itself. Same alert appears whenever the rep adds/removes a berth such that the overlap set changes.
|
||||||
|
> - **Edit mode behaviour:** also fires on edits — a rep extending an existing interest's berth set into another open interest's territory gets the same warning.
|
||||||
|
> - **Out of scope (intentional):** no admin-configurable strict-block toggle; no warning for closed-outcome siblings; no warning across tenants. Keep it dead-simple now and revisit if data quality issues persist.
|
||||||
|
> - **Effort:** ~1.5-2h (service helper + integration test for the overlap query + lightweight route + form-side hook + UI panel + a single Playwright smoke covering the create + edit paths). Captured 2026-05-24 from UAT. Cross-ref: pairs with existing "berth pre-flight dup check" (already shipped) — same intent (data quality at create time), different axis (this one is client-scoped, that one is berth-scoped).
|
||||||
|
|
||||||
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
1. **Berth-demand widget visual overhaul** — _src/components/dashboard/berth-heat-widget.tsx_ — original "Berth heat" widget was a generic table that read as uninspired. First pass added an editorial hero + gradient — that strayed from the standard `CardHeader`/`CardContent` idiom and looked out of place next to siblings. Final version matches `hot-deals-card.tsx`'s layout exactly (icon + title + description in CardHeader, list of `-mx-2 hover:bg-accent/60` rows in CardContent); the visual upgrade is the per-row status-coloured magnitude bar. UI label renamed "Berth Heat" → "Berth Demand" in `widget-registry.tsx`. Fixed in this session.
|
||||||
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
2. **First-class "demand" sort on the berths list** — _src/lib/services/berths.service.ts_, _src/components/berths/berth-columns.tsx_, _src/lib/validators_ — added `?sort=activeInterestCount` to the berths-list service via a correlated subquery in `customOrderBy`; attached `activeInterestCount` per row using the existing two-pass post-fetch pattern (alongside tags/latestInterestStage); added the "Active interests" column to `BERTH_COLUMN_OPTIONS` (default-visible, sortable). Widget's "View all by demand →" link deep-links to `/berths?sort=activeInterestCount&order=desc`. Saved views and the column picker can now use the same lens. Fixed in this session.
|
||||||
@@ -536,8 +672,21 @@ _Component refactors, multi-file edits, single-service tweaks, new validators._
|
|||||||
|
|
||||||
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
_New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||||
|
|
||||||
> **[Umami] Larger follow-ups parked at end of 2026-05-19 build session:**
|
> **[Captured 2026-05-24 from UAT]**
|
||||||
>
|
>
|
||||||
|
> - **Interest dimensions: dual-source model (yacht dims + desired dims) with per-interest source-of-truth + recommender view-time toggle** — _src/components/interests/interest-form.tsx:670-710_ (the "Berth size desired" section, the `space-y-3` block in the React grab) + _src/lib/db/schema/interests.ts_ (new column) + _src/lib/services/berth-recommender.service.ts:379-455_ (predicate builder reads `desired*` straight off `interests` today; no yacht lookup) + _src/components/interests/berth-recommender-panel.tsx_ (new view-time picker) + _src/lib/services/qualification.service.ts_ (`computeAutoSatisfied` + `computeEvidence` — already accept either as evidence, need to clarify which is active). Today the rep manually types desired length/width/draft into the interest form. If a yacht is linked (yachts carry their own `length_ft / width_ft / draft_ft` per `src/lib/db/schema/yachts.ts:32-34`), those measurements are invisible inside the interest form — the rep has to navigate to the yacht detail page to read them, then transcribe (or estimate desired-dims around) them by hand. The two dim sets are also conceptually different (yacht = the actual boat the client owns; desired = the box the rep is shopping berths for — could legitimately differ if the rep is targeting a smaller/larger slot for upsell, or if the client is yacht-shopping in parallel). Today's recommender uses `desired*` only, ignoring yacht dims even when desired-dims are blank.
|
||||||
|
> - **Three coupled changes:**
|
||||||
|
> - **(a) Display yacht dimensions inside the "Berth size desired" section** when a yacht is linked. New read-only row above the editable Length/Width/Draft inputs showing the linked yacht's measurements as chips (e.g. `Yacht (Fiona III): 58 ft × 16 ft × 5 ft`). Renders empty / hidden when no yacht linked OR yacht has no recorded dims. Honors the existing `desiredUnit` toggle (ft ↔ m) so the chip values switch alongside the inputs.
|
||||||
|
> - **(b) Source-of-truth toggle persisted on the interest** — new column `interests.dimension_source` (`'yacht' | 'desired'`, nullable; null = auto = `'yacht' if yacht linked AND has dims else 'desired'`). UI: segmented control / radio above the dim inputs ("Use yacht dimensions" / "Use desired dimensions") shown only when a yacht is linked AND has dims (otherwise hidden; effective source is implicitly `'desired'`). Selecting "Use yacht dimensions" greys out (doesn't clear) the manual inputs — desired-dim values stay persisted but ignored by downstream consumers. Selecting "Use desired dimensions" re-enables them. Consumers (recommender, qualification evidence display, anywhere else that needs canonical dims) resolve through a new helper `resolveInterestDimensions(interest, yacht?)` that returns `{ source, lengthFt, widthFt, draftFt }` based on `dimension_source`.
|
||||||
|
> - **(c) Recommender panel: view-time toggle (independent of persisted preference)** — _src/components/interests/berth-recommender-panel.tsx_ — at top of panel, a third segmented control: `Recommend for: [Yacht] [Desired] [Both]`. Defaults to the interest's persisted `dimension_source`. Flipping it doesn't update the interest record — it's purely a query parameter for the current panel session, so the rep can explore "what if I shopped against the yacht's actual dims instead of the desired ones?" without committing. `[Both]` runs two queries side by side and shows the union with a tiny `via Yacht` / `via Desired` tag on each row's chip. Panel also surfaces a one-line note like "Recommendations using **yacht** dimensions (58 × 16 × 5 ft) - [switch]" so the rep always knows which lens is active. Recommender service accepts an explicit `inputOverride: { lengthFt, widthFt, draftFt }` param to honor the view-time selection without rereading the persisted preference.
|
||||||
|
> - **Service signature:** `getRecommendations(interestId, { dimensionsOverride?: { lengthFt, widthFt, draftFt }, ...rest })` — when `dimensionsOverride` is set, predicates 437-455 use those numbers; otherwise resolve from `dimension_source` via the new helper. Falls through to whichever is non-null when one set is missing (no yacht → desired; no desired → yacht; neither → unconstrained query, current fall-through behavior).
|
||||||
|
> - **Qualification evidence:** `computeAutoSatisfied` keeps accepting either as evidence for "Dimensions confirmed", but `computeEvidence` updates its label so the rep can see which one drove the tick: `Yacht: 58 × 16 × 5 ft (active source)` vs `Yacht: 58 × 16 × 5 ft (desired-dims active)` etc.
|
||||||
|
> - **Edge cases:**
|
||||||
|
> - Yacht linked but has zero/null dims → toggle hidden, effective source = desired, dim chip shows `Yacht (Fiona III): no recorded dimensions`.
|
||||||
|
> - Yacht unlinked after dim_source='yacht' was selected → effective source flips to 'desired' (the helper has the fall-through). DB value stays 'yacht' so re-linking the yacht restores the original intent.
|
||||||
|
> - Edit-form-only — the create-form chooses default at submit (`'yacht'` if `yachtId` set + yacht-dims-present, else `'desired'`); no need for an explicit picker in create.
|
||||||
|
> - Migration: new column is nullable + null defaults to auto-resolve, so existing rows need no backfill.
|
||||||
|
> - **Effort:** ~4-6h end-to-end. ~30 min schema + migration. ~1h `resolveInterestDimensions` helper + recommender service param. ~1.5h interest-form display + persisted toggle UI. ~1.5h recommender panel view-time toggle. ~30-45 min qualification evidence + tests. Captured 2026-05-24 from UAT.
|
||||||
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
> - **SHIPPED in a7cbee0 (O48):** New POST /api/v1/tracked-links mints redirect-link the rep can drop into outgoing email; body { targetUrl, sendId? }, returns { id, slug, targetUrl, url }, gated on `email.send`. `<TrackedLinkComposerButton>` opens a dialog: paste destination → Create → returns public /q/<slug> URL with Copy + "Insert into message" action. Wired into `<SendDocumentDialog>`'s Message body label row. **[Umami] Tracked-link composer button (Phase 4c UI)** — _src/components/email-composer/_ (find/create) + _src/lib/services/tracked-links.service.ts (already shipped)_ — backend shipped this session: `tracked_links` + `tracked_link_clicks` tables, `/q/[slug]` redirect endpoint, `createTrackedLink` + `buildTrackedUrl` helpers, Umami `link-clicked` cross-post. The missing piece is the rep-facing UI. Recommendation: a "🔗 Tracked link" button inside the sales email composer that takes the currently-selected URL (or prompts for one), calls `createTrackedLink({portId, targetUrl, sendId})`, and inserts the resulting `/q/<slug>` URL in place of the original. Show per-link click stats on the document_sends list (companion to the Bucket 2 open-rate column). Cap: ~3-4 h including the list-side rendering of click stats. Captured 2026-05-19.
|
||||||
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
> - **[Umami] Marketing-site instrumentation (Phase 4a)** — _separate marketing-site repo, NOT this one_ — adds `umami.track('cta-clicked', {…})`, `umami.track('eoi-page-reached')`, etc. calls on the marketing site so the Events tab + cross-system funnels (Phase 3 + Phase 5) light up. Also adds a `do_not_track` opt-out checkbox to the marketing-site cookie banner so visitors who decline tracking get `localStorage.setItem('umami.disabled', '1')` and skip the script entirely. Needs to be coordinated with whoever owns the marketing-site repo — capture the schema we want them to emit (event names + payload shapes) in `docs/marketing-site-event-catalogue.md` once we know which CRM funnels we actually want to drive. ~4-6 h of marketing-repo work + ~2 h of CRM-side cataloguing. Captured 2026-05-19.
|
||||||
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
> - **[Umami] Events tab (Phase 3)** — _src/components/website-analytics/events-list.tsx (new)_ + new route — Umami's `/api/websites/:id/events` is already wrapped in `umami.service.ts` (`getEvents`, `getEventsStats`, `getEventsSeries`). Surface as a new "Events" tab on the analytics page. BLOCKED on Phase 4a — the tab is empty until the marketing site fires custom events. Cap: ~3-4 h once 4a lands. Captured 2026-05-19.
|
||||||
@@ -698,6 +847,42 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
|||||||
|
|
||||||
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
_Functional defects. Tag each with `[critical|high|medium|low]` prefix._
|
||||||
|
|
||||||
|
> - **EntityFolderView (Files section): surface per-row interest badge (berth label) + complete visual overhaul** — _src/components/documents/entity-folder-view.tsx_ + _src/hooks/use-aggregated-listing.ts_ (`AggregatedFile` type extension) + _src/lib/services/documents.service.ts_ (`listFilesAggregatedByEntity`). Today's file rows show only filename + date + optional "View signing details" link. User wants each file row to also indicate **which interest the file is attached to**, ideally as a badge containing the interest's primary berth(s) (mooring number / berth range). Cross-ref: lots of files on a multi-deal client all look identical; reps can't tell which file belongs to which deal at a glance.
|
||||||
|
> - **Fix shape:**
|
||||||
|
> - **(a) Service-side extend `listFilesAggregatedByEntity`** to also return `interestId: string | null` + `berthLabel: string | null` per file (computed via the existing `getAllBerthMooringsForInterests` aggregator + `deriveInterestBerthLabel`). The interest_id link is already snapshotted on `files`; just plumb through.
|
||||||
|
> - **(b) `AggregatedFile` type** gains the two new fields.
|
||||||
|
> - **(c) Render** a small badge next to the filename: when `berthLabel` is set, show `<Badge variant="outline">A1-A3</Badge>` linking to the interest detail. When file is attached but interest has no berths yet, show `<Badge>Interest</Badge>` instead. When file has no interest (general client/company doc), no badge.
|
||||||
|
> - **(d) Bigger visual overhaul** — same surface looks "stale": uniform monospace filenames, no file-type icons, no signed/unsigned visual hierarchy. Add: file-type icon (PDF/image/doc — match FileGrid's icon mapping), signed-state pill ("Signed" / "Sent" / "Draft" inferred from `signedFromDocumentId`), upload-by-user hint when available, tighter row spacing, hover treatment, group counts in the section header.
|
||||||
|
> - **Already SHIPPED this session (partial):** per-row Download button (icon-only, hover-reveal) + file-type icon prefix on filename + cleaner row layout (`h-8` ghost buttons, tabular-nums dates).
|
||||||
|
> - **Effort:** ~2-3h for the remaining work (~1h service+type plumbing for the interest badge, ~1-1.5h visual pass including the workflow section, ~30 min smoke tests). Captured 2026-05-24 from UAT.
|
||||||
|
> - **Sheet dialog feels cramped on wide viewports — needs more horizontal room** — UAT 2026-05-24: user flagged a Sheet (`role="dialog"` + `class="fixed top-0 right-..."`) as too narrow vs. the viewport, without specifying which one. The right-side Sheet primitive (`src/components/ui/sheet.tsx`) defaults to `w-3/4 sm:max-w-sm` per the CRM convention; many domain Sheets override to `sm:max-w-xl` / `sm:max-w-2xl` / `sm:max-w-3xl`. Likely candidates the user might have been looking at: EoiGenerateDialog (`sm:max-w-2xl`), InterestForm Sheet, ClientForm Sheet, ReminderForm Sheet, ContactLog Sheet, audit-detail Sheet (now removed in favor of Popover), the various entity-detail Sheets. **Action when user returns:** ask which Sheet specifically + ship a width bump (likely `max-w-3xl` → `max-w-5xl` + `w-[90vw]` or similar). Captured 2026-05-24 from UAT.
|
||||||
|
> - **[medium] Global-search dropdown appears translucent — table content bleeds through** — _src/components/search/command-search.tsx:321_ — the popover wrapper uses `bg-popover` which resolves to `hsl(0 0% 100%)` (opaque white per `src/app/globals.css:189`), so on paper it should be solid. But UAT 2026-05-24 shows the table behind the dropdown clearly visible through certain rows (RECENTLY VIEWED entries + the row right before RECENT SEARCHES). Possible causes to verify with DevTools: (a) a parent topbar `backdrop-filter` / `mix-blend-mode` is colour-mixing the popover's white into the page; (b) a wrapping element has `opacity` < 1; (c) `bg-popover` is being class-merged out somewhere by `cn()`. **Repro:** open the global search on the Berths page (desktop width), look at the dropdown without typing. Fix: capture the computed background of the popover wrapper in DevTools; if `rgba(.., .., .., < 1)` or `transparent`, find what's overriding `bg-popover` and replace with an explicit `bg-white dark:bg-popover` or `bg-card`. Captured 2026-05-24 from UAT.
|
||||||
|
> - **[critical] External EOI upload doesn't advance pipeline stage from `qualified` (or any new-pipeline pre-EOI stage) — stage-advance list still hard-codes legacy 9-stage names** — _src/lib/services/external-eoi.service.ts:186-190_. The advance list reads `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'` — all legacy 9-stage vocabulary. The 9→7 migration replaced these with the new vocabulary (`lead`, `berth_interest`, `qualified`, `eoi`, `reservation`, `deposit`, `contract`, …). Result: when an interest is at `qualified` (the canonical pre-EOI stage in the new pipeline) and a rep uploads an externally-signed EOI, the document IS filed and `eoiStatus` flips to `signed`, but `pipelineStage` stays at `qualified`. Downstream side effects: the EOI / Reservation / Contract tabs render based on stage-reached gates, so they stay hidden; the milestone strip + activity feed report the deal as still pre-EOI even though the EOI is signed; the pipeline funnel + Pipeline Value tile undercount. Reproduced 2026-05-24 against `interests.id=a79d929e-6af7-4d54-a56e-fe3c94a5e3d8` (Matthew Ciaccio, berths A2/A3/A4) — `pipeline_stage=qualified`, `eoi_status=signed`, `date_eoi_signed=2026-05-24` confirmed via direct DB query post-upload.
|
||||||
|
> - **Fix:** rewrite the advance list against the current vocabulary in `src/lib/db/schema/interests.ts` (pipeline stage enum) — every pre-EOI stage (`lead`, `berth_interest`, `qualified`, `eoi`) should flip to `eoi_signed` (or whichever the post-EOI signed-stage name is in the current vocab); stages at or past signing (`reservation`, `deposit`, `contract`, `won`, `lost`) should stay put. Better: invert the gate — define `PRE_EOI_STAGES = [...] as const` near the pipeline enum and check membership; survives future renames.
|
||||||
|
> - **Audit cousin call sites:** every other service that gates on legacy stage names. Quick grep targets: `'open' | 'details_sent' | 'in_communication' | 'eoi_sent'`, `pipelineStage === '`, anywhere stages are checked by literal. Likely candidates: `documents.service.ts` (auto-deposit chain), `external-signing.service.ts`, the Documenso webhook handlers (`handleDocumentCompleted`, `handleRecipientSigned`), the rules engine (`berth-rules-engine.ts`), the auto-advance code, the deposit-paid handler, the contract-signed handler. **Bundle the audit + fix into one PR** so all legacy stage references die together.
|
||||||
|
> - **Backfill:** existing interests at `qualified` (or other pre-EOI stages) with `eoi_status='signed'` should be retroactively flipped to `eoi_signed`. One-off script — read all interests where `eoi_status='signed' AND pipeline_stage IN ('lead','berth_interest','qualified','eoi')`, set `pipeline_stage='eoi_signed'`, audit-log each as `kind: 'retroactive_stage_alignment'`.
|
||||||
|
> - **Effort:** ~1-1.5h for the external-eoi fix + grep audit + the 4-5 sibling fixes. ~30 min backfill script + dry-run. ~30 min vitest covering the new advance gate across every pre-EOI stage. **Severity: critical** — silent pipeline-state corruption affecting every external-EOI upload from the new-vocab pre-EOI stages. Captured 2026-05-24 from UAT.
|
||||||
|
> - **SHIPPED in this session:**
|
||||||
|
> - `external-eoi.service.ts` advance list rewritten to canonical `enquiry/qualified/nurturing` → `eoi`; target stage corrected from legacy `'eoi_signed'` to canonical `'eoi'`; now also writes `eoiDocStatus='signed'` alongside `eoiStatus='signed'`.
|
||||||
|
> - `public-interest.service.ts:233` + `api/public/interests/route.ts:60` flipped `pipelineStage:'open'` → `'enquiry'` for new public interests.
|
||||||
|
> - `interests.service.ts:1139` legacy `'open'` gate → canonical `'enquiry'`.
|
||||||
|
> - Display fallbacks canonicalized: `dashboard.service.ts:208`, `dashboard-report-data.service.ts:399`, `pdf/templates/interest-summary.tsx:77`, `pdf/templates/client-summary.tsx:134`, `components/interests/interest-picker.tsx:62`, `api/v1/interests/[id]/timeline/route.ts:225-232` — all now route through `canonicalizeStage()` / `stageLabelFor()` instead of falling back to the legacy `'open'` literal.
|
||||||
|
> - `inline-stage-picker.tsx` stale comments referencing `'open'` updated to `enquiry` to match the actual logic.
|
||||||
|
> - Backfill + tests next. NOT YET shipped: rules engine + Documenso webhook handlers' stage references (pending re-audit; if any legacy gates remain there, they'll be folded into the same PR).
|
||||||
|
> - **[medium] Clicking "Upload signed copy" on ActiveEoiCard renders a blank interest detail body + "Unknown Client" header (transient client-side cache race, NOT server-side data loss)** — _src/components/interests/interest-eoi-tab.tsx:200-204_ (`<ExternalEoiUploadDialog open={uploadSignedOpen} ... interestId={interestId} />`) + _src/components/interests/external-eoi-upload-dialog.tsx_ + _src/components/interests/active-eoi-card.tsx_ (the trigger that flips `uploadSignedOpen`). UAT 2026-05-24: on an interest that has an active generated EOI, clicking "Upload signed copy" from ActiveEoiCard caused the entire interest detail body to disappear — only the header (with "Unknown Client" instead of the actual client name) + the tab strip rendered, with no Overview / EOI / etc. content below. Screenshot captured in chat.
|
||||||
|
> - **Symptoms to triage when reproducing:**
|
||||||
|
> - Client name resolved to "Unknown Client" — suggests the interest's parent loader returned a row where `clientName` / `client.fullName` was null. May be pre-existing on this specific test interest (orphaned client FK?) OR may be a side effect of the dialog mount triggering a re-fetch with bad params.
|
||||||
|
> - Whole tab body blank below the tab strip — suggests an error boundary unmounted the tab content. Likely candidates: `ExternalEoiUploadDialog` throws during render when an active EOI already exists (the dialog may not have been built with the "EOI already exists" path in mind — it's typically used for the empty-state "Mark externally signed" entry on EmptyEoiState, not on top of an ActiveEoiCard).
|
||||||
|
> - "2 Issues" badge bottom-left = runtime error counter (Comet / React DevTools).
|
||||||
|
> - **Investigation path:**
|
||||||
|
> - Open browser console + Network tab, repro, capture the error stack and any failed `/api/v1/interests/{id}` request.
|
||||||
|
> - Diff `ExternalEoiUploadDialog`'s expected props vs what `interest-eoi-tab.tsx:200-204` passes — confirm `onSuccess` (or any required prop) isn't accidentally missing on the active-EOI-trigger path.
|
||||||
|
> - Check whether the dialog calls a service that requires an EOI doc to NOT exist (e.g. an early validation in `markExternallySigned` that throws "EOI already exists").
|
||||||
|
> - Verify whether the "Unknown Client" state pre-existed (try the same flow on a different interest with a real client) — if yes, the bug is purely in the dialog/error-boundary chain; if no, the dialog mount IS what's triggering the data loss.
|
||||||
|
> - **Workaround:** none — the upload path is blocked when an active EOI exists. Reps would have to cancel the generated EOI first (manual cancel via the EOI tab's cancel-document flow), then mark externally signed from the empty state.
|
||||||
|
> - **Severity high:** blocks a real workflow ("client signed offline; let's record it on top of the EOI we already generated"). Doesn't lose data but renders the page unusable until refresh.
|
||||||
|
> - **Pairs with:** the related Bucket 2 finding to auto-cancel the generated EOI when an external upload happens — both touch the same "two EOIs at once" workflow gap. Captured 2026-05-24 from UAT.
|
||||||
|
|
||||||
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
|
-1. **[high] BulkAddBerthsWizard side-pontoon dropdown uses a wrong, locally-defined enum (not the canonical / admin-editable vocabulary)** — _src/components/admin/bulk-add-berths-wizard.tsx:42_ — the wizard hard-codes `const SIDE_PONTOON_OPTIONS = ['Port', 'Starboard', 'Bow', 'Stern', '']` (nautical directions). The **actual** canonical list in _src/lib/constants.ts:187_ `BERTH_SIDE_PONTOON_OPTIONS` is: `'No', 'Quay SB', 'Quay PT', 'Quay SB, Yes PT', 'Quay PT, Yes SB', 'Yes SB', 'Yes PT', 'Yes SB, PT', 'Finger SB', 'Finger PT'` — these match the original NocoDB enum + the single-berth edit form + EOI/contract surfaces. Reps using the bulk wizard end up writing `side_pontoon='Port'` / `'Starboard'` etc. to the DB — values that no other surface in the app produces or filters on. Filtering / reporting / search across the same column gives misleading results because the data has two parallel vocabularies.
|
||||||
|
|
||||||
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
|
> - **Additional problem:** the codebase has a full per-port vocabularies system (_src/lib/vocabularies.ts_) where `berth_side_pontoon_options` is registered as admin-editable, with defaults sourced from `BERTH_SIDE_PONTOON_OPTIONS`. The wizard not only uses the wrong list — it bypasses the admin-editability entirely. Even after fixing the values, admins won't be able to tune the list per-port unless the wizard reads through `getVocabulary('berth_side_pontoon_options')` like other surfaces should.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
|
|||||||
yachtId: result.yachtId,
|
yachtId: result.yachtId,
|
||||||
companyId: result.companyId,
|
companyId: result.companyId,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
pipelineStage: 'open',
|
pipelineStage: 'enquiry',
|
||||||
berthId: result.berthId,
|
berthId: result.berthId,
|
||||||
},
|
},
|
||||||
metadata: { type: 'public_registration', ip },
|
metadata: { type: 'public_registration', ip },
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { user, userProfiles } from '@/lib/db/schema/users';
|
|||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { canonicalizeStage, stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
const OUTCOME_LABELS: Record<string, string> = {
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
won: 'Won',
|
won: 'Won',
|
||||||
@@ -219,17 +219,17 @@ export const GET = withAuth(
|
|||||||
// Fallback: when no audit-log entries exist for this interest (typical
|
// Fallback: when no audit-log entries exist for this interest (typical
|
||||||
// for seed/imported data inserted directly into the table without going
|
// for seed/imported data inserted directly into the table without going
|
||||||
// through the service), synthesize a "Created at <stage>" event so the
|
// through the service), synthesize a "Created at <stage>" event so the
|
||||||
// tab isn't empty when the interest is clearly past `open`.
|
// tab isn't empty when the interest is clearly past the initial stage.
|
||||||
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
const hasCreateAudit = allEvents.some((e) => e.action === 'create');
|
||||||
if (!hasCreateAudit) {
|
if (!hasCreateAudit) {
|
||||||
const stage = stageLabel(interest.pipelineStage);
|
const stage = stageLabel(interest.pipelineStage);
|
||||||
const created = interest.createdAt ?? new Date();
|
const created = interest.createdAt ?? new Date();
|
||||||
|
const isInitialStage = canonicalizeStage(interest.pipelineStage) === 'enquiry';
|
||||||
allEvents.push({
|
allEvents.push({
|
||||||
id: `synth-${interest.id}-create`,
|
id: `synth-${interest.id}-create`,
|
||||||
type: 'audit',
|
type: 'audit',
|
||||||
action: 'create',
|
action: 'create',
|
||||||
description:
|
description: isInitialStage ? 'Interest created' : `Interest created at ${stage}`,
|
||||||
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
|
||||||
userId: null,
|
userId: null,
|
||||||
userName: null,
|
userName: null,
|
||||||
createdAt: created,
|
createdAt: created,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { createAuditLog } from '@/lib/audit';
|
|||||||
|
|
||||||
const dashboardConfigSchema = z.object({
|
const dashboardConfigSchema = z.object({
|
||||||
kind: z.literal('dashboard'),
|
kind: z.literal('dashboard'),
|
||||||
widgetIds: z.array(z.string()).min(1).max(20),
|
widgetIds: z.array(z.string()).min(1).max(40),
|
||||||
dateFrom: z
|
dateFrom: z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
|||||||
@@ -22,13 +22,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetDescription,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { AuditLogCard } from './audit-log-card';
|
import { AuditLogCard } from './audit-log-card';
|
||||||
@@ -149,10 +143,10 @@ export function AuditLogList() {
|
|||||||
const [userId, setUserId] = useState('');
|
const [userId, setUserId] = useState('');
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
/** Currently-open audit detail row. Drives the side Sheet that
|
// Per-row detail is surfaced inline via a Popover anchored to the
|
||||||
* exposes the full oldValue / newValue / metadata / IP / UA payload
|
// Details button (see column cell below). Lets the rep inspect the
|
||||||
* so reps can inspect a row without leaving the search list. */
|
// full oldValue / newValue / metadata / IP / UA payload without
|
||||||
const [detailEntry, setDetailEntry] = useState<AuditEntry | null>(null);
|
// leaving the table or opening a Sheet.
|
||||||
|
|
||||||
const debouncedSearch = useDebounced(search);
|
const debouncedSearch = useDebounced(search);
|
||||||
const debouncedUserId = useDebounced(userId);
|
const debouncedUserId = useDebounced(userId);
|
||||||
@@ -368,14 +362,76 @@ export function AuditLogList() {
|
|||||||
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
|
Boolean(e.oldValue) || Boolean(e.newValue) || Boolean(e.metadata) || Boolean(e.userAgent);
|
||||||
if (!hasDetail) return null;
|
if (!hasDetail) return null;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Popover>
|
||||||
variant="ghost"
|
<PopoverTrigger asChild>
|
||||||
size="sm"
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
||||||
className="h-7 px-2 text-xs"
|
Details
|
||||||
onClick={() => setDetailEntry(e)}
|
</Button>
|
||||||
>
|
</PopoverTrigger>
|
||||||
Details
|
<PopoverContent
|
||||||
</Button>
|
align="end"
|
||||||
|
side="bottom"
|
||||||
|
className="w-[420px] max-h-[60vh] overflow-y-auto p-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="font-semibold capitalize">
|
||||||
|
{e.action.replace(/_/g, ' ')} - {e.entityType}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(e.createdAt, 'datetime.medium')}
|
||||||
|
{e.actor ? ` · ${e.actor.name}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{e.oldValue ? (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Old value
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
||||||
|
{JSON.stringify(e.oldValue, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{e.newValue ? (
|
||||||
|
<details open>
|
||||||
|
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
New value
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
||||||
|
{JSON.stringify(e.newValue, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{e.metadata ? (
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Metadata
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-1 max-h-60 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
||||||
|
{JSON.stringify(e.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
{e.ipAddress || e.userAgent ? (
|
||||||
|
<dl className="grid grid-cols-[88px_1fr] gap-x-2 gap-y-1 text-[11px]">
|
||||||
|
{e.ipAddress ? (
|
||||||
|
<>
|
||||||
|
<dt className="font-semibold text-muted-foreground">IP address</dt>
|
||||||
|
<dd className="font-mono">{e.ipAddress}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{e.userAgent ? (
|
||||||
|
<>
|
||||||
|
<dt className="font-semibold text-muted-foreground">User agent</dt>
|
||||||
|
<dd className="font-mono break-all">{e.userAgent}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
size: 80,
|
size: 80,
|
||||||
@@ -391,7 +447,7 @@ export function AuditLogList() {
|
|||||||
variant="gradient"
|
variant="gradient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
<div className="mt-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="audit-search" className="text-xs">
|
<Label htmlFor="audit-search" className="text-xs">
|
||||||
Search
|
Search
|
||||||
@@ -533,7 +589,7 @@ export function AuditLogList() {
|
|||||||
</Label>
|
</Label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
id="audit-from"
|
id="audit-from"
|
||||||
className="w-44 h-9"
|
className="w-52 h-9"
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={setDateFrom}
|
onChange={setDateFrom}
|
||||||
/>
|
/>
|
||||||
@@ -543,7 +599,7 @@ export function AuditLogList() {
|
|||||||
<Label htmlFor="audit-to" className="text-xs">
|
<Label htmlFor="audit-to" className="text-xs">
|
||||||
To
|
To
|
||||||
</Label>
|
</Label>
|
||||||
<DatePicker id="audit-to" className="w-44 h-9" value={dateTo} onChange={setDateTo} />
|
<DatePicker id="audit-to" className="w-52 h-9" value={dateTo} onChange={setDateTo} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* M-AU03: CSV export inherits the current filter set. The
|
{/* M-AU03: CSV export inherits the current filter set. The
|
||||||
@@ -629,73 +685,6 @@ export function AuditLogList() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Sheet open={!!detailEntry} onOpenChange={(o) => !o && setDetailEntry(null)}>
|
|
||||||
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
|
|
||||||
{detailEntry ? (
|
|
||||||
<>
|
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>
|
|
||||||
{detailEntry.action.replace(/_/g, ' ')} - {detailEntry.entityType}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription>
|
|
||||||
{formatDate(detailEntry.createdAt, 'datetime.medium')}
|
|
||||||
{detailEntry.actor ? ` · ${detailEntry.actor.name}` : ''}
|
|
||||||
</SheetDescription>
|
|
||||||
</SheetHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-4 text-sm">
|
|
||||||
{detailEntry.oldValue ? (
|
|
||||||
<details>
|
|
||||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Old value
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
|
||||||
{JSON.stringify(detailEntry.oldValue, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{detailEntry.newValue ? (
|
|
||||||
<details open>
|
|
||||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
New value
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
|
||||||
{JSON.stringify(detailEntry.newValue, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{detailEntry.metadata ? (
|
|
||||||
<details>
|
|
||||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Metadata
|
|
||||||
</summary>
|
|
||||||
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 font-mono text-[11px]">
|
|
||||||
{JSON.stringify(detailEntry.metadata, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
{detailEntry.ipAddress || detailEntry.userAgent ? (
|
|
||||||
<dl className="grid grid-cols-[110px_1fr] gap-x-3 gap-y-1 text-xs">
|
|
||||||
{detailEntry.ipAddress ? (
|
|
||||||
<>
|
|
||||||
<dt className="font-semibold text-muted-foreground">IP address</dt>
|
|
||||||
<dd className="font-mono">{detailEntry.ipAddress}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{detailEntry.userAgent ? (
|
|
||||||
<>
|
|
||||||
<dt className="font-semibold text-muted-foreground">User agent</dt>
|
|
||||||
<dd className="font-mono break-all">{detailEntry.userAgent}</dd>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</dl>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
import { STAGE_BADGE, STAGE_LABELS, safeStage } from '@/components/clients/pipeline-constants';
|
||||||
@@ -39,9 +40,9 @@ function InterestRowItem({
|
|||||||
}) {
|
}) {
|
||||||
const stage = safeStage(interest.pipelineStage);
|
const stage = safeStage(interest.pipelineStage);
|
||||||
|
|
||||||
const berthLabel = interest.berthMooringNumber
|
const berthDisplay =
|
||||||
? `Berth ${interest.berthMooringNumber}`
|
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
|
||||||
: 'General interest';
|
const berthLabel = berthDisplay ? `Berth ${berthDisplay}` : 'General interest';
|
||||||
|
|
||||||
const yachtLabel = interest.yachtName ?? null;
|
const yachtLabel = interest.yachtName ?? null;
|
||||||
|
|
||||||
@@ -200,9 +201,12 @@ function InterestPreviewSheet({
|
|||||||
const reached = (target: PipelineStage) =>
|
const reached = (target: PipelineStage) =>
|
||||||
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
stageIdx !== -1 && PIPELINE_STAGES.indexOf(target) <= stageIdx;
|
||||||
|
|
||||||
|
const showingBerthDisplay = showing
|
||||||
|
? (deriveInterestBerthLabel(showing.berthMoorings) ?? showing.berthMooringNumber)
|
||||||
|
: null;
|
||||||
const berthLabel = showing
|
const berthLabel = showing
|
||||||
? showing.berthMooringNumber
|
? showingBerthDisplay
|
||||||
? `Berth ${showing.berthMooringNumber}`
|
? `Berth ${showingBerthDisplay}`
|
||||||
: 'General interest'
|
: 'General interest'
|
||||||
: '';
|
: '';
|
||||||
const yachtLabel = showing?.yachtName ?? null;
|
const yachtLabel = showing?.yachtName ?? null;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDistanceToNowStrict } from 'date-fns';
|
|||||||
|
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
PIPELINE_STAGES,
|
PIPELINE_STAGES,
|
||||||
@@ -27,6 +28,7 @@ export interface ClientInterestRow {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
dateLastContact: string | null;
|
dateLastContact: string | null;
|
||||||
berthMooringNumber?: string | null;
|
berthMooringNumber?: string | null;
|
||||||
|
berthMoorings?: string[];
|
||||||
yachtName?: string | null;
|
yachtName?: string | null;
|
||||||
/** Requirements surfaced on the Client Overview panel - "Wants L × W × D
|
/** Requirements surfaced on the Client Overview panel - "Wants L × W × D
|
||||||
* · Source" lets reps see what the deal is looking for without drilling
|
* · Source" lets reps see what the deal is looking for without drilling
|
||||||
@@ -184,9 +186,8 @@ function HeroVariant({ clientId, portSlug }: { clientId: string; portSlug: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stage = safeStage(top.pipelineStage);
|
const stage = safeStage(top.pipelineStage);
|
||||||
const berthLabel = top.berthMooringNumber
|
const topBerthDisplay = deriveInterestBerthLabel(top.berthMoorings) ?? top.berthMooringNumber;
|
||||||
? `Berth ${top.berthMooringNumber}`
|
const berthLabel = topBerthDisplay ? `Berth ${topBerthDisplay}` : 'General interest';
|
||||||
: 'General interest';
|
|
||||||
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
const detailsHref = `/${portSlug}/interests/${top.id}` as Route;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -298,9 +299,8 @@ function PanelVariant({ clientId, portSlug }: { clientId: string; portSlug: stri
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sorted.map((i) => {
|
{sorted.map((i) => {
|
||||||
const stage = safeStage(i.pipelineStage);
|
const stage = safeStage(i.pipelineStage);
|
||||||
const berthLabel = i.berthMooringNumber
|
const rowBerthDisplay = deriveInterestBerthLabel(i.berthMoorings) ?? i.berthMooringNumber;
|
||||||
? `Berth ${i.berthMooringNumber}`
|
const berthLabel = rowBerthDisplay ? `Berth ${rowBerthDisplay}` : 'General interest';
|
||||||
: 'General interest';
|
|
||||||
const href = `/${portSlug}/interests/${i.id}` as Route;
|
const href = `/${portSlug}/interests/${i.id}` as Route;
|
||||||
return (
|
return (
|
||||||
<li key={i.id}>
|
<li key={i.id}>
|
||||||
|
|||||||
@@ -13,17 +13,16 @@ interface BerthStatusResponse {
|
|||||||
available: number;
|
available: number;
|
||||||
underOffer: number;
|
underOffer: number;
|
||||||
sold: number;
|
sold: number;
|
||||||
maintenance: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brand-aligned palette. Order matches the legend reading order
|
// Brand-aligned palette. Order matches the legend reading order
|
||||||
// (positive → in-progress → closed → exception).
|
// (positive → in-progress → closed). Mirrors BERTH_STATUSES in
|
||||||
|
// src/lib/constants.ts (canonical 3-status enum).
|
||||||
const SEGMENTS = [
|
const SEGMENTS = [
|
||||||
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
|
{ key: 'available', label: 'Available', color: 'hsl(213 55% 56%)' },
|
||||||
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
|
{ key: 'underOffer', label: 'Under offer', color: 'hsl(38 92% 50%)' },
|
||||||
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
|
{ key: 'sold', label: 'Sold', color: 'hsl(142 70% 40%)' },
|
||||||
{ key: 'maintenance', label: 'Maintenance', color: 'hsl(228 10% 60%)' },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function DashboardShell({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<DateRangePicker value={range} onChange={setRange} />
|
<DateRangePicker value={range} onChange={setRange} />
|
||||||
<CustomizeWidgetsMenu />
|
<CustomizeWidgetsMenu />
|
||||||
<ExportDashboardPdfButton />
|
<ExportDashboardPdfButton initialRange={range} />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronDown, ChevronRight, FileText, Plus, Upload } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -236,6 +236,10 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
key={selectedFolderId ?? 'root'}
|
key={selectedFolderId ?? 'root'}
|
||||||
portSlug={portSlug}
|
portSlug={portSlug}
|
||||||
folderId={selectedFolderId}
|
folderId={selectedFolderId}
|
||||||
|
childFolders={
|
||||||
|
typeof selectedFolderId === 'string' ? (selectedFolder?.children ?? []) : (tree ?? [])
|
||||||
|
}
|
||||||
|
onFolderSelect={handleFolderSelect}
|
||||||
/>
|
/>
|
||||||
</FolderDropZone>
|
</FolderDropZone>
|
||||||
)}
|
)}
|
||||||
@@ -297,9 +301,19 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
interface FlatFolderListingProps {
|
interface FlatFolderListingProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
folderId: string | null;
|
folderId: string | null;
|
||||||
|
/** Direct children of the currently-viewed folder. Rendered above the
|
||||||
|
* document list as clickable cards so the rep can drill into subfolders
|
||||||
|
* from the main content area, not only the sidebar tree. */
|
||||||
|
childFolders?: FolderNode[];
|
||||||
|
onFolderSelect?: (id: string | null | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
function FlatFolderListing({
|
||||||
|
portSlug,
|
||||||
|
folderId,
|
||||||
|
childFolders = [],
|
||||||
|
onFolderSelect,
|
||||||
|
}: FlatFolderListingProps) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
const [typeFilter, setTypeFilter] = useState<string | undefined>(undefined);
|
||||||
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
const [expandedDocId, setExpandedDocId] = useState<string | null>(null);
|
||||||
@@ -444,13 +458,38 @@ function FlatFolderListing({ portSlug, folderId }: FlatFolderListingProps) {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{childFolders.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1.5">
|
||||||
|
Subfolders
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||||
|
{childFolders.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={sub.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onFolderSelect?.(sub.id)}
|
||||||
|
className="flex items-center gap-2 rounded-md border bg-white px-3 py-2 text-sm text-left transition-colors hover:border-primary/50 hover:bg-accent/30"
|
||||||
|
title={sub.name}
|
||||||
|
>
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="truncate font-medium">{sub.name}</span>
|
||||||
|
{sub.systemManaged ? (
|
||||||
|
<Lock className="ml-auto h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ul className="rounded-md border bg-white">
|
<ul className="rounded-md border bg-white">
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
{[0, 1, 2, 3, 4].map((i) => (
|
||||||
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
<li key={i} className="h-12 animate-pulse border-b last:border-b-0 bg-muted/40" />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : documents.length === 0 ? (
|
) : documents.length === 0 && childFolders.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<FileText className="h-7 w-7" aria-hidden />}
|
icon={<FileText className="h-7 w-7" aria-hidden />}
|
||||||
title="No documents in this folder"
|
title="No documents in this folder"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ClipboardSignature, FileText, Eye } from 'lucide-react';
|
import { ClipboardSignature, FileText, Eye, Download } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AggregatedSection } from './aggregated-section';
|
import { AggregatedSection } from './aggregated-section';
|
||||||
@@ -10,6 +10,9 @@ import { SigningDetailsDialog } from './signing-details-dialog';
|
|||||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||||
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
|
import { useAggregatedFiles, useAggregatedWorkflows } from '@/hooks/use-aggregated-listing';
|
||||||
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
import { triggerUrlDownload } from '@/lib/utils/download';
|
||||||
import type {
|
import type {
|
||||||
AggregatedFile,
|
AggregatedFile,
|
||||||
AggregatedGroup,
|
AggregatedGroup,
|
||||||
@@ -34,6 +37,17 @@ function mapWorkflowStatus(status: string): StatusPillStatus {
|
|||||||
return known[status] ?? 'pending';
|
return known[status] ?? 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleFileDownload(fileId: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ data: { url: string; filename: string } }>(
|
||||||
|
`/api/v1/files/${fileId}/download`,
|
||||||
|
);
|
||||||
|
triggerUrlDownload(res.data.url, res.data.filename);
|
||||||
|
} catch (err) {
|
||||||
|
toastError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
|
export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
|
||||||
const [detailsId, setDetailsId] = useState<string | null>(null);
|
const [detailsId, setDetailsId] = useState<string | null>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<{
|
const [previewFile, setPreviewFile] = useState<{
|
||||||
@@ -81,28 +95,49 @@ export function EntityFolderView({ portSlug, entityType, entityId }: Props) {
|
|||||||
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
|
renderRow={(f: AggregatedFile, _group: AggregatedGroup<AggregatedFile>) => {
|
||||||
const signedFromDocumentId = f.signedFromDocumentId;
|
const signedFromDocumentId = f.signedFromDocumentId;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="group flex items-center justify-between gap-3 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="truncate text-left hover:text-brand hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
|
className="flex min-w-0 flex-1 items-center gap-2 text-left hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 rounded-sm"
|
||||||
onClick={() => setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })}
|
onClick={() => setPreviewFile({ id: f.id, name: f.filename, mimeType: f.mimeType })}
|
||||||
aria-label={`Preview ${f.filename}`}
|
aria-label={`Preview ${f.filename}`}
|
||||||
>
|
>
|
||||||
{f.filename}
|
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="truncate group-hover:underline">{f.filename}</span>
|
||||||
|
{f.interestBerthLabel && f.interestId ? (
|
||||||
|
<Link
|
||||||
|
href={`/${portSlug}/interests/${f.interestId}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="ml-1 inline-flex shrink-0 items-center rounded-full border bg-muted/40 px-1.5 py-px text-[10px] font-medium text-muted-foreground hover:border-primary/40 hover:bg-accent/40 hover:text-foreground"
|
||||||
|
title={`Linked to interest ${f.interestBerthLabel}`}
|
||||||
|
>
|
||||||
|
{f.interestBerthLabel}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground tabular-nums shrink-0">
|
||||||
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
<span>{new Date(f.createdAt).toLocaleDateString('en-GB')}</span>
|
||||||
{signedFromDocumentId ? (
|
{signedFromDocumentId ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-h-[44px] gap-1 px-2 text-xs text-brand"
|
className="h-8 gap-1 px-2 text-xs text-brand"
|
||||||
onClick={() => setDetailsId(signedFromDocumentId)}
|
onClick={() => setDetailsId(signedFromDocumentId)}
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3" aria-hidden />
|
<Eye className="h-3 w-3" aria-hidden />
|
||||||
View signing details
|
View signing details
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
|
||||||
|
onClick={() => void handleFileDownload(f.id)}
|
||||||
|
aria-label={`Download ${f.filename}`}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useUIStore } from '@/stores/ui-store';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
import { YachtForm } from '@/components/yachts/yacht-form';
|
import { YachtForm } from '@/components/yachts/yacht-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
const DOCUMENSO_TEMPLATE_VALUE = 'documenso-template';
|
||||||
@@ -576,6 +577,9 @@ export function EoiGenerateDialog({
|
|||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'timeline'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }),
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId, 'berths'] }),
|
||||||
]);
|
]);
|
||||||
|
toast.success(
|
||||||
|
isDocumenso ? 'EOI generated and sent for signature.' : 'EOI generated. Ready to send.',
|
||||||
|
);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
|
||||||
@@ -767,12 +771,25 @@ export function EoiGenerateDialog({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
<label
|
||||||
|
className={
|
||||||
|
link.isPrimary
|
||||||
|
? 'flex items-center gap-1.5 text-xs cursor-not-allowed opacity-70'
|
||||||
|
: 'flex items-center gap-1.5 text-xs cursor-pointer'
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
link.isPrimary
|
||||||
|
? 'Primary berth is always included in the EOI bundle.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={draft.isInEoiBundle}
|
checked={link.isPrimary ? true : draft.isInEoiBundle}
|
||||||
onCheckedChange={(v) =>
|
disabled={link.isPrimary}
|
||||||
setBerthFlag(link.berthId, 'isInEoiBundle', v === true)
|
onCheckedChange={(v) => {
|
||||||
}
|
if (link.isPrimary) return;
|
||||||
|
setBerthFlag(link.berthId, 'isInEoiBundle', v === true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span>In EOI</span>
|
<span>In EOI</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
return parts.join(' - ');
|
return parts.join(' - ');
|
||||||
}, [interestData, berthsData, signedAt]);
|
}, [interestData, berthsData, signedAt]);
|
||||||
|
|
||||||
|
// The title input is controlled with `displayTitle` (derived from
|
||||||
|
// either the rep's typed value or the auto-derived default). Reps
|
||||||
|
// were treating the empty field as "this needs me to type something"
|
||||||
|
// even though the placeholder showed a sensible default - now the
|
||||||
|
// default is visible inside the input itself. Typing replaces the
|
||||||
|
// default; clearing the field re-shows it.
|
||||||
|
const displayTitle = title || defaultTitle;
|
||||||
|
|
||||||
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
|
const mutation = useMutation<{ data?: { stageChanged?: boolean } }, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!file) throw new Error('No file selected');
|
if (!file) throw new Error('No file selected');
|
||||||
@@ -199,13 +207,13 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc
|
|||||||
<div>
|
<div>
|
||||||
<Label>Title (optional)</Label>
|
<Label>Title (optional)</Label>
|
||||||
<Input
|
<Input
|
||||||
value={title}
|
value={displayTitle}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder={defaultTitle}
|
placeholder={defaultTitle}
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Leave blank to use the default shown above.
|
Edit the auto-filled title or clear it to restore the default.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ export function InlineStagePicker({
|
|||||||
// interest's history, accessible via the activity timeline.
|
// interest's history, accessible via the activity timeline.
|
||||||
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
|
const [overrideTarget, setOverrideTarget] = useState<PipelineStage | null>(null);
|
||||||
const [overrideReason, setOverrideReason] = useState('');
|
const [overrideReason, setOverrideReason] = useState('');
|
||||||
// When dropping the stage back to 'open' on an interest with linked
|
// When dropping the stage back to enquiry on an interest with linked
|
||||||
// berths, prompt the rep whether to keep or unlink them. Going back to
|
// berths, prompt the rep whether to keep or unlink them. Going back to
|
||||||
// open usually means restarting the lead, so the berth association is
|
// enquiry usually means restarting the lead, so the berth association
|
||||||
// often stale; offering a one-tap unlink prevents the public-map +
|
// is often stale; offering a one-tap unlink prevents the public-map +
|
||||||
// recommender from showing the berths as "under offer" for a dead deal.
|
// recommender from showing the berths as "under offer" for a dead deal.
|
||||||
const [openConfirmTarget, setOpenConfirmTarget] = useState<PipelineStage | null>(null);
|
const [openConfirmTarget, setOpenConfirmTarget] = useState<PipelineStage | null>(null);
|
||||||
const [unlinking, setUnlinking] = useState(false);
|
const [unlinking, setUnlinking] = useState(false);
|
||||||
@@ -97,7 +97,7 @@ export function InlineStagePicker({
|
|||||||
const stage = safeStage(currentStage);
|
const stage = safeStage(currentStage);
|
||||||
|
|
||||||
// Fetch the linked-berth list lazily so we know whether to surface the
|
// Fetch the linked-berth list lazily so we know whether to surface the
|
||||||
// unlink-prompt when the rep drops the stage back to 'open'.
|
// unlink-prompt when the rep drops the stage back to enquiry.
|
||||||
const { data: linkedBerths } = useQuery<{ data: Array<{ berthId: string }> }>({
|
const { data: linkedBerths } = useQuery<{ data: Array<{ berthId: string }> }>({
|
||||||
queryKey: ['interest-berths', interestId, 'count-only'],
|
queryKey: ['interest-berths', interestId, 'count-only'],
|
||||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/berths`),
|
||||||
|
|||||||
@@ -80,11 +80,26 @@ export function InterestBerthStatusBanner({
|
|||||||
|
|
||||||
if (conflicts.length === 0) return null;
|
if (conflicts.length === 0) return null;
|
||||||
|
|
||||||
const lines = conflicts.map((b, idx) => {
|
// Wait for every per-berth competing-interest query to finish before
|
||||||
const q = competingQueries[idx];
|
// committing to a count - otherwise the banner briefly reads "3 berths"
|
||||||
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
// and then shrinks to "1 berth" as queries land.
|
||||||
return { berth: b, competing };
|
const allCompetingLoaded = competingQueries.every((q) => !q.isLoading);
|
||||||
});
|
if (!allCompetingLoaded) return null;
|
||||||
|
|
||||||
|
// A berth's status is 'under_offer' or 'sold' if ANY active interest -
|
||||||
|
// including this one - flagged it as is_specific_interest. When this
|
||||||
|
// interest is the only deal touching the berth, the conflict is
|
||||||
|
// self-caused and shouldn't fire the banner: filter to berths where at
|
||||||
|
// least one OTHER interest is in play.
|
||||||
|
const lines = conflicts
|
||||||
|
.map((b, idx) => {
|
||||||
|
const q = competingQueries[idx];
|
||||||
|
const competing = (q?.data ?? []).find((c) => c.isPrimary) ?? (q?.data ?? [])[0] ?? null;
|
||||||
|
return { berth: b, competing };
|
||||||
|
})
|
||||||
|
.filter((l) => l.competing !== null);
|
||||||
|
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -100,24 +115,22 @@ export function InterestBerthStatusBanner({
|
|||||||
} to another deal.`
|
} to another deal.`
|
||||||
: `${lines.length} linked berths are no longer freely available.`}
|
: `${lines.length} linked berths are no longer freely available.`}
|
||||||
</p>
|
</p>
|
||||||
{lines.some((l) => l.competing) ? (
|
<ul className="mt-1 space-y-0.5">
|
||||||
<ul className="mt-1 space-y-0.5">
|
{lines.map(({ berth, competing }) =>
|
||||||
{lines.map(({ berth, competing }) =>
|
competing ? (
|
||||||
competing ? (
|
<li key={berth.id} className="text-rose-900">
|
||||||
<li key={berth.id} className="text-rose-900">
|
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
||||||
<span className="font-medium">{berth.mooringNumber}:</span>{' '}
|
<Link
|
||||||
<Link
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
||||||
href={`/${portSlug}/interests/${competing.interestId}` as any}
|
className="underline-offset-2 hover:underline"
|
||||||
className="underline-offset-2 hover:underline"
|
>
|
||||||
>
|
{competing.clientName}
|
||||||
{competing.clientName}
|
</Link>
|
||||||
</Link>
|
</li>
|
||||||
</li>
|
) : null,
|
||||||
) : null,
|
)}
|
||||||
)}
|
</ul>
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
<p className="mt-0.5 text-rose-800">
|
<p className="mt-0.5 text-rose-800">
|
||||||
You can still progress this interest as a backup, but the rep on the other deal owns the
|
You can still progress this interest as a backup, but the rep on the other deal owns the
|
||||||
primary path. If their deal falls through, this one can step in.
|
primary path. If their deal falls through, this one can step in.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
formatSource,
|
formatSource,
|
||||||
} from '@/lib/constants';
|
} from '@/lib/constants';
|
||||||
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
import { computeUrgencyBadges } from '@/components/interests/urgency';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import type { InterestRow } from './interest-columns';
|
import type { InterestRow } from './interest-columns';
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
@@ -52,7 +53,8 @@ export function InterestCard({ interest, portSlug, onEdit, onArchive }: Interest
|
|||||||
const urgencyBadges = computeUrgencyBadges(interest);
|
const urgencyBadges = computeUrgencyBadges(interest);
|
||||||
|
|
||||||
const clientName = interest.clientName ?? 'Unknown client';
|
const clientName = interest.clientName ?? 'Unknown client';
|
||||||
const berthLabel = interest.berthMooringNumber;
|
const berthLabel =
|
||||||
|
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
|
||||||
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
const lastIso = interest.dateLastContact ?? interest.updatedAt ?? null;
|
||||||
const lastActivity = lastIso
|
const lastActivity = lastIso
|
||||||
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
? formatDistanceToNowStrict(new Date(lastIso), { addSuffix: true })
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
import { stageBadgeClass, stageLabel } from '@/lib/constants';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
import { computeUrgencyBadges, type InterestUrgencyInput } from '@/components/interests/urgency';
|
||||||
|
|
||||||
export interface InterestRow {
|
export interface InterestRow {
|
||||||
@@ -24,6 +25,10 @@ export interface InterestRow {
|
|||||||
yachtName?: string | null;
|
yachtName?: string | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring (sorted) - drives the multi-berth list
|
||||||
|
* cell label (`A1-A3, B5`). Falls back to `berthMooringNumber` alone
|
||||||
|
* when empty/absent. */
|
||||||
|
berthMoorings?: string[];
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
@@ -172,7 +177,9 @@ export function getInterestColumns({
|
|||||||
accessorKey: 'berthMooringNumber',
|
accessorKey: 'berthMooringNumber',
|
||||||
header: 'Berth',
|
header: 'Berth',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (!row.original.berthId || !row.original.berthMooringNumber) {
|
const label =
|
||||||
|
deriveInterestBerthLabel(row.original.berthMoorings) ?? row.original.berthMooringNumber;
|
||||||
|
if (!row.original.berthId || !label) {
|
||||||
return <span className="text-muted-foreground">-</span>;
|
return <span className="text-muted-foreground">-</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -181,7 +188,7 @@ export function getInterestColumns({
|
|||||||
className="text-primary hover:underline text-sm"
|
className="text-primary hover:underline text-sm"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{row.original.berthMooringNumber}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { MultiEoiChip } from '@/components/interests/multi-eoi-chip';
|
|||||||
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
import { DealPulseChip } from '@/components/interests/deal-pulse-chip';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { formatOutcome } from '@/lib/constants';
|
import { formatOutcome } from '@/lib/constants';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
||||||
@@ -85,6 +86,10 @@ interface InterestDetailHeaderProps {
|
|||||||
activeReminderCount?: number;
|
activeReminderCount?: number;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring (sorted) - drives the multi-berth
|
||||||
|
* header label (`Berth A1-A3, B5`). When absent or empty, falls back
|
||||||
|
* to the primary mooring alone. */
|
||||||
|
berthMoorings?: string[];
|
||||||
yachtId: string | null;
|
yachtId: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
@@ -184,8 +189,14 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Multi-berth header label: prefer the full berth-range derived from
|
||||||
|
// every linked mooring, fall back to the primary alone for older
|
||||||
|
// payloads that haven't been re-fetched yet.
|
||||||
|
const berthDisplayLabel =
|
||||||
|
deriveInterestBerthLabel(interest.berthMoorings) ?? interest.berthMooringNumber;
|
||||||
|
|
||||||
const meta: Array<{ key: string; node: React.ReactNode }> = [];
|
const meta: Array<{ key: string; node: React.ReactNode }> = [];
|
||||||
if (interest.berthMooringNumber) {
|
if (berthDisplayLabel) {
|
||||||
meta.push({
|
meta.push({
|
||||||
key: 'berth',
|
key: 'berth',
|
||||||
node: (
|
node: (
|
||||||
@@ -193,7 +204,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
href={`/${portSlug}/berths/${interest.berthId}`}
|
href={`/${portSlug}/berths/${interest.berthId}`}
|
||||||
className="text-foreground hover:underline"
|
className="text-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Berth {interest.berthMooringNumber}
|
Berth {berthDisplayLabel}
|
||||||
</Link>
|
</Link>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
|
|||||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||||
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
|
|
||||||
interface InterestData {
|
interface InterestData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,6 +45,10 @@ interface InterestData {
|
|||||||
} | null;
|
} | null;
|
||||||
berthId: string | null;
|
berthId: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring (sorted). Drives the multi-berth label
|
||||||
|
* rendered in the breadcrumb + header. Falls back to the primary
|
||||||
|
* mooring alone when empty/absent. */
|
||||||
|
berthMoorings?: string[];
|
||||||
/** Linked yacht - null until the rep ties one to the deal. Required to
|
/** Linked yacht - null until the rep ties one to the deal. Required to
|
||||||
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
|
* leave Enquiry; surfaced inline in the stage picker as a prereq. */
|
||||||
yachtId: string | null;
|
yachtId: string | null;
|
||||||
@@ -132,7 +137,8 @@ export function InterestDetail({ interestId, currentUserId }: InterestDetailProp
|
|||||||
data.clientId && data.clientName
|
data.clientId && data.clientName
|
||||||
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
|
? [{ label: data.clientName, href: `/${portSlug}/clients/${data.clientId}` }]
|
||||||
: [],
|
: [],
|
||||||
current: data.berthMooringNumber ?? 'Interest',
|
current:
|
||||||
|
deriveInterestBerthLabel(data.berthMoorings) ?? data.berthMooringNumber ?? 'Interest',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ export function InterestList() {
|
|||||||
} = usePaginatedQuery<InterestRow>({
|
} = usePaginatedQuery<InterestRow>({
|
||||||
queryKey: ['interests'],
|
queryKey: ['interests'],
|
||||||
endpoint: '/api/v1/interests',
|
endpoint: '/api/v1/interests',
|
||||||
|
// Surface the active sort visibly on the column header. The API
|
||||||
|
// already defaults to updatedAt desc when no sort param is sent, but
|
||||||
|
// without this the table renders with no active-sort indicator and
|
||||||
|
// the rep can't tell what ordering is in play. Newly added / edited
|
||||||
|
// deals bubble to the top of the list - the most useful default for
|
||||||
|
// triage.
|
||||||
|
initialSort: { field: 'updatedAt', direction: 'desc' },
|
||||||
filterDefinitions: interestFilterDefinitions,
|
filterDefinitions: interestFilterDefinitions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { useDebounce } from '@/hooks/use-debounce';
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { stageLabelFor } from '@/lib/constants';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface InterestOption {
|
interface InterestOption {
|
||||||
@@ -59,7 +60,7 @@ export function InterestPicker({
|
|||||||
if (!value) return placeholder;
|
if (!value) return placeholder;
|
||||||
const match = options.find((o) => o.id === value);
|
const match = options.find((o) => o.id === value);
|
||||||
if (!match) return `Interest ${value.slice(0, 8)}`;
|
if (!match) return `Interest ${value.slice(0, 8)}`;
|
||||||
if (match.clientName) return `${match.clientName} - ${match.pipelineStage ?? 'open'}`;
|
if (match.clientName) return `${match.clientName} - ${stageLabelFor(match.pipelineStage)}`;
|
||||||
return `Interest ${match.id.slice(0, 8)}`;
|
return `Interest ${match.id.slice(0, 8)}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface InterestRow {
|
|||||||
id: string;
|
id: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
berthMoorings?: string[];
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import { CSS } from '@dnd-kit/utilities';
|
|||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
|
|
||||||
interface PipelineCardProps {
|
interface PipelineCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring (sorted) - drives the multi-berth label.
|
||||||
|
* Falls back to `berthMooringNumber` alone when empty / absent. */
|
||||||
|
berthMoorings?: string[];
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
updatedAt: string | Date;
|
updatedAt: string | Date;
|
||||||
}
|
}
|
||||||
@@ -24,6 +28,7 @@ export function PipelineCard({
|
|||||||
id,
|
id,
|
||||||
clientName,
|
clientName,
|
||||||
berthMooringNumber,
|
berthMooringNumber,
|
||||||
|
berthMoorings,
|
||||||
leadCategory,
|
leadCategory,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
}: PipelineCardProps) {
|
}: PipelineCardProps) {
|
||||||
@@ -38,6 +43,7 @@ export function PipelineCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
|
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
|
||||||
|
const berthLabel = deriveInterestBerthLabel(berthMoorings) ?? berthMooringNumber;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -49,9 +55,7 @@ export function PipelineCard({
|
|||||||
>
|
>
|
||||||
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
|
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
|
||||||
|
|
||||||
{berthMooringNumber && (
|
{berthLabel && <p className="text-xs text-muted-foreground">Berth: {berthLabel}</p>}
|
||||||
<p className="text-xs text-muted-foreground">Berth: {berthMooringNumber}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{leadCategory && (
|
{leadCategory && (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ColumnItem {
|
|||||||
id: string;
|
id: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
berthMoorings?: string[];
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
updatedAt: string | Date;
|
updatedAt: string | Date;
|
||||||
}
|
}
|
||||||
@@ -45,6 +46,7 @@ export function PipelineColumn({ stage, label, items }: PipelineColumnProps) {
|
|||||||
id={item.id}
|
id={item.id}
|
||||||
clientName={item.clientName}
|
clientName={item.clientName}
|
||||||
berthMooringNumber={item.berthMooringNumber}
|
berthMooringNumber={item.berthMooringNumber}
|
||||||
|
berthMoorings={item.berthMoorings}
|
||||||
leadCategory={item.leadCategory}
|
leadCategory={item.leadCategory}
|
||||||
updatedAt={item.updatedAt}
|
updatedAt={item.updatedAt}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { triggerBlobDownload } from '@/lib/utils/download';
|
import { triggerBlobDownload } from '@/lib/utils/download';
|
||||||
import { usePermissions } from '@/hooks/use-permissions';
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
import { resolvePortIdFromSlug } from '@/lib/api/client';
|
||||||
|
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
|
||||||
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
import { SavedTemplatesPicker, type SavedTemplate } from './saved-templates-picker';
|
||||||
import { PdfPreviewModal } from './pdf-preview-modal';
|
import { PdfPreviewModal } from './pdf-preview-modal';
|
||||||
|
|
||||||
@@ -51,22 +52,40 @@ function toIsoLocal(d: Date): string {
|
|||||||
* Permission-gated client-side on `reports.export`; the server route
|
* Permission-gated client-side on `reports.export`; the server route
|
||||||
* re-checks via withPermission so a tampered client can't bypass.
|
* re-checks via withPermission so a tampered client can't bypass.
|
||||||
*/
|
*/
|
||||||
export function ExportDashboardPdfButton({ className }: { className?: string } = {}) {
|
export function ExportDashboardPdfButton({
|
||||||
|
className,
|
||||||
|
initialRange,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
/** The dashboard's currently-active range. When supplied, drives the
|
||||||
|
* dialog's initial dateFrom / dateTo so the rep doesn't re-pick a
|
||||||
|
* range they just chose on the dashboard. Falls back to last 30 days
|
||||||
|
* when omitted (still useful for ad-hoc reports). */
|
||||||
|
initialRange?: DateRange;
|
||||||
|
} = {}) {
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`);
|
const [title, setTitle] = useState(`Report - ${new Date().toLocaleDateString('en-GB')}`);
|
||||||
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
const [selected, setSelected] = useState<PdfDashboardWidgetId[]>(
|
||||||
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
PDF_DASHBOARD_WIDGETS.map((w) => w.id),
|
||||||
);
|
);
|
||||||
// Default report window = last 30 days. Many of the new widgets
|
// Default report window: honour the dashboard's active range when one
|
||||||
// (period cohorts, occupancy timeline) require the window;
|
// was passed in (rep already chose a window upstream); otherwise default
|
||||||
// populating with sensible defaults means the rep gets a useful
|
// to last 30 days. Period-cohort + occupancy-timeline widgets require
|
||||||
// report on first export without picking dates.
|
// the window, so populating with sensible defaults means the rep gets a
|
||||||
const today = new Date();
|
// useful report on first export without re-picking dates.
|
||||||
const last30 = new Date(today);
|
const initialBounds = (() => {
|
||||||
last30.setDate(last30.getDate() - 30);
|
if (initialRange) {
|
||||||
const [dateFrom, setDateFrom] = useState(toIsoLocal(last30));
|
const { from, to } = rangeToBounds(initialRange);
|
||||||
const [dateTo, setDateTo] = useState(toIsoLocal(today));
|
return { from: toIsoLocal(from), to: toIsoLocal(to) };
|
||||||
|
}
|
||||||
|
const today = new Date();
|
||||||
|
const last30 = new Date(today);
|
||||||
|
last30.setDate(last30.getDate() - 30);
|
||||||
|
return { from: toIsoLocal(last30), to: toIsoLocal(today) };
|
||||||
|
})();
|
||||||
|
const [dateFrom, setDateFrom] = useState(initialBounds.from);
|
||||||
|
const [dateTo, setDateTo] = useState(initialBounds.to);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [previewOpen, setPreviewOpen] = useState(false);
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
@@ -279,12 +298,12 @@ export function ExportDashboardPdfButton({ className }: { className?: string } =
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium">{w.label}</span>
|
<span className="font-medium">{w.label}</span>
|
||||||
{w.isChart ? (
|
{w.isChart ? (
|
||||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-primary">
|
<span className="shrink-0 whitespace-nowrap rounded-full bg-primary/10 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-primary">
|
||||||
chart
|
chart
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{w.requiresPeriod ? (
|
{w.requiresPeriod ? (
|
||||||
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-800">
|
<span className="shrink-0 whitespace-nowrap rounded-full bg-amber-100 px-1.5 py-px text-[8px] font-medium uppercase tracking-wide leading-none text-amber-800">
|
||||||
needs date range
|
needs date range
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
import { useDebounce } from '@/hooks/use-debounce';
|
import { useDebounce } from '@/hooks/use-debounce';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface InterestOption {
|
interface InterestOption {
|
||||||
id: string;
|
id: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring (sorted). Drives the multi-berth label
|
||||||
|
* rendered in the picker option row. */
|
||||||
|
berthMoorings?: string[];
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +75,8 @@ export function InterestPicker({
|
|||||||
|
|
||||||
const labelFor = (o: InterestOption) => {
|
const labelFor = (o: InterestOption) => {
|
||||||
const parts = [o.clientName ?? 'Unknown client'];
|
const parts = [o.clientName ?? 'Unknown client'];
|
||||||
if (o.berthMooringNumber) parts.push(`Berth ${o.berthMooringNumber}`);
|
const berthLabel = deriveInterestBerthLabel(o.berthMoorings) ?? o.berthMooringNumber;
|
||||||
|
if (berthLabel) parts.push(`Berth ${berthLabel}`);
|
||||||
parts.push(stageLabel(o.pipelineStage));
|
parts.push(stageLabel(o.pipelineStage));
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ export function SessionsList({ range }: Props) {
|
|||||||
const pageSize = 15;
|
const pageSize = 15;
|
||||||
const query = useUmamiSessions(range, { page, pageSize });
|
const query = useUmamiSessions(range, { page, pageSize });
|
||||||
|
|
||||||
const sessions = query.data?.data?.data ?? [];
|
const rawSessions = query.data?.data?.data ?? [];
|
||||||
|
// Umami's /sessions page isn't reliably ordered by activity timestamp -
|
||||||
|
// sort by most-recent activity (lastAt) descending so the row at the top
|
||||||
|
// is genuinely the latest session, matching the displayed timestamp.
|
||||||
|
const sessions = [...rawSessions].sort((a, b) => {
|
||||||
|
const la = a.lastAt ? new Date(a.lastAt).getTime() : 0;
|
||||||
|
const lb = b.lastAt ? new Date(b.lastAt).getTime() : 0;
|
||||||
|
return lb - la;
|
||||||
|
});
|
||||||
const total = query.data?.data?.count ?? 0;
|
const total = query.data?.data?.count ?? 0;
|
||||||
const hasMore = page * pageSize < total;
|
const hasMore = page * pageSize < total;
|
||||||
|
|
||||||
@@ -83,7 +91,7 @@ export function SessionsList({ range }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
|
{s.browser} · {s.os} · {fmtTime(s.lastAt ?? s.firstAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, type ReactNode } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
|
import { Globe, Info, Settings, ExternalLink } from 'lucide-react';
|
||||||
@@ -292,11 +291,7 @@ export function WebsiteAnalyticsShell() {
|
|||||||
rows={allCountries.data?.data ?? null}
|
rows={allCountries.data?.data ?? null}
|
||||||
loading={allCountries.isLoading}
|
loading={allCountries.isLoading}
|
||||||
onCountryClick={(iso2) => {
|
onCountryClick={(iso2) => {
|
||||||
const url = `/${portSlug}/clients?nationality=${encodeURIComponent(iso2)}`;
|
router.push(`/${portSlug}/clients?nationality=${encodeURIComponent(iso2)}` as never);
|
||||||
void navigator.clipboard?.writeText(window.location.origin + url);
|
|
||||||
toast.message(`${iso2} - link copied`, {
|
|
||||||
description: `Paste into the address bar to see all ${iso2} clients.`,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { YachtOwnershipHistory } from '@/components/yachts/yacht-ownership-histo
|
|||||||
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
|
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
|
|
||||||
type YachtPatchField =
|
type YachtPatchField =
|
||||||
| 'name'
|
| 'name'
|
||||||
@@ -287,6 +288,7 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
|||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
berthMoorings?: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>;
|
}>;
|
||||||
}>({
|
}>({
|
||||||
@@ -320,11 +322,12 @@ function YachtInterestsTab({ yachtId }: { yachtId: string }) {
|
|||||||
{stageLabel(i.pipelineStage)}
|
{stageLabel(i.pipelineStage)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1 truncate">{i.clientName ?? '-'}</span>
|
<span className="flex-1 truncate">{i.clientName ?? '-'}</span>
|
||||||
{i.berthMooringNumber && (
|
{(() => {
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
const label = deriveInterestBerthLabel(i.berthMoorings) ?? i.berthMooringNumber;
|
||||||
Berth {i.berthMooringNumber}
|
return label ? (
|
||||||
</span>
|
<span className="shrink-0 text-xs text-muted-foreground">Berth {label}</span>
|
||||||
)}
|
) : null;
|
||||||
|
})()}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export interface AggregatedFile {
|
|||||||
clientId: string | null;
|
clientId: string | null;
|
||||||
companyId: string | null;
|
companyId: string | null;
|
||||||
yachtId: string | null;
|
yachtId: string | null;
|
||||||
|
interestId: string | null;
|
||||||
|
/** Multi-berth label of the file's parent interest (e.g. "A1-A3, B5").
|
||||||
|
* Null when the file isn't tied to an interest or when the interest
|
||||||
|
* has no linked berths. Drives the "which deal" badge on each row in
|
||||||
|
* EntityFolderView so reps can disambiguate files on a multi-deal
|
||||||
|
* client. */
|
||||||
|
interestBerthLabel: string | null;
|
||||||
signedFromDocumentId: string | null;
|
signedFromDocumentId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ interface UsePaginatedQueryOptions {
|
|||||||
endpoint: string;
|
endpoint: string;
|
||||||
initialPage?: number;
|
initialPage?: number;
|
||||||
initialPageSize?: number;
|
initialPageSize?: number;
|
||||||
|
/** Default sort applied when the URL has no `sort` param. Lets a list
|
||||||
|
* surface advertise its preferred ordering (e.g. interests → most-
|
||||||
|
* recently-updated first) without forcing the rep to click a header
|
||||||
|
* on each visit. The active sort still serializes to / from the URL,
|
||||||
|
* so deep-links keep working. */
|
||||||
|
initialSort?: { field: string; direction: 'asc' | 'desc' };
|
||||||
filterDefinitions?: FilterDefinition[];
|
filterDefinitions?: FilterDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ export function usePaginatedQuery<T>({
|
|||||||
endpoint,
|
endpoint,
|
||||||
initialPage = 1,
|
initialPage = 1,
|
||||||
initialPageSize = 25,
|
initialPageSize = 25,
|
||||||
|
initialSort,
|
||||||
filterDefinitions = [],
|
filterDefinitions = [],
|
||||||
}: UsePaginatedQueryOptions) {
|
}: UsePaginatedQueryOptions) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -42,7 +49,7 @@ export function usePaginatedQuery<T>({
|
|||||||
const [page, setPageState] = useState(pageFromUrl);
|
const [page, setPageState] = useState(pageFromUrl);
|
||||||
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
|
const [pageSize, setPageSizeState] = useState(pageSizeFromUrl);
|
||||||
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
|
const [sort, setSortState] = useState<{ field: string; direction: 'asc' | 'desc' } | undefined>(
|
||||||
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : undefined,
|
sortFieldFromUrl ? { field: sortFieldFromUrl, direction: sortOrderFromUrl } : initialSort,
|
||||||
);
|
);
|
||||||
const [filters, setFiltersState] = useState<FilterValues>(() =>
|
const [filters, setFiltersState] = useState<FilterValues>(() =>
|
||||||
deserializeFiltersFromParams(searchParams, filterDefinitions),
|
deserializeFiltersFromParams(searchParams, filterDefinitions),
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface DashboardReportData {
|
|||||||
berthStatus?: {
|
berthStatus?: {
|
||||||
available: number;
|
available: number;
|
||||||
underOffer: number;
|
underOffer: number;
|
||||||
maintenance: number;
|
|
||||||
sold: number;
|
sold: number;
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
@@ -119,7 +118,7 @@ export interface DashboardReportData {
|
|||||||
newInterestsInPeriod?: Array<{
|
newInterestsInPeriod?: Array<{
|
||||||
clientName: string;
|
clientName: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
source: string | null;
|
berthLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}>;
|
}>;
|
||||||
/** Berths transitioned to Sold status during the report window. */
|
/** Berths transitioned to Sold status during the report window. */
|
||||||
@@ -269,11 +268,6 @@ export function DashboardReport({
|
|||||||
String(data.berthStatus.sold),
|
String(data.berthStatus.sold),
|
||||||
pct(data.berthStatus.sold, data.berthStatus.total),
|
pct(data.berthStatus.sold, data.berthStatus.total),
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'Maintenance',
|
|
||||||
String(data.berthStatus.maintenance),
|
|
||||||
pct(data.berthStatus.maintenance, data.berthStatus.total),
|
|
||||||
],
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -330,7 +324,6 @@ export function DashboardReport({
|
|||||||
{ label: 'Available', value: data.berthStatus.available, color: '#0d9488' },
|
{ label: 'Available', value: data.berthStatus.available, color: '#0d9488' },
|
||||||
{ label: 'Under offer', value: data.berthStatus.underOffer, color: '#f59e0b' },
|
{ label: 'Under offer', value: data.berthStatus.underOffer, color: '#f59e0b' },
|
||||||
{ label: 'Sold', value: data.berthStatus.sold, color: '#0284c7' },
|
{ label: 'Sold', value: data.berthStatus.sold, color: '#0284c7' },
|
||||||
{ label: 'Maintenance', value: data.berthStatus.maintenance, color: '#94a3b8' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -535,17 +528,14 @@ export function DashboardReport({
|
|||||||
data.berthDemandRanking.length > 0 ? (
|
data.berthDemandRanking.length > 0 ? (
|
||||||
<View wrap={false}>
|
<View wrap={false}>
|
||||||
<Text style={styles.sectionTitle}>Berth demand ranking</Text>
|
<Text style={styles.sectionTitle}>Berth demand ranking</Text>
|
||||||
<Text style={styles.sectionSubtitle}>
|
<Text style={styles.sectionSubtitle}>Top berths by active-interest count.</Text>
|
||||||
Top berths by active-interest count + heat tier (A = strongest signal).
|
|
||||||
</Text>
|
|
||||||
<SimpleTable
|
<SimpleTable
|
||||||
styles={styles}
|
styles={styles}
|
||||||
headers={['Mooring', 'Active interests', 'Tier']}
|
headers={['Mooring', 'Active interests']}
|
||||||
widths={[40, 40, 20]}
|
widths={[50, 50]}
|
||||||
rows={data.berthDemandRanking.map((row) => [
|
rows={data.berthDemandRanking.map((row) => [
|
||||||
row.mooringNumber,
|
row.mooringNumber,
|
||||||
String(row.interestCount),
|
String(row.interestCount),
|
||||||
row.tier,
|
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -557,13 +547,16 @@ export function DashboardReport({
|
|||||||
<View wrap={false}>
|
<View wrap={false}>
|
||||||
<Text style={styles.sectionTitle}>Deal pulse distribution</Text>
|
<Text style={styles.sectionTitle}>Deal pulse distribution</Text>
|
||||||
<Text style={styles.sectionSubtitle}>
|
<Text style={styles.sectionSubtitle}>
|
||||||
Counts of active interests in each pulse tier (hot / warm / cool / cold).
|
Counts of active interests in each pulse tier (Hot / Warm / Cool / Cold).
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleTable
|
<SimpleTable
|
||||||
styles={styles}
|
styles={styles}
|
||||||
headers={['Tier', 'Count']}
|
headers={['Tier', 'Count']}
|
||||||
widths={[70, 30]}
|
widths={[70, 30]}
|
||||||
rows={data.dealPulseDistribution.map((row) => [row.tier, String(row.count)])}
|
rows={data.dealPulseDistribution.map((row) => [
|
||||||
|
row.tier ? row.tier.charAt(0).toUpperCase() + row.tier.slice(1) : row.tier,
|
||||||
|
String(row.count),
|
||||||
|
])}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -632,17 +625,17 @@ export function DashboardReport({
|
|||||||
<View wrap={false}>
|
<View wrap={false}>
|
||||||
<Text style={styles.sectionTitle}>New interests (in period)</Text>
|
<Text style={styles.sectionTitle}>New interests (in period)</Text>
|
||||||
<Text style={styles.sectionSubtitle}>
|
<Text style={styles.sectionSubtitle}>
|
||||||
Interests opened during the report window, with the stage they currently sit at and
|
Interests opened during the report window, with the stage they currently sit at and the
|
||||||
their lead source.
|
berth(s) attached.
|
||||||
</Text>
|
</Text>
|
||||||
<SimpleTable
|
<SimpleTable
|
||||||
styles={styles}
|
styles={styles}
|
||||||
headers={['Client', 'Stage', 'Source', 'Opened']}
|
headers={['Client', 'Stage', 'Berth', 'Opened']}
|
||||||
widths={[40, 22, 18, 20]}
|
widths={[35, 22, 23, 20]}
|
||||||
rows={data.newInterestsInPeriod.map((r) => [
|
rows={data.newInterestsInPeriod.map((r) => [
|
||||||
r.clientName,
|
r.clientName,
|
||||||
stageLabel(r.stage),
|
stageLabel(r.stage),
|
||||||
r.source ?? '-',
|
r.berthLabel ?? '-',
|
||||||
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
new Date(r.createdAt).toLocaleDateString('en-GB'),
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge, DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
|
import { Badge, DataTable, DocumentShell, KeyValueGrid, Section } from '@/lib/pdf/brand-kit';
|
||||||
|
import { stageLabelFor } from '@/lib/constants';
|
||||||
|
|
||||||
export interface ClientContact {
|
export interface ClientContact {
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -131,7 +132,7 @@ export function ClientSummaryPdf({
|
|||||||
<Section title="Pipeline interests">
|
<Section title="Pipeline interests">
|
||||||
<DataTable<InterestRow>
|
<DataTable<InterestRow>
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Stage', flex: 2, render: (i) => i.pipelineStage ?? 'open' },
|
{ header: 'Stage', flex: 2, render: (i) => stageLabelFor(i.pipelineStage) },
|
||||||
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '-' },
|
{ header: 'Berth', flex: 1, render: (i) => i.berthMooringNumber ?? '-' },
|
||||||
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '-' },
|
{ header: 'Category', flex: 2, render: (i) => i.leadCategory ?? '-' },
|
||||||
{ header: 'Created', flex: 1.5, render: (i) => fmtDate(i.createdAt) },
|
{ header: 'Created', flex: 1.5, render: (i) => fmtDate(i.createdAt) },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
type BadgeTone,
|
type BadgeTone,
|
||||||
} from '@/lib/pdf/brand-kit';
|
} from '@/lib/pdf/brand-kit';
|
||||||
|
import { stageLabelFor } from '@/lib/constants';
|
||||||
|
|
||||||
export interface InterestSummaryPdfProps {
|
export interface InterestSummaryPdfProps {
|
||||||
portName: string;
|
portName: string;
|
||||||
@@ -74,8 +75,8 @@ export function InterestSummaryPdf({
|
|||||||
berth,
|
berth,
|
||||||
timeline,
|
timeline,
|
||||||
}: InterestSummaryPdfProps) {
|
}: InterestSummaryPdfProps) {
|
||||||
const stage = (interest.pipelineStage ?? 'open').toLowerCase();
|
const stage = stageLabelFor(interest.pipelineStage);
|
||||||
const docMeta = `${client.fullName ?? 'Unknown client'} · stage: ${stage.replace('_', ' ')}`;
|
const docMeta = `${client.fullName ?? 'Unknown client'} · stage: ${stage}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentShell
|
<DocumentShell
|
||||||
|
|||||||
@@ -15,20 +15,20 @@ export interface OccupancyReportPdfProps {
|
|||||||
data: OccupancyData;
|
data: OccupancyData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirrors BERTH_STATUSES in src/lib/constants.ts (canonical 3-status
|
||||||
|
// enum). 'reserved' and 'maintenance' were dropped from the schema; if
|
||||||
|
// pre-migration data still carries them, the label falls back to the
|
||||||
|
// raw status string via the `?? status` guard at the call site below.
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
under_offer: 'Under offer',
|
under_offer: 'Under offer',
|
||||||
sold: 'Sold',
|
sold: 'Sold',
|
||||||
reserved: 'Reserved',
|
|
||||||
maintenance: 'Maintenance',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
available: PDF_TOKENS.colors.success,
|
available: PDF_TOKENS.colors.success,
|
||||||
under_offer: PDF_TOKENS.colors.warning,
|
under_offer: PDF_TOKENS.colors.warning,
|
||||||
sold: PDF_TOKENS.colors.accentBlue,
|
sold: PDF_TOKENS.colors.accentBlue,
|
||||||
reserved: PDF_TOKENS.colors.accentSlate,
|
|
||||||
maintenance: PDF_TOKENS.colors.danger,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OccupancyReportPdf({ portName, logoBuffer, data }: OccupancyReportPdfProps) {
|
export function OccupancyReportPdf({ portName, logoBuffer, data }: OccupancyReportPdfProps) {
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ import { berths } from '@/lib/db/schema/berths';
|
|||||||
import { documents } from '@/lib/db/schema/documents';
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
import { reminders } from '@/lib/db/schema/operations';
|
import { reminders } from '@/lib/db/schema/operations';
|
||||||
import { payments } from '@/lib/db/schema/pipeline';
|
import { payments } from '@/lib/db/schema/pipeline';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { userProfiles } from '@/lib/db/schema/users';
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
|
import { canonicalizeStage } from '@/lib/constants';
|
||||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||||
import { computeDealHealth } from './deal-health';
|
import { computeDealHealth } from './deal-health';
|
||||||
|
import { getAllBerthMooringsForInterests } from './interest-berths.service';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import {
|
import {
|
||||||
getKpis,
|
getKpis,
|
||||||
getPipelineCounts,
|
getPipelineCounts,
|
||||||
@@ -92,6 +96,16 @@ export async function resolveDashboardReportData(
|
|||||||
const { from: windowFrom, to: windowTo } = parseWindow(window);
|
const { from: windowFrom, to: windowTo } = parseWindow(window);
|
||||||
const hasWindow = windowFrom !== null && windowTo !== null;
|
const hasWindow = windowFrom !== null && windowTo !== null;
|
||||||
|
|
||||||
|
// Resolve the port's configured currency once - every money-bearing
|
||||||
|
// section reads it for the Intl.NumberFormat output. Falls back to USD
|
||||||
|
// for any port row without a default set (schema default is also USD).
|
||||||
|
const portRow = await db
|
||||||
|
.select({ defaultCurrency: ports.defaultCurrency })
|
||||||
|
.from(ports)
|
||||||
|
.where(eq(ports.id, portId))
|
||||||
|
.limit(1);
|
||||||
|
const portCurrency = portRow[0]?.defaultCurrency ?? 'USD';
|
||||||
|
|
||||||
// ─── KPI / summary ───────────────────────────────────────────────
|
// ─── KPI / summary ───────────────────────────────────────────────
|
||||||
if (want.has('kpi_overview')) {
|
if (want.has('kpi_overview')) {
|
||||||
data.kpis = await getKpis(portId);
|
data.kpis = await getKpis(portId);
|
||||||
@@ -232,7 +246,6 @@ export async function resolveDashboardReportData(
|
|||||||
id: interests.id,
|
id: interests.id,
|
||||||
clientName: clients.fullName,
|
clientName: clients.fullName,
|
||||||
stage: interests.pipelineStage,
|
stage: interests.pipelineStage,
|
||||||
source: interests.source,
|
|
||||||
createdAt: interests.createdAt,
|
createdAt: interests.createdAt,
|
||||||
})
|
})
|
||||||
.from(interests)
|
.from(interests)
|
||||||
@@ -247,10 +260,14 @@ export async function resolveDashboardReportData(
|
|||||||
)
|
)
|
||||||
.orderBy(desc(interests.createdAt))
|
.orderBy(desc(interests.createdAt))
|
||||||
.limit(50);
|
.limit(50);
|
||||||
|
// Resolve berth moorings per interest in one batched round-trip so
|
||||||
|
// the "Berth" column renders the same multi-berth label idiom as
|
||||||
|
// every other interest-row surface (`A1-A3, B5`).
|
||||||
|
const allMooringsMap = await getAllBerthMooringsForInterests(rows.map((r) => r.id));
|
||||||
data.newInterestsInPeriod = rows.map((r) => ({
|
data.newInterestsInPeriod = rows.map((r) => ({
|
||||||
clientName: r.clientName,
|
clientName: r.clientName,
|
||||||
stage: r.stage,
|
stage: r.stage,
|
||||||
source: r.source ?? null,
|
berthLabel: deriveInterestBerthLabel(allMooringsMap.get(r.id)),
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -396,7 +413,7 @@ export async function resolveDashboardReportData(
|
|||||||
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
|
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const health = computeDealHealth({
|
const health = computeDealHealth({
|
||||||
pipelineStage: r.pipelineStage ?? 'open',
|
pipelineStage: canonicalizeStage(r.pipelineStage),
|
||||||
outcome: r.outcome,
|
outcome: r.outcome,
|
||||||
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
||||||
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
|
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
|
||||||
@@ -572,7 +589,7 @@ export async function resolveDashboardReportData(
|
|||||||
data.revenueForecast = {
|
data.revenueForecast = {
|
||||||
grossValue: forecast.totalGrossValue,
|
grossValue: forecast.totalGrossValue,
|
||||||
weightedValue: forecast.totalWeightedValue,
|
weightedValue: forecast.totalWeightedValue,
|
||||||
currency: 'EUR',
|
currency: portCurrency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,10 +646,12 @@ export async function resolveDashboardReportData(
|
|||||||
gross: s.grossValue,
|
gross: s.grossValue,
|
||||||
weighted: s.weightedValue,
|
weighted: s.weightedValue,
|
||||||
deals: s.count,
|
deals: s.count,
|
||||||
// The forecast service doesn't return a port-currency hint;
|
// The forecast service doesn't return a per-stage currency hint;
|
||||||
// default to EUR which matches the seeded berths schema. A
|
// every stage rolls up under the port's configured defaultCurrency
|
||||||
// multi-currency-aware breakdown would need extra plumbing.
|
// (ports.default_currency). Multi-currency-per-stage rollups would
|
||||||
currency: 'EUR',
|
// need extra plumbing - until then a single port currency drives
|
||||||
|
// the whole breakdown to match the dashboard tile.
|
||||||
|
currency: portCurrency,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { residentialClients, residentialInterests } from '@/lib/db/schema/reside
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||||
import { userProfiles } from '@/lib/db/schema/users';
|
import { userProfiles } from '@/lib/db/schema/users';
|
||||||
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
|
||||||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||||||
import { convert as convertCurrency } from '@/lib/services/currency';
|
import { convert as convertCurrency } from '@/lib/services/currency';
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ export async function getRevenueForecast(portId: string, range?: { from: Date; t
|
|||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
for (const row of interestRows) {
|
for (const row of interestRows) {
|
||||||
const stage = row.pipelineStage ?? 'open';
|
const stage = canonicalizeStage(row.pipelineStage);
|
||||||
const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0;
|
const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0;
|
||||||
const weight = weights[stage] ?? 0;
|
const weight = weights[stage] ?? 0;
|
||||||
const weighted = price * weight;
|
const weighted = price * weight;
|
||||||
@@ -261,7 +261,6 @@ export async function getBerthStatusDistribution(portId: string) {
|
|||||||
available: counts['available'] ?? 0,
|
available: counts['available'] ?? 0,
|
||||||
underOffer: counts['under_offer'] ?? 0,
|
underOffer: counts['under_offer'] ?? 0,
|
||||||
sold: counts['sold'] ?? 0,
|
sold: counts['sold'] ?? 0,
|
||||||
maintenance: counts['maintenance'] ?? 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { env } from '@/lib/env';
|
|||||||
import { buildStoragePath } from '@/lib/minio';
|
import { buildStoragePath } from '@/lib/minio';
|
||||||
import { getStorageBackend } from '@/lib/storage';
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
|
import { canonicalizeStage, type PipelineStage } from '@/lib/constants';
|
||||||
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
import { CodedError, ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
|
|
||||||
@@ -172,29 +173,34 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Two concerns to keep separate:
|
// Two concerns to keep separate:
|
||||||
// 1. Document metadata - always write `dateEoiSigned` + `eoiStatus`
|
// 1. Document metadata - always write dateEoiSigned, eoiStatus, and
|
||||||
// from the upload. Even if the rep already advanced the stage
|
// eoiDocStatus from the upload. Even if the rep already advanced
|
||||||
// manually, the paper signing event needs a recorded date so
|
// the stage manually, the paper signing event needs a recorded
|
||||||
// downstream surfaces (SkipAheadBanner, milestone strip, EOI
|
// date + doc-status badge so downstream surfaces (SkipAheadBanner,
|
||||||
// merge fields) reflect reality. Honour an existing
|
// milestone strip, EOI merge fields, signing-progress chip) reflect
|
||||||
// dateEoiSigned (don't overwrite if already set - covers the
|
// reality. Honour an existing dateEoiSigned - covers the case where
|
||||||
// case where the rep is uploading evidence for an event whose
|
// the rep is uploading evidence for an event whose date was already
|
||||||
// date was already backfilled).
|
// backfilled.
|
||||||
// 2. Stage advance - only when the deal hasn't reached eoi_signed
|
// 2. Stage advance - in the 7-stage pipeline (PIPELINE_STAGES), every
|
||||||
// yet. Bypasses canTransitionStage because the operator just
|
// pre-EOI stage (enquiry / qualified / nurturing) should flip to
|
||||||
// brought concrete proof.
|
// 'eoi' on signing. The doc-status 'signed' carries the within-stage
|
||||||
|
// sub-state. Bypasses canTransitionStage because the operator just
|
||||||
|
// brought concrete proof. Idempotent at the 'eoi' / 'reservation' /
|
||||||
|
// 'deposit_paid' / 'contract' stages (stays put).
|
||||||
|
const stageBeforeAdvance = interest.pipelineStage as PipelineStage | null | undefined;
|
||||||
|
const canonicalStage = canonicalizeStage(stageBeforeAdvance);
|
||||||
const shouldAdvanceStage =
|
const shouldAdvanceStage =
|
||||||
interest.pipelineStage === 'open' ||
|
canonicalStage === 'enquiry' ||
|
||||||
interest.pipelineStage === 'details_sent' ||
|
canonicalStage === 'qualified' ||
|
||||||
interest.pipelineStage === 'in_communication' ||
|
canonicalStage === 'nurturing';
|
||||||
interest.pipelineStage === 'eoi_sent';
|
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.update(interests)
|
.update(interests)
|
||||||
.set({
|
.set({
|
||||||
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
|
dateEoiSigned: interest.dateEoiSigned ?? input.signedAt ?? new Date(),
|
||||||
eoiStatus: 'signed',
|
eoiStatus: 'signed',
|
||||||
...(shouldAdvanceStage ? { pipelineStage: 'eoi_signed' as const } : {}),
|
eoiDocStatus: 'signed',
|
||||||
|
...(shouldAdvanceStage ? { pipelineStage: 'eoi' as const } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(interests.id, interestId));
|
.where(eq(interests.id, interestId));
|
||||||
@@ -203,7 +209,7 @@ export async function uploadExternallySignedEoi(input: ExternalEoiInput) {
|
|||||||
documentId: doc.id,
|
documentId: doc.id,
|
||||||
fileId: fileRecord.id,
|
fileId: fileRecord.id,
|
||||||
stageChanged: shouldAdvanceStage,
|
stageChanged: shouldAdvanceStage,
|
||||||
newStage: shouldAdvanceStage ? ('eoi_signed' as const) : interest.pipelineStage,
|
newStage: shouldAdvanceStage ? ('eoi' as const) : canonicalStage,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
PREVIEWABLE_MIMES,
|
PREVIEWABLE_MIMES,
|
||||||
bufferMatchesMime,
|
bufferMatchesMime,
|
||||||
} from '@/lib/constants/file-validation';
|
} from '@/lib/constants/file-validation';
|
||||||
|
import { getAllBerthMooringsForInterests } from '@/lib/services/interest-berths.service';
|
||||||
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
|
import { generateStorageKey, sanitizeFilename } from '@/lib/services/storage';
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
|
import type { UploadFileInput, UpdateFileInput, ListFilesInput } from '@/lib/validators/files';
|
||||||
import { documentFolders } from '@/lib/db/schema/documents';
|
import { documentFolders } from '@/lib/db/schema/documents';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
@@ -334,6 +336,13 @@ export async function getFileById(id: string, portId: string) {
|
|||||||
*/
|
*/
|
||||||
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
|
export type AggregatedFileRow = Omit<typeof files.$inferSelect, 'storagePath' | 'storageBucket'> & {
|
||||||
signedFromDocumentId: string | null;
|
signedFromDocumentId: string | null;
|
||||||
|
/** When the file is tagged to an interest, the multi-berth label of
|
||||||
|
* that interest (e.g. "A1-A3, B5") - or null. Resolved in
|
||||||
|
* listFilesAggregated* via one batched mooring lookup per request.
|
||||||
|
* Lets EntityFolderView render a per-row "which deal" badge so reps
|
||||||
|
* can tell apart files that all look identical on a multi-deal
|
||||||
|
* client. */
|
||||||
|
interestBerthLabel: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AggregatedFileGroup {
|
export interface AggregatedFileGroup {
|
||||||
@@ -443,7 +452,13 @@ export async function listFilesAggregatedByEntity(
|
|||||||
columns: { id: true, clientId: true },
|
columns: { id: true, clientId: true },
|
||||||
});
|
});
|
||||||
if (!interest) return { groups: [] };
|
if (!interest) return { groups: [] };
|
||||||
return listFilesAggregatedForInterest(portId, interest.id, interest.clientId ?? null);
|
const result = await listFilesAggregatedForInterest(
|
||||||
|
portId,
|
||||||
|
interest.id,
|
||||||
|
interest.clientId ?? null,
|
||||||
|
);
|
||||||
|
await attachInterestBerthLabels(result.groups);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const related = await collectRelatedEntities(portId, entityType, entityId);
|
const related = await collectRelatedEntities(portId, entityType, entityId);
|
||||||
@@ -499,6 +514,8 @@ export async function listFilesAggregatedByEntity(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await attachInterestBerthLabels(groups);
|
||||||
|
|
||||||
return { groups };
|
return { groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,6 +705,9 @@ async function fetchGroupRows(
|
|||||||
// Reverse-link: if any document row has this file as its signed_file_id,
|
// Reverse-link: if any document row has this file as its signed_file_id,
|
||||||
// surface that document's id.
|
// surface that document's id.
|
||||||
signedFromDocumentId: documents.id,
|
signedFromDocumentId: documents.id,
|
||||||
|
// interestBerthLabel is filled post-fetch via a single batched
|
||||||
|
// getAllBerthMooringsForInterests call at the entry-point level so
|
||||||
|
// we don't N+1 the moorings join inside each group.
|
||||||
})
|
})
|
||||||
.from(files)
|
.from(files)
|
||||||
.leftJoin(documents, eq(documents.signedFileId, files.id))
|
.leftJoin(documents, eq(documents.signedFileId, files.id))
|
||||||
@@ -708,7 +728,37 @@ async function fetchGroupRows(
|
|||||||
.from(files)
|
.from(files)
|
||||||
.where(and(eq(files.portId, portId), predicate));
|
.where(and(eq(files.portId, portId), predicate));
|
||||||
|
|
||||||
return { rows, total: Number(countRow?.count ?? 0) };
|
// interestBerthLabel filled by the entry-point post-pass (see
|
||||||
|
// attachInterestBerthLabels below); default to null inside this row.
|
||||||
|
const rowsWithDefault: AggregatedFileRow[] = rows.map((r) => ({
|
||||||
|
...r,
|
||||||
|
interestBerthLabel: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { rows: rowsWithDefault, total: Number(countRow?.count ?? 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutate the rows of every group in-place to fill `interestBerthLabel`
|
||||||
|
* from a single batched mooring lookup. Called by both interest- and
|
||||||
|
* entity-aggregation entry-points so EntityFolderView gets the "which
|
||||||
|
* deal" badge without an N+1 join.
|
||||||
|
*/
|
||||||
|
async function attachInterestBerthLabels(groups: AggregatedFileGroup[]): Promise<void> {
|
||||||
|
const interestIds = new Set<string>();
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const f of g.files) {
|
||||||
|
if (f.interestId) interestIds.add(f.interestId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (interestIds.size === 0) return;
|
||||||
|
const mooringsMap = await getAllBerthMooringsForInterests(Array.from(interestIds));
|
||||||
|
for (const g of groups) {
|
||||||
|
for (const f of g.files) {
|
||||||
|
if (!f.interestId) continue;
|
||||||
|
f.interestBerthLabel = deriveInterestBerthLabel(mooringsMap.get(f.interestId));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
|
function dedupeBy<T, K>(items: T[], key: (t: T) => K): T[] {
|
||||||
|
|||||||
@@ -101,6 +101,41 @@ export async function getPrimaryBerthsForInterests(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map { interestId → mooring numbers[] } for a batch of interest ids.
|
||||||
|
* Used by list/kanban/header surfaces that need to render the full
|
||||||
|
* berth-range label (`A1-A3, B5`) rather than just the primary mooring.
|
||||||
|
* One round-trip; siblings the primary-only aggregator above.
|
||||||
|
*
|
||||||
|
* Mooring numbers come back sorted lexically; the consumer formatter
|
||||||
|
* (`deriveInterestBerthLabel` / `formatBerthRange`) re-sorts by
|
||||||
|
* prefix+number for range collapsing. Null mooring numbers (orphaned
|
||||||
|
* junction rows where the berth was hard-deleted) are filtered out.
|
||||||
|
*/
|
||||||
|
export async function getAllBerthMooringsForInterests(
|
||||||
|
interestIds: string[],
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
if (interestIds.length === 0) return new Map();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
interestId: interestBerths.interestId,
|
||||||
|
mooringNumber: berths.mooringNumber,
|
||||||
|
})
|
||||||
|
.from(interestBerths)
|
||||||
|
.leftJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||||
|
.where(inArray(interestBerths.interestId, interestIds))
|
||||||
|
.orderBy(berths.mooringNumber);
|
||||||
|
|
||||||
|
const out = new Map<string, string[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.mooringNumber) continue;
|
||||||
|
const existing = out.get(r.interestId);
|
||||||
|
if (existing) existing.push(r.mooringNumber);
|
||||||
|
else out.set(r.interestId, [r.mooringNumber]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
|
/** Berth metadata surfaced alongside each junction row by {@link listBerthsForInterest}.
|
||||||
* All berth-derived fields are nullable so an orphaned junction row (berth
|
* All berth-derived fields are nullable so an orphaned junction row (berth
|
||||||
* hard-deleted out from under the link) still renders rather than vanishing. */
|
* hard-deleted out from under the link) still renders rather than vanishing. */
|
||||||
@@ -256,6 +291,15 @@ export async function upsertInterestBerthTx(
|
|||||||
if (opts.isSpecificInterest !== undefined)
|
if (opts.isSpecificInterest !== undefined)
|
||||||
setForUpdate.isSpecificInterest = opts.isSpecificInterest;
|
setForUpdate.isSpecificInterest = opts.isSpecificInterest;
|
||||||
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
|
if (opts.isInEoiBundle !== undefined) setForUpdate.isInEoiBundle = opts.isInEoiBundle;
|
||||||
|
// Invariant: primary berth is ALWAYS in the EOI bundle. The primary IS
|
||||||
|
// the canonical "berth for this deal" - excluding it from the signed
|
||||||
|
// envelope is semantically nonsense. If the caller is setting the row
|
||||||
|
// to primary OR opting to take out of the EOI bundle, force the bundle
|
||||||
|
// flag back on whenever the row is also (about to be) primary.
|
||||||
|
const willBePrimary = opts.isPrimary === true;
|
||||||
|
if (willBePrimary && opts.isInEoiBundle === false) {
|
||||||
|
setForUpdate.isInEoiBundle = true;
|
||||||
|
}
|
||||||
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
|
if (opts.addedBy !== undefined) setForUpdate.addedBy = opts.addedBy;
|
||||||
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
|
if (opts.notes !== undefined) setForUpdate.notes = opts.notes;
|
||||||
// Bypass fields move as a unit - either we set all three to record a bypass
|
// Bypass fields move as a unit - either we set all three to record a bypass
|
||||||
@@ -283,6 +327,11 @@ export async function upsertInterestBerthTx(
|
|||||||
// non-primary rows default to FALSE so the public map doesn't
|
// non-primary rows default to FALSE so the public map doesn't
|
||||||
// light up extra berths.
|
// light up extra berths.
|
||||||
const isPrimary = opts.isPrimary ?? false;
|
const isPrimary = opts.isPrimary ?? false;
|
||||||
|
// Force is_in_eoi_bundle=true when this row is the primary: the EOI
|
||||||
|
// bundle MUST cover the deal's canonical berth, regardless of what
|
||||||
|
// the caller passed. Non-primary rows still default to true (rep can
|
||||||
|
// opt out per-berth) but primary is non-negotiable.
|
||||||
|
const isInEoiBundle = isPrimary ? true : (opts.isInEoiBundle ?? true);
|
||||||
const [row] = await tx
|
const [row] = await tx
|
||||||
.insert(interestBerths)
|
.insert(interestBerths)
|
||||||
.values({
|
.values({
|
||||||
@@ -290,7 +339,7 @@ export async function upsertInterestBerthTx(
|
|||||||
berthId,
|
berthId,
|
||||||
isPrimary,
|
isPrimary,
|
||||||
isSpecificInterest: opts.isSpecificInterest ?? isPrimary,
|
isSpecificInterest: opts.isSpecificInterest ?? isPrimary,
|
||||||
isInEoiBundle: opts.isInEoiBundle ?? true,
|
isInEoiBundle,
|
||||||
addedBy: opts.addedBy,
|
addedBy: opts.addedBy,
|
||||||
notes: opts.notes,
|
notes: opts.notes,
|
||||||
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,
|
eoiBypassReason: setForUpdate.eoiBypassReason ?? null,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { evaluateRule } from '@/lib/services/berth-rules-engine';
|
|||||||
import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service';
|
import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import {
|
import {
|
||||||
|
getAllBerthMooringsForInterests,
|
||||||
getPrimaryBerth,
|
getPrimaryBerth,
|
||||||
getPrimaryBerthsForInterests,
|
getPrimaryBerthsForInterests,
|
||||||
listBerthsForInterest,
|
listBerthsForInterest,
|
||||||
@@ -163,6 +164,11 @@ export interface BoardInterestRow {
|
|||||||
id: string;
|
id: string;
|
||||||
clientName: string | null;
|
clientName: string | null;
|
||||||
berthMooringNumber: string | null;
|
berthMooringNumber: string | null;
|
||||||
|
/** Every linked berth's mooring on this interest (sorted). Consumers
|
||||||
|
* pass this through `deriveInterestBerthLabel` for the header / card
|
||||||
|
* display so multi-berth interests render as `A1-A3, B5` rather than
|
||||||
|
* just the primary mooring. */
|
||||||
|
berthMoorings: string[];
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
pipelineStage: string;
|
pipelineStage: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -262,13 +268,20 @@ export async function listInterestsForBoard(
|
|||||||
|
|
||||||
// Primary-berth resolution stays in the junction-aware service so the
|
// Primary-berth resolution stays in the junction-aware service so the
|
||||||
// board sees the same "the berth for this deal" as every other surface.
|
// board sees the same "the berth for this deal" as every other surface.
|
||||||
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
|
// All-berth aggregator runs in parallel; both come from the same
|
||||||
|
// interest_berths table so the round-trips are independent.
|
||||||
|
const interestIds = data.map((r) => r.id);
|
||||||
|
const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([
|
||||||
|
getPrimaryBerthsForInterests(interestIds),
|
||||||
|
getAllBerthMooringsForInterests(interestIds),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data.map((r) => ({
|
data: data.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
clientName: r.clientName ?? null,
|
clientName: r.clientName ?? null,
|
||||||
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
|
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
|
||||||
|
berthMoorings: allBerthMooringsMap.get(r.id) ?? [],
|
||||||
leadCategory: r.leadCategory ?? null,
|
leadCategory: r.leadCategory ?? null,
|
||||||
pipelineStage: r.pipelineStage,
|
pipelineStage: r.pipelineStage,
|
||||||
updatedAt: r.updatedAt,
|
updatedAt: r.updatedAt,
|
||||||
@@ -405,7 +418,12 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
// Primary-berth lookup via the interest_berths junction. Single round-trip
|
// Primary-berth lookup via the interest_berths junction. Single round-trip
|
||||||
// by interestId list - see plan §3.4: every "the berth for this interest"
|
// by interestId list - see plan §3.4: every "the berth for this interest"
|
||||||
// surface resolves through getPrimaryBerth(...) rather than a column read.
|
// surface resolves through getPrimaryBerth(...) rather than a column read.
|
||||||
const primaryBerthMap = await getPrimaryBerthsForInterests(interestIds);
|
// Sibling all-mooring aggregator runs in parallel so the list endpoint
|
||||||
|
// can surface multi-berth labels (A1-A3, B5) without a second waterfall.
|
||||||
|
const [primaryBerthMap, allBerthMooringsMap] = await Promise.all([
|
||||||
|
getPrimaryBerthsForInterests(interestIds),
|
||||||
|
getAllBerthMooringsForInterests(interestIds),
|
||||||
|
]);
|
||||||
|
|
||||||
if (yachtIds.length > 0) {
|
if (yachtIds.length > 0) {
|
||||||
const yachtRows = await db
|
const yachtRows = await db
|
||||||
@@ -453,6 +471,7 @@ export async function listInterests(portId: string, query: ListInterestsInput) {
|
|||||||
clientName: clientsMap[i.clientId as string] ?? null,
|
clientName: clientsMap[i.clientId as string] ?? null,
|
||||||
berthId: primary?.berthId ?? null,
|
berthId: primary?.berthId ?? null,
|
||||||
berthMooringNumber: primary?.mooringNumber ?? null,
|
berthMooringNumber: primary?.mooringNumber ?? null,
|
||||||
|
berthMoorings: allBerthMooringsMap.get(i.id as string) ?? [],
|
||||||
yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null,
|
yachtName: i.yachtId ? (yachtsMap[i.yachtId as string] ?? null) : null,
|
||||||
tags: tagsByInterestId[i.id as string] ?? [],
|
tags: tagsByInterestId[i.id as string] ?? [],
|
||||||
notesCount: notesCountByInterestId[i.id as string] ?? 0,
|
notesCount: notesCountByInterestId[i.id as string] ?? 0,
|
||||||
@@ -515,9 +534,15 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
// Primary berth comes from the interest_berths junction (plan §3.4).
|
// Primary berth comes from the interest_berths junction (plan §3.4).
|
||||||
const primaryBerth = await getPrimaryBerth(interest.id);
|
// All linked moorings come from the same junction in one go - powers
|
||||||
|
// the multi-berth label rendered on every "interest header" surface.
|
||||||
|
const [primaryBerth, allMooringsMap] = await Promise.all([
|
||||||
|
getPrimaryBerth(interest.id),
|
||||||
|
getAllBerthMooringsForInterests([interest.id]),
|
||||||
|
]);
|
||||||
const berthId = primaryBerth?.berthId ?? null;
|
const berthId = primaryBerth?.berthId ?? null;
|
||||||
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
||||||
|
const berthMoorings = allMooringsMap.get(interest.id) ?? [];
|
||||||
|
|
||||||
// Total linked-berth count powers the "Berth Interest" milestone on
|
// Total linked-berth count powers the "Berth Interest" milestone on
|
||||||
// the OverviewTab - first thing the rep needs to capture, especially
|
// the OverviewTab - first thing the rep needs to capture, especially
|
||||||
@@ -666,6 +691,7 @@ export async function getInterestById(id: string, portId: string) {
|
|||||||
clientHasAddress: !!addressRow,
|
clientHasAddress: !!addressRow,
|
||||||
berthId,
|
berthId,
|
||||||
berthMooringNumber,
|
berthMooringNumber,
|
||||||
|
berthMoorings,
|
||||||
linkedBerthCount,
|
linkedBerthCount,
|
||||||
tags: tagRows,
|
tags: tagRows,
|
||||||
notesCount,
|
notesCount,
|
||||||
@@ -712,14 +738,33 @@ export async function createInterest(portId: string, data: CreateInterestInput,
|
|||||||
const resolvedReminderDays =
|
const resolvedReminderDays =
|
||||||
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
|
interestData.reminderDays ?? (resolvedReminderEnabled ? reminderConfig.defaultDays : null);
|
||||||
|
|
||||||
// Auto-assign to the port's default owner when the caller omits assignedTo.
|
// Resolve the deal owner. Three-tier chain:
|
||||||
// Setting is stored as `{ userId: "..." }` so other surfaces can extend it
|
// 1. Explicit `data.assignedTo` from the caller (rep picked an
|
||||||
// with round-robin / quota rules later without breaking this code path.
|
// assignee in the create form).
|
||||||
|
// 2. Port's `default_new_interest_owner` setting (used for round-
|
||||||
|
// robin / "front desk owns all new leads" rules).
|
||||||
|
// 3. Auto-assign to the creating user when they're a regular role
|
||||||
|
// (sales rep, sales manager, etc.). Skipped for super-admins who
|
||||||
|
// often create on behalf of other reps - they'd otherwise hijack
|
||||||
|
// every new lead. Falls back to null (Unassigned) when none of
|
||||||
|
// the above resolve.
|
||||||
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
let resolvedAssignedTo = interestData.assignedTo ?? null;
|
||||||
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
if (resolvedAssignedTo === null && !('assignedTo' in interestData)) {
|
||||||
const defaultOwner = await getSetting('default_new_interest_owner', portId);
|
const defaultOwner = await getSetting('default_new_interest_owner', portId);
|
||||||
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
const v = defaultOwner?.value as { userId?: string } | null | undefined;
|
||||||
if (v?.userId) resolvedAssignedTo = v.userId;
|
if (v?.userId) {
|
||||||
|
resolvedAssignedTo = v.userId;
|
||||||
|
} else {
|
||||||
|
// Tier 3: auto-assign to creator unless they're a super-admin.
|
||||||
|
const [profile] = await db
|
||||||
|
.select({ isSuperAdmin: userProfiles.isSuperAdmin })
|
||||||
|
.from(userProfiles)
|
||||||
|
.where(eq(userProfiles.userId, meta.userId))
|
||||||
|
.limit(1);
|
||||||
|
if (profile && !profile.isSuperAdmin) {
|
||||||
|
resolvedAssignedTo = meta.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await withTransaction(async (tx) => {
|
const result = await withTransaction(async (tx) => {
|
||||||
@@ -1133,10 +1178,11 @@ export async function advanceStageIfBehind(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// yachtId gate: changeInterestStage requires a yacht before leaving `open`.
|
// yachtId gate: changeInterestStage requires a yacht before leaving the
|
||||||
// EOI events imply a yacht is in the picture, but if the data is missing we
|
// initial enquiry stage. EOI events imply a yacht is in the picture, but
|
||||||
// bail rather than throw - the EOI itself shouldn't fail because of this.
|
// if the data is missing we bail rather than throw - the EOI itself
|
||||||
if (existing.pipelineStage === 'open' && !existing.yachtId) {
|
// shouldn't fail because of this.
|
||||||
|
if (existing.pipelineStage === 'enquiry' && !existing.yachtId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,14 @@ export const SETTING_KEYS = {
|
|||||||
// Ignored entirely on v1 instances.
|
// Ignored entirely on v1 instances.
|
||||||
documensoSigningOrder: 'documenso_signing_order',
|
documensoSigningOrder: 'documenso_signing_order',
|
||||||
// v2-only override of the post-signing redirect URL set on documentMeta.
|
// v2-only override of the post-signing redirect URL set on documentMeta.
|
||||||
// Falls back to the embedded signing host (or APP_URL) when unset. Use
|
// Resolver chain: explicit override -> port's public_site_url -> null
|
||||||
// this to land signed clients on /portal/eoi-complete (or wherever
|
// (let Documenso use its own default). Lets signers land on the port's
|
||||||
// makes sense for the workflow).
|
// marketing site by default without each admin having to configure two
|
||||||
|
// settings.
|
||||||
documensoRedirectUrl: 'documenso_redirect_url',
|
documensoRedirectUrl: 'documenso_redirect_url',
|
||||||
|
// Per-port public marketing-site URL. Used by signing-redirect
|
||||||
|
// fallback, email CTAs, and some templates.
|
||||||
|
publicSiteUrl: 'public_site_url',
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
brandingLogoUrl: 'branding_logo_url',
|
brandingLogoUrl: 'branding_logo_url',
|
||||||
@@ -396,6 +400,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
|||||||
approverUserId,
|
approverUserId,
|
||||||
signingOrder,
|
signingOrder,
|
||||||
redirectUrlOverride,
|
redirectUrlOverride,
|
||||||
|
publicSiteUrl,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
|
readSetting<string>(SETTING_KEYS.documensoApiUrlOverride, portId),
|
||||||
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
|
readSetting<string>(SETTING_KEYS.documensoApiKeyOverride, portId),
|
||||||
@@ -419,6 +424,7 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
|||||||
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
|
readSetting<string>(SETTING_KEYS.documensoApproverUserId, portId),
|
||||||
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
|
readSetting<'PARALLEL' | 'SEQUENTIAL'>(SETTING_KEYS.documensoSigningOrder, portId),
|
||||||
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
|
readSetting<string>(SETTING_KEYS.documensoRedirectUrl, portId),
|
||||||
|
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Determine the resolution source for the two credentials. Used by
|
// Determine the resolution source for the two credentials. Used by
|
||||||
@@ -457,7 +463,12 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
|
|||||||
developerUserId: developerUserId ?? null,
|
developerUserId: developerUserId ?? null,
|
||||||
approverUserId: approverUserId ?? null,
|
approverUserId: approverUserId ?? null,
|
||||||
signingOrder: signingOrder ?? null,
|
signingOrder: signingOrder ?? null,
|
||||||
redirectUrl: redirectUrlOverride ?? null,
|
// Resolution chain: explicit Documenso override → port's marketing
|
||||||
|
// site URL → null (Documenso falls back to its own default, which is
|
||||||
|
// typically the configured APP_URL = the CRM login - not what we want
|
||||||
|
// for signers). The marketing-site fallback means operators who set
|
||||||
|
// public_site_url (most do) automatically get sensible signer landing.
|
||||||
|
redirectUrl: redirectUrlOverride ?? publicSiteUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export async function createPublicInterest(
|
|||||||
clientId,
|
clientId,
|
||||||
yachtId,
|
yachtId,
|
||||||
source: 'website',
|
source: 'website',
|
||||||
pipelineStage: 'open',
|
pipelineStage: 'enquiry',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
40
src/lib/templates/interest-berth-label.ts
Normal file
40
src/lib/templates/interest-berth-label.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Shared helper that turns an interest's full berth list into the display
|
||||||
|
* label surfaced everywhere the record is named (header, kanban card, list
|
||||||
|
* rows, search results, picker chips). Mirrors the EOI / Documents-Hub
|
||||||
|
* idiom: consecutive runs collapse to a hyphenated range, separate runs
|
||||||
|
* comma-join, and an over-cap fallback degrades to "<first> + N more".
|
||||||
|
*
|
||||||
|
* deriveInterestBerthLabel([]) -> null
|
||||||
|
* deriveInterestBerthLabel(['A1']) -> 'A1'
|
||||||
|
* deriveInterestBerthLabel(['A1','A2','A3']) -> 'A1-A3'
|
||||||
|
* deriveInterestBerthLabel(['A1','A3']) -> 'A1, A3'
|
||||||
|
* deriveInterestBerthLabel(['A1','A3','B5','B6']) -> 'A1, A3, B5-B6'
|
||||||
|
* deriveInterestBerthLabel(['A1','A3','A5','A7','A9','A11'])
|
||||||
|
* -> 'A1 + 5 more'
|
||||||
|
*
|
||||||
|
* Truncation triggers when, post-range-collapse, the segment count exceeds
|
||||||
|
* MAX_SEGMENTS - keeps the button / header from overflowing.
|
||||||
|
*/
|
||||||
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||||
|
|
||||||
|
const MAX_SEGMENTS = 5;
|
||||||
|
|
||||||
|
export function deriveInterestBerthLabel(
|
||||||
|
mooringNumbers: readonly (string | null | undefined)[] | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!mooringNumbers) return null;
|
||||||
|
const clean = mooringNumbers.filter((m): m is string => !!m && m.trim().length > 0);
|
||||||
|
if (clean.length === 0) return null;
|
||||||
|
|
||||||
|
const compact = formatBerthRange(clean);
|
||||||
|
if (!compact) return null;
|
||||||
|
|
||||||
|
const segments = compact.split(', ');
|
||||||
|
if (segments.length <= MAX_SEGMENTS) return compact;
|
||||||
|
|
||||||
|
// Over-cap: degrade to "first + N more" against the total berth count.
|
||||||
|
const first = segments[0]!;
|
||||||
|
const remaining = clean.length - 1;
|
||||||
|
return `${first} + ${remaining} more`;
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ describe('POST /api/public/interests - trio creation', () => {
|
|||||||
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
|
const [interest] = await db.select().from(interests).where(eq(interests.id, interestId));
|
||||||
expect(interest).toBeDefined();
|
expect(interest).toBeDefined();
|
||||||
expect(interest!.portId).toBe(port.id);
|
expect(interest!.portId).toBe(port.id);
|
||||||
expect(interest!.pipelineStage).toBe('open');
|
expect(interest!.pipelineStage).toBe('enquiry');
|
||||||
expect(interest!.yachtId).not.toBeNull();
|
expect(interest!.yachtId).not.toBeNull();
|
||||||
expect(interest!.clientId).not.toBeNull();
|
expect(interest!.clientId).not.toBeNull();
|
||||||
|
|
||||||
|
|||||||
@@ -47,10 +47,9 @@ describe('PDF report renderer', () => {
|
|||||||
{ stage: 'deposit_paid', count: 1 },
|
{ stage: 'deposit_paid', count: 1 },
|
||||||
],
|
],
|
||||||
berthStatus: {
|
berthStatus: {
|
||||||
total: 120,
|
total: 115,
|
||||||
available: 80,
|
available: 80,
|
||||||
underOffer: 10,
|
underOffer: 10,
|
||||||
maintenance: 5,
|
|
||||||
sold: 25,
|
sold: 25,
|
||||||
},
|
},
|
||||||
sourceConversion: [
|
sourceConversion: [
|
||||||
|
|||||||
48
tests/unit/templates/interest-berth-label.test.ts
Normal file
48
tests/unit/templates/interest-berth-label.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
|
||||||
|
|
||||||
|
describe('deriveInterestBerthLabel', () => {
|
||||||
|
it('returns null for empty / nullish input', () => {
|
||||||
|
expect(deriveInterestBerthLabel(undefined)).toBeNull();
|
||||||
|
expect(deriveInterestBerthLabel(null)).toBeNull();
|
||||||
|
expect(deriveInterestBerthLabel([])).toBeNull();
|
||||||
|
expect(deriveInterestBerthLabel([null, undefined, ''])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single berth verbatim', () => {
|
||||||
|
expect(deriveInterestBerthLabel(['A1'])).toBe('A1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses consecutive runs into hyphenated ranges', () => {
|
||||||
|
expect(deriveInterestBerthLabel(['A1', 'A2', 'A3'])).toBe('A1-A3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins non-consecutive berths with comma', () => {
|
||||||
|
expect(deriveInterestBerthLabel(['A1', 'A3'])).toBe('A1, A3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixes ranges + standalone segments', () => {
|
||||||
|
expect(deriveInterestBerthLabel(['A1', 'A2', 'B5', 'B6', 'B7'])).toBe('A1-A2, B5-B7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a fully consecutive run as one segment regardless of length', () => {
|
||||||
|
// 20 berths but one segment - should NOT trigger the over-cap fallback.
|
||||||
|
const moorings = Array.from({ length: 20 }, (_, i) => `A${i + 1}`);
|
||||||
|
expect(deriveInterestBerthLabel(moorings)).toBe('A1-A20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to "first + N more" when post-collapse segment count exceeds 5', () => {
|
||||||
|
// Six standalone berths, all non-consecutive → 6 segments → fallback.
|
||||||
|
expect(deriveInterestBerthLabel(['A1', 'A3', 'A5', 'A7', 'A9', 'A11'])).toBe('A1 + 5 more');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the compact form when post-collapse segments are <=5', () => {
|
||||||
|
// 5 standalone berths → 5 segments → still rendered in full.
|
||||||
|
expect(deriveInterestBerthLabel(['A1', 'A3', 'A5', 'A7', 'A9'])).toBe('A1, A3, A5, A7, A9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores blank / nullish entries while preserving the rest', () => {
|
||||||
|
expect(deriveInterestBerthLabel(['A1', null, '', undefined, 'A3'])).toBe('A1, A3');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user