diff --git a/src/lib/services/reports/operational.service.ts b/src/lib/services/reports/operational.service.ts index e691cd09..9c7cf0b1 100644 --- a/src/lib/services/reports/operational.service.ts +++ b/src/lib/services/reports/operational.service.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, gte, isNotNull, isNull, lte, sql } from 'drizzle-orm'; +import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; @@ -6,6 +6,7 @@ import { berthTenancies } from '@/lib/db/schema/tenancies'; import { clients } from '@/lib/db/schema/clients'; import { documents } from '@/lib/db/schema/documents'; import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service'; +import type { OperationalFilters } from './operational-filters'; /** * Service layer for the Operational report. @@ -25,6 +26,17 @@ interface DateRange { to: Date; } +/** + * Optional berth-area WHERE-condition. Returns `undefined` when no area + * filter is active, so it drops cleanly out of a drizzle `and(...)` + * (which ignores undefined operands). + */ +function areaCond(filters?: OperationalFilters) { + return filters?.areas && filters.areas.length > 0 + ? inArray(berths.area, filters.areas) + : undefined; +} + export interface OperationalKpis { totalBerths: number; soldPct: number; @@ -54,6 +66,7 @@ export interface OperationalKpis { export async function getOperationalKpis( portId: string, range: DateRange, + filters?: OperationalFilters, ): Promise { const [ totalBerths, @@ -67,11 +80,11 @@ export async function getOperationalKpis( signingTurnaround, conflicts, ] = await Promise.all([ - countActiveBerths(portId), - countBerthsByStatusNow(portId, 'sold'), - countBerthsByStatusAtTimestamp(portId, 'sold', range.from), - countBerthsByStatusNow(portId, 'under_offer'), - countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from), + countActiveBerths(portId, filters), + countBerthsByStatusNow(portId, 'sold', filters), + countBerthsByStatusAtTimestamp(portId, 'sold', range.from, filters), + countBerthsByStatusNow(portId, 'under_offer', filters), + countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from, filters), isTenanciesModuleEnabled(portId), countActiveTenancies(portId), medianTenancyLengthYears(portId), @@ -116,6 +129,7 @@ export interface UtilisationCell { export async function getUtilisationHeatmap( portId: string, months = 24, + filters?: OperationalFilters, ): Promise { // For each month buckets we walk all berths and compute the // share that was 'sold' or 'under_offer' at month-end. To keep @@ -130,7 +144,7 @@ export async function getUtilisationHeatmap( const berthRows = await db .select({ id: berths.id, area: berths.area, status: berths.status }) .from(berths) - .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))); + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); if (berthRows.length === 0) return []; const areaSet = new Set(); @@ -511,7 +525,10 @@ export interface AreaOccupancyRow { total: number; } -export async function getOccupancyByArea(portId: string): Promise { +export async function getOccupancyByArea( + portId: string, + filters?: OperationalFilters, +): Promise { const rows = await db .select({ area: berths.area, @@ -519,7 +536,7 @@ export async function getOccupancyByArea(portId: string): Promise`count(*)::int`, }) .from(berths) - .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))) + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))) .groupBy(berths.area, berths.status); const byArea = new Map(); @@ -652,6 +669,7 @@ export interface VacantBerthRow { export async function getVacantBerths( portId: string, minDaysAvailable = 60, + filters?: OperationalFilters, ): Promise { const now = Date.now(); const rows = await db @@ -667,7 +685,12 @@ export async function getVacantBerths( }) .from(berths) .where( - and(eq(berths.portId, portId), eq(berths.status, 'available'), isNull(berths.archivedAt)), + and( + eq(berths.portId, portId), + eq(berths.status, 'available'), + isNull(berths.archivedAt), + areaCond(filters), + ), ) .orderBy(berths.mooringNumber); @@ -775,6 +798,7 @@ export interface HighestValueVacantRow { export async function getHighestValueVacant( portId: string, limit = 10, + filters?: OperationalFilters, ): Promise { const now = Date.now(); const rows = await db @@ -795,6 +819,7 @@ export async function getHighestValueVacant( eq(berths.status, 'available'), isNull(berths.archivedAt), isNotNull(berths.price), + areaCond(filters), ), ) .orderBy(desc(berths.price)) @@ -820,21 +845,61 @@ export async function getHighestValueVacant( }); } +/** + * Distinct, non-null berth areas for the Operational report's Area filter. + * Mirrors `getRepFilterOptions` in sales.service.ts. The FilterBar hides + * the Area control when this is empty, so ports with no areas defined never + * see it. + */ +export async function getOperationalAreaOptions(portId: string): Promise { + const rows = await db + .selectDistinct({ area: berths.area }) + .from(berths) + .where(and(eq(berths.portId, portId), isNotNull(berths.area), isNull(berths.archivedAt))) + .orderBy(berths.area); + return rows.map((r) => r.area).filter((a): a is string => a !== null); +} + +/** + * Window-independent existence check: does this port have any berth at all? + * Drives the report-level empty state (distinct from the per-window empty + * states the charts already render). + */ +export async function operationalHasData(portId: string): Promise { + const rows = await db + .select({ one: sql`1` }) + .from(berths) + .where(eq(berths.portId, portId)) + .limit(1); + return rows.length > 0; +} + // ─── Internals ────────────────────────────────────────────────────────────── -async function countActiveBerths(portId: string): Promise { +async function countActiveBerths(portId: string, filters?: OperationalFilters): Promise { const [row] = await db .select({ value: sql`count(*)::int` }) .from(berths) - .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))); + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); return row?.value ?? 0; } -async function countBerthsByStatusNow(portId: string, status: string): Promise { +async function countBerthsByStatusNow( + portId: string, + status: string, + filters?: OperationalFilters, +): Promise { const [row] = await db .select({ value: sql`count(*)::int` }) .from(berths) - .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), eq(berths.status, status))); + .where( + and( + eq(berths.portId, portId), + isNull(berths.archivedAt), + eq(berths.status, status), + areaCond(filters), + ), + ); return row?.value ?? 0; } @@ -848,11 +913,12 @@ async function countBerthsByStatusAtTimestamp( portId: string, targetStatus: string, at: Date, + filters?: OperationalFilters, ): Promise { const berthRows = await db .select({ id: berths.id, status: berths.status, createdAt: berths.createdAt }) .from(berths) - .where(and(eq(berths.portId, portId), isNull(berths.archivedAt))); + .where(and(eq(berths.portId, portId), isNull(berths.archivedAt), areaCond(filters))); const auditRows = await db.execute<{ entity_id: string;