chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -8,19 +8,56 @@
|
||||
* 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-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 { auditLogs } from '@/lib/db/schema/system';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||
import { computeDealHealth } from './deal-health';
|
||||
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
|
||||
@@ -28,35 +65,56 @@ import type { DashboardReportData } from '@/lib/pdf/reports/dashboard-report';
|
||||
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);
|
||||
// Each fetcher returns its own shape; default to undefined to
|
||||
// signal "don't render this section" downstream.
|
||||
const data: DashboardReportData = {};
|
||||
const { from: windowFrom, to: windowTo } = parseWindow(window);
|
||||
const hasWindow = windowFrom !== null && windowTo !== null;
|
||||
|
||||
// ─── KPI / summary ───────────────────────────────────────────────
|
||||
if (want.has('kpi_overview')) {
|
||||
data.kpis = await getKpis(portId);
|
||||
}
|
||||
if (want.has('pipeline_funnel')) {
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
if (want.has('berth_status')) {
|
||||
const dist = await getBerthStatusDistribution(portId);
|
||||
// `dist` shape from the service is already the totals dict; pass
|
||||
// straight through. If the service changes shape, the type-check
|
||||
// here will trip.
|
||||
data.berthStatus = dist;
|
||||
|
||||
// ─── Berths ──────────────────────────────────────────────────────
|
||||
if (want.has('berth_status') || want.has('berth_status_donut')) {
|
||||
data.berthStatus = await getBerthStatusDistribution(portId);
|
||||
}
|
||||
if (want.has('source_conversion')) {
|
||||
|
||||
// ─── 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) => ({
|
||||
@@ -67,5 +125,553 @@ export async function resolveDashboardReportData(
|
||||
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,
|
||||
source: interests.source,
|
||||
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);
|
||||
data.newInterestsInPeriod = rows.map((r) => ({
|
||||
clientName: r.clientName,
|
||||
stage: r.stage,
|
||||
source: r.source ?? null,
|
||||
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: r.pipelineStage ?? 'open',
|
||||
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: 'EUR',
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 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 port-currency hint;
|
||||
// default to EUR which matches the seeded berths schema. A
|
||||
// multi-currency-aware breakdown would need extra plumbing.
|
||||
currency: 'EUR',
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user