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
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { and, count, eq, gte, isNull, lte, sql, sum } from 'drizzle-orm';
|
||
|
||
import { db } from '@/lib/db';
|
||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||
import { berths } from '@/lib/db/schema/berths';
|
||
import { auditLogs, systemSettings } from '@/lib/db/schema/system';
|
||
import { STAGE_WEIGHTS, canonicalizeStage } from '@/lib/constants';
|
||
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
||
import {
|
||
rollupStageRevenue,
|
||
rollupStageCounts,
|
||
rollupBerthStatusCounts,
|
||
computeOccupancyRate,
|
||
computeTotalForecast,
|
||
} from '@/lib/services/report-math';
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||
|
||
export interface PipelineData {
|
||
stageCounts: Record<string, number>;
|
||
topInterests: Array<{
|
||
id: string;
|
||
clientId: string;
|
||
pipelineStage: string;
|
||
berthPrice: string | null;
|
||
}>;
|
||
generatedAt: string;
|
||
}
|
||
|
||
export interface RevenueData {
|
||
/** Gross berth prices per pipeline stage (unweighted). */
|
||
stageRevenue: Record<string, string>;
|
||
/** Money-changed-hands total: sum of berth prices for won deals. */
|
||
totalCompleted: string;
|
||
/** Pipeline-weighted forecast: sum of (berth price × stage weight)
|
||
* for every active interest. Aligns with the dashboard forecast tile
|
||
* so the PDF and dashboard reconcile. */
|
||
totalForecast: string;
|
||
/** Pipeline weights actually applied (port-customizable). Echoes
|
||
* `system_settings.pipeline_weights` when set, otherwise the
|
||
* STAGE_WEIGHTS defaults. */
|
||
pipelineWeights: Record<string, number>;
|
||
generatedAt: string;
|
||
}
|
||
|
||
export interface ActivityData {
|
||
logs: Array<{
|
||
id: string;
|
||
action: string;
|
||
entityType: string;
|
||
entityId: string | null;
|
||
userId: string | null;
|
||
createdAt: Date;
|
||
}>;
|
||
summary: Record<string, number>;
|
||
generatedAt: string;
|
||
}
|
||
|
||
export interface OccupancyData {
|
||
statusCounts: Record<string, number>;
|
||
occupancyRate: number;
|
||
totalBerths: number;
|
||
generatedAt: string;
|
||
}
|
||
|
||
// ─── Pipeline ─────────────────────────────────────────────────────────────────
|
||
|
||
export async function fetchPipelineData(
|
||
portId: string,
|
||
_params: Record<string, unknown>,
|
||
): Promise<PipelineData> {
|
||
// Count interests per pipeline stage (non-archived).
|
||
// The reporting audit caught the missing .groupBy() - without it,
|
||
// postgres rejects the SELECT or collapses every interest into a
|
||
// single ELSE-stage row. groupBy fixes the per-stage breakdown.
|
||
const stageCounts = await db
|
||
.select({
|
||
stage: interests.pipelineStage,
|
||
count: count(),
|
||
})
|
||
.from(interests)
|
||
.where(activeInterestsWhere(portId))
|
||
.groupBy(interests.pipelineStage);
|
||
|
||
// M-L02: legacy 9-stage values (deposit_10pct, contract_sent…) may
|
||
// still be present on historical rows. rollupStageCounts canonicalizes
|
||
// via canonicalizeStage so historical rows fold into the modern bucket.
|
||
const stageCountMap = rollupStageCounts(stageCounts);
|
||
|
||
// Top 10 interests by berth price (via primary-berth junction join, plan §3.4).
|
||
const topInterestsRows = await db
|
||
.select({
|
||
id: interests.id,
|
||
clientId: interests.clientId,
|
||
pipelineStage: interests.pipelineStage,
|
||
berthPrice: berths.price,
|
||
})
|
||
.from(interests)
|
||
.leftJoin(
|
||
interestBerths,
|
||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||
)
|
||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||
.where(activeInterestsWhere(portId))
|
||
.orderBy(sql`${berths.price} DESC NULLS LAST`)
|
||
.limit(10);
|
||
|
||
return {
|
||
stageCounts: stageCountMap,
|
||
topInterests: topInterestsRows.map((r) => ({
|
||
id: r.id,
|
||
clientId: r.clientId,
|
||
// M-L02: canonicalize for the same reason - the PDF stage label
|
||
// should always resolve from the modern 7-stage set.
|
||
pipelineStage: canonicalizeStage(r.pipelineStage),
|
||
berthPrice: r.berthPrice ? String(r.berthPrice) : null,
|
||
})),
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// ─── Revenue ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function fetchRevenueData(
|
||
portId: string,
|
||
_params: Record<string, unknown>,
|
||
): Promise<RevenueData> {
|
||
// Sum berth prices grouped by pipeline stage. Reads the primary-berth link
|
||
// via interest_berths (plan §3.4) - non-primary junction rows do not
|
||
// contribute to the revenue rollup.
|
||
const stageRevenue = await db
|
||
.select({
|
||
stage: interests.pipelineStage,
|
||
revenue: sum(berths.price),
|
||
})
|
||
.from(interests)
|
||
.leftJoin(
|
||
interestBerths,
|
||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||
)
|
||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||
.where(activeInterestsWhere(portId))
|
||
.groupBy(interests.pipelineStage);
|
||
|
||
// M-L02: canonicalize so legacy 9-stage rows fold into the modern bucket.
|
||
const stageRevenueMap = rollupStageRevenue(stageRevenue);
|
||
|
||
// Total revenue from WON interests only. Reporting audit caught the
|
||
// `outcome='won'` is the canonical money-changed-hands signal - won
|
||
// deals can technically be set from any pipeline stage, and the legacy
|
||
// belt-and-suspenders `pipeline_stage='completed'` filter is brittle to
|
||
// future cleanup of the 'completed' sentinel-stage convention (see
|
||
// PRE-DEPLOY-PLAN follow-ups). The outcome filter alone catches every
|
||
// won deal regardless of the stage it closed at.
|
||
const completedRevenue = await db
|
||
.select({ total: sum(berths.price) })
|
||
.from(interests)
|
||
.leftJoin(
|
||
interestBerths,
|
||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||
)
|
||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||
.where(
|
||
and(eq(interests.portId, portId), eq(interests.outcome, 'won'), isNull(interests.archivedAt)),
|
||
);
|
||
|
||
// Pipeline-weighted forecast - sums (berth price × stage weight) for
|
||
// every active interest. Stage weights resolve from
|
||
// `system_settings.pipeline_weights` (per-port admin override) and
|
||
// fall back to STAGE_WEIGHTS defaults. The PDF surfaces this number
|
||
// alongside totalCompleted so investors / leadership see both
|
||
// "money in the bank" and "expected from pipeline" on the same page.
|
||
let pipelineWeights: Record<string, number> = STAGE_WEIGHTS;
|
||
const weightsSetting = await db.query.systemSettings.findFirst({
|
||
where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)),
|
||
});
|
||
if (weightsSetting?.value && typeof weightsSetting.value === 'object') {
|
||
pipelineWeights = weightsSetting.value as Record<string, number>;
|
||
}
|
||
|
||
const forecastRows = await db
|
||
.select({
|
||
stage: interests.pipelineStage,
|
||
revenue: sum(berths.price),
|
||
})
|
||
.from(interests)
|
||
.leftJoin(
|
||
interestBerths,
|
||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||
)
|
||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||
.where(activeInterestsWhere(portId))
|
||
.groupBy(interests.pipelineStage);
|
||
|
||
// M-L02 covered inside computeTotalForecast via canonicalizeStage -
|
||
// legacy stage keys hit the weight map under their modern equivalent.
|
||
const totalForecast = computeTotalForecast(forecastRows, pipelineWeights);
|
||
|
||
return {
|
||
stageRevenue: stageRevenueMap,
|
||
totalCompleted: completedRevenue[0]?.total ? String(completedRevenue[0].total) : '0',
|
||
totalForecast,
|
||
pipelineWeights,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// ─── Activity ─────────────────────────────────────────────────────────────────
|
||
|
||
export async function fetchActivityData(
|
||
portId: string,
|
||
params: Record<string, unknown>,
|
||
): Promise<ActivityData> {
|
||
const dateFrom = params.dateFrom as string | undefined;
|
||
const dateTo = params.dateTo as string | undefined;
|
||
|
||
const thirtyDaysAgo = new Date();
|
||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||
|
||
const fromDate = dateFrom ? new Date(dateFrom) : thirtyDaysAgo;
|
||
|
||
const conditions = [eq(auditLogs.portId, portId), gte(auditLogs.createdAt, fromDate)];
|
||
|
||
if (dateTo) {
|
||
conditions.push(lte(auditLogs.createdAt, new Date(dateTo)));
|
||
}
|
||
|
||
const logs = await db
|
||
.select({
|
||
id: auditLogs.id,
|
||
action: auditLogs.action,
|
||
entityType: auditLogs.entityType,
|
||
entityId: auditLogs.entityId,
|
||
userId: auditLogs.userId,
|
||
createdAt: auditLogs.createdAt,
|
||
})
|
||
.from(auditLogs)
|
||
.where(and(...conditions))
|
||
.orderBy(sql`${auditLogs.createdAt} DESC`)
|
||
.limit(200);
|
||
|
||
// Group by action type
|
||
const summary: Record<string, number> = {};
|
||
for (const log of logs) {
|
||
const key = `${log.action}:${log.entityType}`;
|
||
summary[key] = (summary[key] ?? 0) + 1;
|
||
}
|
||
|
||
return {
|
||
logs,
|
||
summary,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
// ─── Occupancy ────────────────────────────────────────────────────────────────
|
||
|
||
export async function fetchOccupancyData(
|
||
portId: string,
|
||
_params: Record<string, unknown>,
|
||
): Promise<OccupancyData> {
|
||
const statusCounts = await db
|
||
.select({
|
||
status: berths.status,
|
||
count: count(),
|
||
})
|
||
.from(berths)
|
||
.where(eq(berths.portId, portId))
|
||
.groupBy(berths.status);
|
||
|
||
const { statusCounts: statusCountMap, totalBerths } = rollupBerthStatusCounts(statusCounts);
|
||
// Occupied = sold only. Per 2026-05-14 decision, `under_offer` is a
|
||
// hold (blocks the berth from sale to other clients) but the berth is
|
||
// still technically available until the deal closes. computeOccupancyRate
|
||
// implements that rule + rounds to 1 decimal.
|
||
const { occupancyRate } = computeOccupancyRate(statusCountMap);
|
||
|
||
return {
|
||
statusCounts: statusCountMap,
|
||
occupancyRate,
|
||
totalBerths,
|
||
generatedAt: new Date().toISOString(),
|
||
};
|
||
}
|