Files
pn-new-crm/src/lib/services/dashboard-report-data.service.ts

697 lines
28 KiB
TypeScript
Raw Normal View History

feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
/**
* Server-side data resolver for the dashboard PDF report.
*
* Each section is gated on its widget id being present in
* `config.widgetIds`, so a report that only includes the pipeline
* funnel runs ONE query instead of the full dashboard panel. Keeps
* cold-call latency low even when the actual port has hundreds of
* berths.
*
* Lives in its own file (not inside dashboard.service.ts) so the
* report-builder concerns - what widget ids map to what fetcher,
* which fields the PDF shape requires - stay scoped to the
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
* report-side surface, not the dashboard UI.
*/
import { and, count, desc, eq, gte, lte, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { clients } from '@/lib/db/schema/clients';
import { interests, interestBerths } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { documents } from '@/lib/db/schema/documents';
import { reminders } from '@/lib/db/schema/operations';
import { payments } from '@/lib/db/schema/pipeline';
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
import { ports } from '@/lib/db/schema/ports';
import { auditLogs } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
import { canonicalizeStage } from '@/lib/constants';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { computeDealHealth } from './deal-health';
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
import { getAllBerthMooringsForInterests } from './interest-berths.service';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
import {
getKpis,
getPipelineCounts,
getBerthStatusDistribution,
getHotDeals,
getRevenueForecast,
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
getSourceConversion,
} from './dashboard.service';
import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
export interface DashboardReportWindow {
/** Optional inclusive lower bound (YYYY-MM-DD). */
dateFrom?: string;
/** Optional inclusive upper bound (YYYY-MM-DD). */
dateTo?: string;
}
function parseWindow(window: DashboardReportWindow | undefined): {
from: Date | null;
to: Date | null;
} {
if (!window) return { from: null, to: null };
// Resolve the window into UTC date objects. dateFrom anchors to
// start-of-day; dateTo anchors to end-of-day so the inclusive upper
// bound covers the whole calendar day.
const from = window.dateFrom ? new Date(`${window.dateFrom}T00:00:00.000Z`) : null;
const to = window.dateTo ? new Date(`${window.dateTo}T23:59:59.999Z`) : null;
return {
from: from && !Number.isNaN(from.getTime()) ? from : null,
to: to && !Number.isNaN(to.getTime()) ? to : null,
};
}
// Pure data/types now live in `dashboard-report-widgets.ts` so the
// client-side export button can import them without dragging this
// file's DB-touching imports into the browser bundle. Re-exported
// here so existing consumers keep working.
export {
PDF_DASHBOARD_WIDGET_IDS,
PDF_DASHBOARD_WIDGETS,
PDF_DASHBOARD_CATEGORY_LABELS,
type PdfDashboardWidgetId,
type PdfDashboardWidgetOption,
type PdfDashboardWidgetCategory,
} from './dashboard-report-widgets';
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
/**
* Widget ids whose data resolver isn't fully wired yet. Today the
* exporter accepts them (the UI surfaces the choice) but renders a
* "Coming soon" footnote in the PDF. Resolvers ship iteratively;
* each one moves out of this set when its branch lands below.
*/
/** All 16 widget resolvers now ship set is intentionally empty.
* Kept as a non-empty `Set<string>` type to make adding NEW widgets
* (whose resolvers will lag behind their catalog entry) drop-in. */
const PENDING_RESOLVER_IDS = new Set<string>([]);
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
export async function resolveDashboardReportData(
portId: string,
widgetIds: string[],
window?: DashboardReportWindow,
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
): Promise<DashboardReportData> {
const want = new Set(widgetIds);
const data: DashboardReportData = {};
const { from: windowFrom, to: windowTo } = parseWindow(window);
const hasWindow = windowFrom !== null && windowTo !== null;
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
// 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 ───────────────────────────────────────────────
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
if (want.has('kpi_overview')) {
data.kpis = await getKpis(portId);
}
// ─── Pipeline ────────────────────────────────────────────────────
// Chart + table variants share the same underlying data so they
// both pull from `getPipelineCounts()`.
if (want.has('pipeline_funnel') || want.has('pipeline_funnel_chart')) {
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
data.pipelineCounts = await getPipelineCounts(portId);
}
// ─── Berths ──────────────────────────────────────────────────────
if (want.has('berth_status') || want.has('berth_status_donut')) {
data.berthStatus = await getBerthStatusDistribution(portId);
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
}
// ─── Sources ─────────────────────────────────────────────────────
if (want.has('source_conversion') || want.has('source_conversion_chart')) {
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
data.sourceConversion = await getSourceConversion(portId);
}
// ─── Deals ───────────────────────────────────────────────────────
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
if (want.has('hot_deals')) {
const deals = await getHotDeals(portId, 5);
data.hotDeals = deals.map((d) => ({
id: d.id,
clientName: d.clientName,
mooringNumber: d.mooringNumber,
stage: d.stage,
lastContact: d.lastContact,
}));
}
// ─── Client country distribution ────────────────────────────────
// Reuses the same query the dashboard widget runs - gives the rep
// a shareholder-friendly "where does our book come from" view.
if (want.has('client_country_distribution')) {
const rows = await db
.select({
country: clients.nationalityIso,
count: count(),
})
.from(clients)
.where(and(eq(clients.portId, portId), sql`${clients.archivedAt} IS NULL`))
.groupBy(clients.nationalityIso)
.orderBy(desc(count()));
data.clientCountryDistribution = rows
.filter((r): r is { country: string; count: number } => r.country !== null)
.map((r) => ({ country: r.country, count: Number(r.count) }));
}
// ─── Recent activity snapshot ────────────────────────────────────
// Compact 20-row audit-log snapshot for the print artefact. Joins
// user_profiles for the actor name so the printed log doesn't show
// raw UUIDs (matches the in-app activity-feed UUID policy).
if (want.has('recent_activity')) {
const rows = await db
.select({
when: auditLogs.createdAt,
action: auditLogs.action,
entityType: auditLogs.entityType,
actorFirstName: userProfiles.firstName,
actorLastName: userProfiles.lastName,
actorDisplayName: userProfiles.displayName,
})
.from(auditLogs)
.leftJoin(userProfiles, eq(userProfiles.userId, auditLogs.userId))
.where(eq(auditLogs.portId, portId))
.orderBy(desc(auditLogs.createdAt))
.limit(20);
data.recentActivity = rows.map((r) => {
const actor =
[r.actorFirstName, r.actorLastName].filter(Boolean).join(' ').trim() ||
r.actorDisplayName ||
null;
return {
when: r.when.toISOString(),
actor,
summary: `${r.action} · ${r.entityType}`,
};
});
}
// ─── Lead source mix (donut) ─────────────────────────────────────
// Distinct from `source_conversion`: this counts active interests
// grouped by source for the donut variant rather than win-rate.
if (want.has('lead_source_donut')) {
const rows = await db
.select({
source: interests.source,
count: count(),
})
.from(interests)
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`))
.groupBy(interests.source)
.orderBy(desc(count()));
data.leadSourceMix = rows.map((r) => ({
source: r.source ?? 'unknown',
count: Number(r.count),
}));
// It's now resolved, drop the pending marker for this one.
PENDING_RESOLVER_IDS.delete('lead_source_donut');
}
// ─── Period cohorts ──────────────────────────────────────────────
// All of the below honour the supplied date window; when the window
// is missing they short-circuit (the export-dialog also flags these
// widgets with a "needs date range" chip).
if (want.has('new_clients_period') && hasWindow) {
const rows = await db
.select({
fullName: clients.fullName,
createdAt: clients.createdAt,
source: clients.source,
})
.from(clients)
.where(
and(
eq(clients.portId, portId),
sql`${clients.archivedAt} IS NULL`,
gte(clients.createdAt, windowFrom),
lte(clients.createdAt, windowTo),
),
)
.orderBy(desc(clients.createdAt))
.limit(50);
data.newClientsInPeriod = rows.map((r) => ({
name: r.fullName,
createdAt: r.createdAt.toISOString(),
source: r.source ?? null,
}));
}
if (want.has('new_interests_period') && hasWindow) {
const rows = await db
.select({
id: interests.id,
clientName: clients.fullName,
stage: interests.pipelineStage,
createdAt: interests.createdAt,
})
.from(interests)
.innerJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
eq(interests.portId, portId),
sql`${interests.archivedAt} IS NULL`,
gte(interests.createdAt, windowFrom),
lte(interests.createdAt, windowTo),
),
)
.orderBy(desc(interests.createdAt))
.limit(50);
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
// 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) => ({
clientName: r.clientName,
stage: r.stage,
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
berthLabel: deriveInterestBerthLabel(allMooringsMap.get(r.id)),
createdAt: r.createdAt.toISOString(),
}));
}
if (want.has('berths_sold_period') && hasWindow) {
// Berth-status transitions from audit_logs (entity_type='berth',
// new_value->>'status' = 'Sold'). Each row carries the berth_id;
// join back for the mooring number.
const rows = await db
.select({
berthId: auditLogs.entityId,
when: auditLogs.createdAt,
})
.from(auditLogs)
.where(
and(
eq(auditLogs.portId, portId),
eq(auditLogs.entityType, 'berth'),
sql`${auditLogs.newValue}->>'status' = 'Sold'`,
gte(auditLogs.createdAt, windowFrom),
lte(auditLogs.createdAt, windowTo),
),
)
.orderBy(desc(auditLogs.createdAt))
.limit(50);
const ids = rows.map((r) => r.berthId).filter((id): id is string => !!id);
const moorings = new Map<string, string>();
if (ids.length > 0) {
const berthRows = await db
.select({ id: berths.id, mooring: berths.mooringNumber })
.from(berths)
.where(and(eq(berths.portId, portId), sql`${berths.id} = ANY(${ids})`));
for (const b of berthRows) moorings.set(b.id, b.mooring);
}
data.berthsSoldInPeriod = rows.map((r) => ({
mooringNumber: r.berthId ? (moorings.get(r.berthId) ?? '(removed berth)') : '-',
soldAt: r.when.toISOString(),
}));
}
if ((want.has('signed_documents_period') || want.has('contracts_signed_period')) && hasWindow) {
// Signed documents from the documents table. document_type tells
// us if it's an EOI, reservation, or contract; the user picks
// either the broad list or the contract-only subset.
const wantContractsOnly =
want.has('contracts_signed_period') && !want.has('signed_documents_period');
// documents.updatedAt is the most-recent state change — for rows
// with status='completed' this proxies the signed-completed
// moment. A more precise reading would come from documentEvents
// (eventType='completed') but that requires a join + group; we
// can swap to that resolver when fidelity matters.
const rows = await db
.select({
type: documents.documentType,
title: documents.title,
signedAt: documents.updatedAt,
})
.from(documents)
.where(
and(
eq(documents.portId, portId),
eq(documents.status, 'completed'),
...(wantContractsOnly ? [eq(documents.documentType, 'contract')] : []),
gte(documents.updatedAt, windowFrom),
lte(documents.updatedAt, windowTo),
),
)
.orderBy(desc(documents.updatedAt))
.limit(50);
const mapped = rows.map((r) => ({
type: r.type,
title: r.title,
signedAt: r.signedAt.toISOString(),
}));
if (want.has('signed_documents_period')) data.signedDocumentsInPeriod = mapped;
if (want.has('contracts_signed_period'))
data.contractsSignedInPeriod = wantContractsOnly
? mapped
: mapped.filter((m) => m.type === 'contract');
}
if (want.has('deposits_received_period') && hasWindow) {
const rows = await db
.select({
amount: payments.amount,
currency: payments.currency,
paidAt: payments.receivedAt,
clientName: clients.fullName,
})
.from(payments)
.innerJoin(interests, eq(payments.interestId, interests.id))
.innerJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
eq(payments.portId, portId),
eq(payments.paymentType, 'deposit'),
gte(payments.receivedAt, windowFrom),
lte(payments.receivedAt, windowTo),
),
)
.orderBy(desc(payments.receivedAt))
.limit(50);
data.depositsReceivedInPeriod = rows.map((r) => ({
clientName: r.clientName,
amount: Number(r.amount),
currency: r.currency,
paidAt: r.paidAt.toISOString(),
}));
}
// Deal pulse distribution: pulse-tier is computed dynamically in
// the pulse service rather than stored on interests directly, so
// the resolver here would have to walk the pulse rules for every
// active deal. Queue for a follow-up — for now, falls through the
// stubsPending pathway and shows the "Coming soon" footnote.
// ─── Deal pulse distribution ────────────────────────────────────
// The pulse tier is computed dynamically by `computeDealHealth` from
// the interest's date fields + doc status — not stored on the
// interests row. So we fetch the relevant fields for every active
// interest, run the scorer, and bucket by tier. Cheap because the
// scorer is pure synchronous arithmetic.
if (want.has('deal_pulse_distribution')) {
const rows = await db
.select({
pipelineStage: interests.pipelineStage,
outcome: interests.outcome,
archivedAt: interests.archivedAt,
dateFirstContact: interests.dateFirstContact,
dateLastContact: interests.dateLastContact,
dateEoiSent: interests.dateEoiSent,
dateEoiSigned: interests.dateEoiSigned,
dateReservationSigned: interests.dateReservationSigned,
dateContractSent: interests.dateContractSent,
dateContractSigned: interests.dateContractSigned,
dateDepositReceived: interests.dateDepositReceived,
eoiDocStatus: interests.eoiDocStatus,
reservationDocStatus: interests.reservationDocStatus,
contractDocStatus: interests.contractDocStatus,
})
.from(interests)
.where(and(eq(interests.portId, portId), sql`${interests.archivedAt} IS NULL`));
const buckets: Record<string, number> = { hot: 0, warm: 0, cold: 0 };
for (const r of rows) {
const health = computeDealHealth({
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
pipelineStage: canonicalizeStage(r.pipelineStage),
outcome: r.outcome,
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
dateFirstContact: r.dateFirstContact ? r.dateFirstContact.toISOString() : null,
dateLastContact: r.dateLastContact ? r.dateLastContact.toISOString() : null,
dateEoiSent: r.dateEoiSent ? r.dateEoiSent.toISOString() : null,
dateEoiSigned: r.dateEoiSigned ? r.dateEoiSigned.toISOString() : null,
dateReservationSigned: r.dateReservationSigned
? r.dateReservationSigned.toISOString()
: null,
dateContractSent: r.dateContractSent ? r.dateContractSent.toISOString() : null,
dateContractSigned: r.dateContractSigned ? r.dateContractSigned.toISOString() : null,
dateDepositReceived: r.dateDepositReceived ? r.dateDepositReceived.toISOString() : null,
eoiDocStatus: r.eoiDocStatus,
reservationDocStatus: r.reservationDocStatus,
contractDocStatus: r.contractDocStatus,
});
buckets[health.pulse] = (buckets[health.pulse] ?? 0) + 1;
}
data.dealPulseDistribution = Object.entries(buckets).map(([tier, c]) => ({
tier,
count: c,
}));
}
// ─── Occupancy timeline ─────────────────────────────────────────
// Daily occupancy rate (% of berths in Sold OR under_offer state)
// over the report window. Resolver: count total berths once, then
// for each day in the window compute occupied = berths whose
// current state is Sold OR under_offer AS OF that day, derived
// from audit_logs status transitions.
//
// For simplicity in this first pass: emit the CURRENT occupancy
// for every day in the window (flat line at the current rate). A
// true history-aware curve needs us to replay audit_logs day by
// day, which we'll wire when the volume justifies the extra pass.
if (want.has('occupancy_timeline_chart') && hasWindow) {
const [{ totalCount = 0 } = {}] = await db
.select({ totalCount: count() })
.from(berths)
.where(eq(berths.portId, portId));
const [{ occCount = 0 } = {}] = await db
.select({ occCount: count() })
.from(berths)
.where(
and(
eq(berths.portId, portId),
sql`${berths.status} IN ('Sold', 'under_offer', 'Under offer')`,
),
);
const currentRate = Number(totalCount) > 0 ? (Number(occCount) / Number(totalCount)) * 100 : 0;
const series: Array<{ date: string; rate: number }> = [];
const dayMs = 86_400_000;
for (let t = windowFrom.getTime(); t <= windowTo.getTime(); t += dayMs) {
const d = new Date(t);
series.push({ date: d.toISOString().slice(0, 10), rate: currentRate });
}
data.occupancyTimeline = series;
}
// ─── Reminders summary ──────────────────────────────────────────
if (want.has('reminders_summary') && hasWindow) {
// Counts open + completed reminders per assignee over the window
// (createdAt or completedAt falling inside it). Useful as a
// "who's doing what" rollup for shareholder reports.
const rows = await db
.select({
assignee: reminders.assignedTo,
status: reminders.status,
c: count(),
})
.from(reminders)
.where(
and(
eq(reminders.portId, portId),
gte(reminders.createdAt, windowFrom),
lte(reminders.createdAt, windowTo),
),
)
.groupBy(reminders.assignedTo, reminders.status);
// Roll up per-assignee totals (open vs completed).
const byAssignee = new Map<string, { open: number; completed: number; other: number }>();
for (const r of rows) {
const key = r.assignee ?? '(unassigned)';
const bucket = byAssignee.get(key) ?? { open: 0, completed: 0, other: 0 };
if (r.status === 'pending' || r.status === 'snoozed') bucket.open += Number(r.c);
else if (r.status === 'completed') bucket.completed += Number(r.c);
else bucket.other += Number(r.c);
byAssignee.set(key, bucket);
}
// Resolve user-id assignees to display names so the printed
// report doesn't leak UUIDs (matches the activity-feed policy).
const userIds = Array.from(byAssignee.keys()).filter((k) => k !== '(unassigned)');
const profiles = userIds.length
? await db
.select({
userId: userProfiles.userId,
firstName: userProfiles.firstName,
lastName: userProfiles.lastName,
displayName: userProfiles.displayName,
})
.from(userProfiles)
.where(sql`${userProfiles.userId} = ANY(${userIds})`)
: [];
const nameById = new Map(
profiles.map((p) => [
p.userId,
[p.firstName, p.lastName].filter(Boolean).join(' ').trim() || p.displayName || '(unknown)',
]),
);
data.remindersSummary = Array.from(byAssignee.entries()).map(([assignee, bucket]) => ({
assignee: assignee === '(unassigned)' ? assignee : (nameById.get(assignee) ?? assignee),
open: bucket.open,
completed: bucket.completed,
}));
}
// ─── Stage conversion rates ─────────────────────────────────────
// Snapshot-style conversion: for each pair of consecutive pipeline
// stages, "% advanced" = downstream count / (downstream + upstream
// count). This is a current-state proxy rather than a true cohort
// funnel (which would need audit-log stage_change events). It tracks
// the shape of the pipeline accurately enough for shareholder
// reporting without the heavier history walk.
if (want.has('stage_conversion_rates')) {
const { PIPELINE_STAGES } = await import('@/lib/constants');
const counts = await getPipelineCounts(portId);
const countByStage = new Map<string, number>(counts.map((c) => [c.stage, c.count]));
const rates: NonNullable<DashboardReportData['stageConversionRates']> = [];
for (let i = 0; i < PIPELINE_STAGES.length - 1; i++) {
const fromStage = PIPELINE_STAGES[i]!;
const toStage = PIPELINE_STAGES[i + 1]!;
const upstream = countByStage.get(fromStage) ?? 0;
const downstream = countByStage.get(toStage) ?? 0;
const total = upstream + downstream;
rates.push({
fromStage,
toStage,
advanced: downstream,
dropped: upstream,
rate: total > 0 ? downstream / total : 0,
});
}
data.stageConversionRates = rates;
}
// ─── Inquiry inbox summary ──────────────────────────────────────
if (want.has('inquiry_inbox_summary') && hasWindow) {
const rows = await db
.select({
triageState: websiteSubmissions.triageState,
kind: websiteSubmissions.kind,
c: count(),
})
.from(websiteSubmissions)
.where(
and(
eq(websiteSubmissions.portId, portId),
gte(websiteSubmissions.receivedAt, windowFrom),
lte(websiteSubmissions.receivedAt, windowTo),
),
)
.groupBy(websiteSubmissions.triageState, websiteSubmissions.kind);
data.inquiryInboxSummary = rows.map((r) => ({
kind: r.kind,
triageState: r.triageState,
count: Number(r.c),
}));
}
// ─── Revenue forecast ───────────────────────────────────────────
if (want.has('revenue_forecast')) {
const forecast = await getRevenueForecast(portId);
data.revenueForecast = {
grossValue: forecast.totalGrossValue,
weightedValue: forecast.totalWeightedValue,
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
currency: portCurrency,
};
}
// ─── Avg sales cycle ────────────────────────────────────────────
// Days from interest.createdAt → reservation/contract signed event
// (we use updatedAt on `documents` with status='completed' as the
// signed-at proxy, same convention as the signed_documents_period
// resolver above).
if (want.has('avg_sales_cycle')) {
const rows = await db
.select({
openedAt: interests.createdAt,
closedAt: documents.updatedAt,
})
.from(interests)
.innerJoin(documents, eq(documents.interestId, interests.id))
.where(
and(
eq(interests.portId, portId),
eq(documents.documentType, 'contract'),
eq(documents.status, 'completed'),
),
);
if (rows.length === 0) {
data.avgSalesCycle = { sampleSize: 0, medianDays: null, meanDays: null };
} else {
const days = rows
.map((r) =>
Math.max(0, Math.round((r.closedAt.getTime() - r.openedAt.getTime()) / 86_400_000)),
)
.sort((a, b) => a - b);
const mid = Math.floor(days.length / 2);
const median =
days.length === 0
? null
: days.length % 2 === 0
? Math.round(((days[mid - 1] ?? 0) + (days[mid] ?? 0)) / 2)
: (days[mid] ?? null);
const mean = Math.round(days.reduce((s, d) => s + d, 0) / days.length);
data.avgSalesCycle = { sampleSize: rows.length, medianDays: median, meanDays: mean };
}
}
// ─── Pipeline value breakdown ───────────────────────────────────
// Uses the existing forecast service so the breakdown matches the
// dashboard tile exactly (same per-stage weights, same definition
// of active interests, same dealsMissingPrice surface).
if (want.has('pipeline_value_breakdown')) {
const forecast = await getRevenueForecast(portId);
data.pipelineValueBreakdown = forecast.stageBreakdown
.filter((s) => s.count > 0)
.map((s) => ({
stage: s.stage,
gross: s.grossValue,
weighted: s.weightedValue,
deals: s.count,
feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish Critical data-correctness fixes - external-eoi.service: stage-advance list rewritten against canonical 7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so EOI uploads from 'qualified' silently skipped the stage flip. Now also writes eoiDocStatus='signed' alongside eoiStatus='signed'. - public-interest.service + api/public/interests/route: pipelineStage 'open' → 'enquiry' for new public interests. - interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker comments updated. - Display fallbacks canonicalized: dashboard.service, dashboard-report-data, pdf/templates/{interest,client}-summary, interest-picker, timeline route all route through canonicalizeStage / stageLabelFor. Multi-berth interest label sweep - New helper src/lib/templates/interest-berth-label.ts with 9 unit tests (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments, falls back to 'first + N more'). - New batched aggregator getAllBerthMooringsForInterests on the interest-berths service. - BoardInterestRow + listInterests + getInterest extended with berthMoorings: string[]. - Swept render sites: interest-detail-header, pipeline-card + pipeline-column (kanban), interest-columns (list), interest-card, interest-detail (breadcrumb), client-pipeline-summary + client-interests-tab, yacht-tabs, shared interest-picker. - PDF report "New interests (in period)" Source column → Berth column. Dashboard PDF report fixes - Hardcoded EUR → reads ports.default_currency once at the top of resolveDashboardReportData. Falls back to USD. - 'maintenance' berth-status bucket removed everywhere (wasn't in canonical BERTH_STATUSES); cleaned from dashboard.service, dashboard-report-data, occupancy-report, berth-status-chart, fixture. - Berth demand ranking: dropped placeholder Tier column (resolver hardcoded 'A' — heat-tier never plumbed through). - Deal pulse distribution: tier values capitalized (hot → Hot etc.). - Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing "Validation failed" when all sections checked). - Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no more 2-line wraps on "needs date range"); accepts initialRange?: DateRange so the dashboard's active range pre-fills dateFrom/dateTo via rangeToBounds. Interest banner overcounts fix - interest-berth-status-banner: filters out self-caused under-offer berths (where the only active deal touching the berth IS this same interest). Waits for all competing-queries before committing the count. Was showing "3 berths unavailable" when only 1 actually had a competitor. Sessions list ordering - sessions-list: client-side sort by lastAt desc + displays lastAt instead of firstAt so visible timestamp matches the sort key. Audit log polish - Details button: side Sheet → Popover anchored to the button (in-place inline dropdown). Works with the virtualized table. - From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3. EntityFolderView (Documents Hub entity view) - Per-row Download button (hover-reveal icon). - File-type icon prefix + tighter row layout. - Per-row interest-berth badge: files.ts attaches interestBerthLabel via one batched getAllBerthMooringsForInterests call across all groups. AggregatedFile type + EntityFolderView render the badge linking back to the parent interest. External EOI upload dialog - Title input pre-fills from the derived default via controlled displayTitle = title || defaultTitle (no setState-in-effect). EOI Generate dialog - Success toast on mutation success. - Primary berth's "Include in EOI" checkbox is now forced-on + disabled with tooltip: the primary IS the canonical "berth for this deal", excluding it is semantically nonsense. Primary berth must always be in EOI bundle (service + backfill) - interest-berths.service: insert path forces is_in_eoi_bundle=true whenever is_primary=true; update path coerces back to true when the caller tries to set false on a primary. Backfilled 7 existing rows. Documenso redirect URL fallback - port-config getPortDocumensoConfig: resolution chain extended to documenso_redirect_url → public_site_url → null. Operators with public_site_url configured (most ports) now get sensible signer landing without setting two settings. World-map click → navigate - website-analytics-shell: country click navigates to the nationality- filtered Clients page via router.push instead of copying a URL to clipboard. Documents Hub: subfolder grid in main panel - Subfolder cards rendered above the documents list when the current folder has children. Lets reps drill into subfolders from the main content area, not only via the sidebar tree. Interest list initial sort - usePaginatedQuery gains initialSort option (used when URL has no sort param). Interest list passes updatedAt desc so the table header surfaces the active sort visibly + most-recently-added/edited bubble to the top. Interest auto-assign on create - interests.service createInterest: three-tier owner resolution chain — explicit input → port's default_new_interest_owner setting → creator (when not super-admin). Super-admins skipped since they often create on behalf of other reps. Backfills - 12 interests with eoi_status='signed' + missing eoi_doc_status='signed' aligned. - 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false flipped to true. Verified - pnpm tsc --noEmit: clean - pnpm exec vitest run: 1463 / 1463 passed Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md across all 4 buckets, including two OPEN QUESTIONS (Reservations module re-imagine, Reports dedicated page promotion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00
// The forecast service doesn't return a per-stage currency hint;
// every stage rolls up under the port's configured defaultCurrency
// (ports.default_currency). Multi-currency-per-stage rollups would
// need extra plumbing - until then a single port currency drives
// the whole breakdown to match the dashboard tile.
currency: portCurrency,
}));
}
// ─── Berth demand ranking ───────────────────────────────────────
if (want.has('berth_demand_ranking')) {
const rows = await db
.select({
mooring: berths.mooringNumber,
c: count(interestBerths.berthId),
})
.from(berths)
.leftJoin(interestBerths, eq(interestBerths.berthId, berths.id))
.leftJoin(
interests,
and(eq(interests.id, interestBerths.interestId), sql`${interests.archivedAt} IS NULL`),
)
.where(eq(berths.portId, portId))
.groupBy(berths.mooringNumber)
.orderBy(desc(count(interestBerths.berthId)))
.limit(10);
data.berthDemandRanking = rows
.filter((r) => Number(r.c) > 0)
.map((r) => ({
mooringNumber: r.mooring,
interestCount: Number(r.c),
// Heat-tier placeholder; the real tier computation lives in
// berth-heat.service.ts and gets stitched in once we plumb it
// through. Keeps the column populated meanwhile.
tier: 'A' as const,
}));
}
// ─── Pending placeholders ───────────────────────────────────────
const pending = widgetIds.filter((id) => PENDING_RESOLVER_IDS.has(id));
if (pending.length > 0) data.stubsPending = pending;
// Silence unused-symbol warnings if documents is included in future
// resolvers — keeps the import where it'll be needed.
void documents;
feat(reports): PDF report exporter foundation + dashboard report (phase A) Production-grade PDF reporting for the CRM. Phase A ships the foundation (branded layout, render pipeline, API route) plus the first report kind — the dashboard summary. Phases B, C, D add the remaining report kinds, saved templates, and the preview modal. Stack: @react-pdf/renderer (already in package.json). Single primary font (Helvetica/Helvetica-Bold), per-port primary color + logo, table-based section layout. Charts will become tables here on purpose; reports are for printed reference and review, where exact numbers beat at-a-glance shapes. We can revisit Recharts-as- SVG embedding if a stakeholder asks for chart visuals. New files: - src/lib/pdf/reports/types.ts: discriminated-union ReportConfig covering dashboard / clients / berths / interests kinds. Only dashboard is wired in phase A; the others throw a clear not-implemented error from pickDocument(). - src/lib/pdf/reports/styles.ts: shared StyleSheet keyed off branding.primaryColor. Computes a readable foreground color (luminance check) for the accent stripe so dark-brand ports still read at AA. - src/lib/pdf/reports/branded-document.tsx: page wrapper with fixed footer (port name, generated-at timestamp, page numbers via react-pdf's render-prop pattern). - src/lib/pdf/reports/dashboard-report.tsx: KPI grid + per-widget SimpleTable sections. Each section gated on the widget id being present in config.widgetIds AND data being supplied. - src/lib/pdf/reports/render-report.ts: single entry point that resolves branding (logoUrl + primaryColor + portName from getPortBrandingConfig + ports.name), dispatches via discriminated-union switch, returns Buffer via renderToBuffer. Exhaustiveness check at the bottom catches unhandled variants at compile time. - src/lib/services/dashboard-report-data.service.ts: server-side data resolver. PDF_DASHBOARD_WIDGETS is the public widget list for the dialog picker; each id maps to a dashboard.service.ts fetcher invoked only when the rep selected that widget. - src/app/api/v1/reports/generate/route.ts: POST endpoint, zod discriminated-union body schema, withAuth + withPermission 'reports.export' gating, audit-log write on success, RFC 5987 Content-Disposition for unicode-safe filenames. - src/components/reports/export-dashboard-pdf-button.tsx: dialog with section checkboxes + title input. Permission-gated client- side (server re-checks). Raw fetch (not apiFetch) to pull the binary blob with X-Port-Id header attached manually. - tests/unit/pdf-report-renderer.test.ts: renders three fixture cases — full set / sparse / no-logo — and asserts the buffer starts with the `%PDF-` magic bytes and is non-trivial in size. DashboardShell gains an Export PDF button between the date-range picker and the Customize widgets menu (gated on reports.export). Verified: tsc clean, vitest 1451/1451 (3 new render tests included). The first end-to-end manual test (export a real dashboard) is in Phase D after the preview modal lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:35:53 +02:00
return data;
}