import { and, count, desc, eq, isNull, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients } from '@/lib/db/schema/clients'; import { interests } from '@/lib/db/schema/interests'; import { berths } from '@/lib/db/schema/berths'; import { systemSettings, auditLogs } from '@/lib/db/schema/system'; import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants'; const DEFAULT_PIPELINE_WEIGHTS: Record = STAGE_WEIGHTS; // "Active" = not archived AND not closed as lost/cancelled. Won interests are // still counted because they represent revenue. Used everywhere KPIs say // "active interests" or "pipeline value". const isActiveInterest = sql`(${interests.outcome} IS NULL OR ${interests.outcome} = 'won')`; // ─── KPIs ───────────────────────────────────────────────────────────────────── export async function getKpis(portId: string) { const [totalClientsRow] = await db .select({ value: count() }) .from(clients) .where(and(eq(clients.portId, portId), isNull(clients.archivedAt))); const [activeInterestsRow] = await db .select({ value: count() }) .from(interests) .where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest)); // Pipeline value: SUM each berth's price ONCE regardless of how many active // interests reference it. A berth with multiple interests would otherwise be // counted multiple times, inflating the total. const pipelineRows = await db .selectDistinct({ berthId: interests.berthId, price: berths.price }) .from(interests) .innerJoin(berths, eq(interests.berthId, berths.id)) .where( and( eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest, sql`${interests.berthId} IS NOT NULL`, ), ); const pipelineValueUsd = pipelineRows.reduce((acc, row) => { return acc + (row.price ? parseFloat(String(row.price)) : 0); }, 0); // Occupancy rate: (sold + under_offer) / total * 100 const allBerthsRows = await db .select({ status: berths.status }) .from(berths) .where(eq(berths.portId, portId)); const totalBerths = allBerthsRows.length; const occupiedBerths = allBerthsRows.filter( (b) => b.status === 'sold' || b.status === 'under_offer', ).length; const occupancyRate = totalBerths > 0 ? (occupiedBerths / totalBerths) * 100 : 0; return { totalClients: totalClientsRow?.value ?? 0, activeInterests: activeInterestsRow?.value ?? 0, pipelineValueUsd, occupancyRate, }; } // ─── Pipeline Counts ────────────────────────────────────────────────────────── export async function getPipelineCounts(portId: string) { const rows = await db .select({ stage: interests.pipelineStage, count: sql`count(*)::int`, }) .from(interests) .where(and(eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest)) .groupBy(interests.pipelineStage); const countsByStage = Object.fromEntries(rows.map((r) => [r.stage, r.count])); return PIPELINE_STAGES.map((stage) => ({ stage, count: countsByStage[stage] ?? 0, })); } // ─── Revenue Forecast ───────────────────────────────────────────────────────── export async function getRevenueForecast(portId: string) { // Load weights from systemSettings let weights: Record = DEFAULT_PIPELINE_WEIGHTS; let weightsSource: 'db' | 'default' = 'default'; const settingRow = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, 'pipeline_weights'), eq(systemSettings.portId, portId)), }); if (settingRow?.value) { try { const parsed = settingRow.value as Record; if (typeof parsed === 'object' && parsed !== null) { weights = parsed; weightsSource = 'db'; } } catch { // Fall through to defaults } } // Forecast excludes lost/cancelled — only currently-active or won-out // interests should affect the weighted pipeline value. const interestRows = await db .select({ id: interests.id, pipelineStage: interests.pipelineStage, berthPrice: berths.price, }) .from(interests) .innerJoin(berths, eq(interests.berthId, berths.id)) .where( and( eq(interests.portId, portId), isNull(interests.archivedAt), isActiveInterest, sql`${interests.berthId} IS NOT NULL`, ), ); // Build stageBreakdown const stageMap: Record = {}; for (const row of interestRows) { const stage = row.pipelineStage ?? 'open'; const price = row.berthPrice ? parseFloat(String(row.berthPrice)) : 0; const weight = weights[stage] ?? 0; const weighted = price * weight; if (!stageMap[stage]) { stageMap[stage] = { count: 0, weightedValue: 0 }; } stageMap[stage]!.count += 1; stageMap[stage]!.weightedValue += weighted; } const stageBreakdown = PIPELINE_STAGES.map((stage) => ({ stage, count: stageMap[stage]?.count ?? 0, weightedValue: stageMap[stage]?.weightedValue ?? 0, })); const totalWeightedValue = stageBreakdown.reduce((acc, s) => acc + s.weightedValue, 0); return { totalWeightedValue, stageBreakdown, weightsSource, }; } // ─── Recent Activity ────────────────────────────────────────────────────────── export async function getRecentActivity(portId: string, limit = 20) { const rows = await db .select({ id: auditLogs.id, action: auditLogs.action, entityType: auditLogs.entityType, entityId: auditLogs.entityId, userId: auditLogs.userId, metadata: auditLogs.metadata, createdAt: auditLogs.createdAt, }) .from(auditLogs) .where(eq(auditLogs.portId, portId)) .orderBy(desc(auditLogs.createdAt)) .limit(limit); return rows; }