Files
pn-new-crm/src/lib/services/dashboard.service.ts
Matt Ciaccio a391934b73 feat(marina): end-reservation UI + global list, yacht tabs, dashboard distinct count
- End-reservation: API handler existed but had no UI surface. Adds an
  "End reservation" button + date dialog on the reservation detail page,
  visible only when status is `active`.
- New port-scoped `GET /api/v1/berth-reservations` list endpoint and
  `[portSlug]/berth-reservations` page so users can see all reservations
  across all berths from one place (was 404).
- Berths "Edit" menu pushed `/berths/{id}?edit=true` but the detail page
  never read the param — it now auto-opens the edit sheet on mount and
  strips `edit` from the URL.
- Reservation detail no longer shows raw 8-char UUIDs for Berth / Yacht
  / Client; reuses the lazy-fetching link components from the list view.
- Yacht "Interests" and "Reservations" tabs replaced their "Coming soon"
  stubs with real lists fetched from the existing service routes.
- Dashboard "Pipeline Value" KPI used `select(berthId, price)` and
  summed per active interest, so a berth with three open interests was
  counted three times. Switched to `selectDistinct(berthId, price)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:01:15 +02:00

183 lines
6.3 KiB
TypeScript

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<string, number> = 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<number>`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<string, number> = 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<string, number>;
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<string, { count: number; weightedValue: number }> = {};
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;
}