Files
pn-new-crm/src/lib/services/dashboard-report-data.service.ts
Matt 41737fa950 feat(audit-session): legacy-stage canonicalization + multi-berth label sweep + PDF/UI polish
Critical data-correctness fixes
- external-eoi.service: stage-advance list rewritten against canonical
  7-stage vocab (enquiry/qualified/nurturing → eoi). Was hardcoded to
  legacy 9-stage names (open/details_sent/in_communication/eoi_sent), so
  EOI uploads from 'qualified' silently skipped the stage flip. Now also
  writes eoiDocStatus='signed' alongside eoiStatus='signed'.
- public-interest.service + api/public/interests/route: pipelineStage
  'open' → 'enquiry' for new public interests.
- interests.service: legacy 'open' gate → 'enquiry'; inline-stage-picker
  comments updated.
- Display fallbacks canonicalized: dashboard.service, dashboard-report-data,
  pdf/templates/{interest,client}-summary, interest-picker, timeline route
  all route through canonicalizeStage / stageLabelFor.

Multi-berth interest label sweep
- New helper src/lib/templates/interest-berth-label.ts with 9 unit tests
  (deriveInterestBerthLabel reuses formatBerthRange + caps at 5 segments,
  falls back to 'first + N more').
- New batched aggregator getAllBerthMooringsForInterests on the
  interest-berths service.
- BoardInterestRow + listInterests + getInterest extended with
  berthMoorings: string[].
- Swept render sites: interest-detail-header, pipeline-card +
  pipeline-column (kanban), interest-columns (list), interest-card,
  interest-detail (breadcrumb), client-pipeline-summary +
  client-interests-tab, yacht-tabs, shared interest-picker.
- PDF report "New interests (in period)" Source column → Berth column.

Dashboard PDF report fixes
- Hardcoded EUR → reads ports.default_currency once at the top of
  resolveDashboardReportData. Falls back to USD.
- 'maintenance' berth-status bucket removed everywhere (wasn't in
  canonical BERTH_STATUSES); cleaned from dashboard.service,
  dashboard-report-data, occupancy-report, berth-status-chart, fixture.
- Berth demand ranking: dropped placeholder Tier column (resolver
  hardcoded 'A' — heat-tier never plumbed through).
- Deal pulse distribution: tier values capitalized (hot → Hot etc.).
- Validator widgetIds.max 20 → 40 (catalog has 25 entries; was throwing
  "Validation failed" when all sections checked).
- Export dialog: badges tightened (text-[8px] py-px whitespace-nowrap, no
  more 2-line wraps on "needs date range"); accepts initialRange?:
  DateRange so the dashboard's active range pre-fills dateFrom/dateTo via
  rangeToBounds.

Interest banner overcounts fix
- interest-berth-status-banner: filters out self-caused under-offer
  berths (where the only active deal touching the berth IS this same
  interest). Waits for all competing-queries before committing the
  count. Was showing "3 berths unavailable" when only 1 actually had a
  competitor.

Sessions list ordering
- sessions-list: client-side sort by lastAt desc + displays lastAt
  instead of firstAt so visible timestamp matches the sort key.

Audit log polish
- Details button: side Sheet → Popover anchored to the button (in-place
  inline dropdown). Works with the virtualized table.
- From/To date pickers: width w-44 → w-52, wrapper gap-3 → gap-x-4 gap-y-3.

EntityFolderView (Documents Hub entity view)
- Per-row Download button (hover-reveal icon).
- File-type icon prefix + tighter row layout.
- Per-row interest-berth badge: files.ts attaches interestBerthLabel via
  one batched getAllBerthMooringsForInterests call across all groups.
  AggregatedFile type + EntityFolderView render the badge linking back
  to the parent interest.

External EOI upload dialog
- Title input pre-fills from the derived default via controlled
  displayTitle = title || defaultTitle (no setState-in-effect).

EOI Generate dialog
- Success toast on mutation success.
- Primary berth's "Include in EOI" checkbox is now forced-on + disabled
  with tooltip: the primary IS the canonical "berth for this deal",
  excluding it is semantically nonsense.

Primary berth must always be in EOI bundle (service + backfill)
- interest-berths.service: insert path forces is_in_eoi_bundle=true
  whenever is_primary=true; update path coerces back to true when the
  caller tries to set false on a primary. Backfilled 7 existing rows.

Documenso redirect URL fallback
- port-config getPortDocumensoConfig: resolution chain extended to
  documenso_redirect_url → public_site_url → null. Operators with
  public_site_url configured (most ports) now get sensible signer
  landing without setting two settings.

World-map click → navigate
- website-analytics-shell: country click navigates to the nationality-
  filtered Clients page via router.push instead of copying a URL to
  clipboard.

Documents Hub: subfolder grid in main panel
- Subfolder cards rendered above the documents list when the current
  folder has children. Lets reps drill into subfolders from the main
  content area, not only via the sidebar tree.

Interest list initial sort
- usePaginatedQuery gains initialSort option (used when URL has no sort
  param). Interest list passes updatedAt desc so the table header
  surfaces the active sort visibly + most-recently-added/edited bubble
  to the top.

Interest auto-assign on create
- interests.service createInterest: three-tier owner resolution chain
  — explicit input → port's default_new_interest_owner setting →
  creator (when not super-admin). Super-admins skipped since they often
  create on behalf of other reps.

Backfills
- 12 interests with eoi_status='signed' + missing eoi_doc_status='signed'
  aligned.
- 7 interest_berths rows with is_primary=true but is_in_eoi_bundle=false
  flipped to true.

Verified
- pnpm tsc --noEmit: clean
- pnpm exec vitest run: 1463 / 1463 passed

Captured 25+ additional UAT findings to docs/superpowers/audits/alpha-uat-master.md
across all 4 buckets, including two OPEN QUESTIONS (Reservations module
re-imagine, Reports dedicated page promotion).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:41:27 +02:00

697 lines
28 KiB
TypeScript

/**
* 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
* 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';
import { ports } from '@/lib/db/schema/ports';
import { auditLogs } from '@/lib/db/schema/system';
import { userProfiles } from '@/lib/db/schema/users';
import { canonicalizeStage } from '@/lib/constants';
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
import { computeDealHealth } from './deal-health';
import { getAllBerthMooringsForInterests } from './interest-berths.service';
import { deriveInterestBerthLabel } from '@/lib/templates/interest-berth-label';
import {
getKpis,
getPipelineCounts,
getBerthStatusDistribution,
getHotDeals,
getRevenueForecast,
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';
/**
* 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>([]);
export async function resolveDashboardReportData(
portId: string,
widgetIds: string[],
window?: DashboardReportWindow,
): Promise<DashboardReportData> {
const want = new Set(widgetIds);
const data: DashboardReportData = {};
const { from: windowFrom, to: windowTo } = parseWindow(window);
const hasWindow = windowFrom !== null && windowTo !== null;
// Resolve the port's configured currency once - every money-bearing
// section reads it for the Intl.NumberFormat output. Falls back to USD
// for any port row without a default set (schema default is also USD).
const portRow = await db
.select({ defaultCurrency: ports.defaultCurrency })
.from(ports)
.where(eq(ports.id, portId))
.limit(1);
const portCurrency = portRow[0]?.defaultCurrency ?? 'USD';
// ─── KPI / summary ───────────────────────────────────────────────
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')) {
data.pipelineCounts = await getPipelineCounts(portId);
}
// ─── Berths ──────────────────────────────────────────────────────
if (want.has('berth_status') || want.has('berth_status_donut')) {
data.berthStatus = await getBerthStatusDistribution(portId);
}
// ─── Sources ─────────────────────────────────────────────────────
if (want.has('source_conversion') || want.has('source_conversion_chart')) {
data.sourceConversion = await getSourceConversion(portId);
}
// ─── Deals ───────────────────────────────────────────────────────
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);
// 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,
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({
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,
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,
// 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;
return data;
}