feat(reports): thread Area filter + add area-options/hasData helpers (operational service)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 { db } from '@/lib/db';
|
||||||
import { berths } from '@/lib/db/schema/berths';
|
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 { clients } from '@/lib/db/schema/clients';
|
||||||
import { documents } from '@/lib/db/schema/documents';
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
import { isTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import type { OperationalFilters } from './operational-filters';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service layer for the Operational report.
|
* Service layer for the Operational report.
|
||||||
@@ -25,6 +26,17 @@ interface DateRange {
|
|||||||
to: Date;
|
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 {
|
export interface OperationalKpis {
|
||||||
totalBerths: number;
|
totalBerths: number;
|
||||||
soldPct: number;
|
soldPct: number;
|
||||||
@@ -54,6 +66,7 @@ export interface OperationalKpis {
|
|||||||
export async function getOperationalKpis(
|
export async function getOperationalKpis(
|
||||||
portId: string,
|
portId: string,
|
||||||
range: DateRange,
|
range: DateRange,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<OperationalKpis> {
|
): Promise<OperationalKpis> {
|
||||||
const [
|
const [
|
||||||
totalBerths,
|
totalBerths,
|
||||||
@@ -67,11 +80,11 @@ export async function getOperationalKpis(
|
|||||||
signingTurnaround,
|
signingTurnaround,
|
||||||
conflicts,
|
conflicts,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
countActiveBerths(portId),
|
countActiveBerths(portId, filters),
|
||||||
countBerthsByStatusNow(portId, 'sold'),
|
countBerthsByStatusNow(portId, 'sold', filters),
|
||||||
countBerthsByStatusAtTimestamp(portId, 'sold', range.from),
|
countBerthsByStatusAtTimestamp(portId, 'sold', range.from, filters),
|
||||||
countBerthsByStatusNow(portId, 'under_offer'),
|
countBerthsByStatusNow(portId, 'under_offer', filters),
|
||||||
countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from),
|
countBerthsByStatusAtTimestamp(portId, 'under_offer', range.from, filters),
|
||||||
isTenanciesModuleEnabled(portId),
|
isTenanciesModuleEnabled(portId),
|
||||||
countActiveTenancies(portId),
|
countActiveTenancies(portId),
|
||||||
medianTenancyLengthYears(portId),
|
medianTenancyLengthYears(portId),
|
||||||
@@ -116,6 +129,7 @@ export interface UtilisationCell {
|
|||||||
export async function getUtilisationHeatmap(
|
export async function getUtilisationHeatmap(
|
||||||
portId: string,
|
portId: string,
|
||||||
months = 24,
|
months = 24,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<UtilisationCell[]> {
|
): Promise<UtilisationCell[]> {
|
||||||
// For each month buckets we walk all berths and compute the
|
// For each month buckets we walk all berths and compute the
|
||||||
// share that was 'sold' or 'under_offer' at month-end. To keep
|
// share that was 'sold' or 'under_offer' at month-end. To keep
|
||||||
@@ -130,7 +144,7 @@ export async function getUtilisationHeatmap(
|
|||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select({ id: berths.id, area: berths.area, status: berths.status })
|
.select({ id: berths.id, area: berths.area, status: berths.status })
|
||||||
.from(berths)
|
.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 [];
|
if (berthRows.length === 0) return [];
|
||||||
|
|
||||||
const areaSet = new Set<string>();
|
const areaSet = new Set<string>();
|
||||||
@@ -511,7 +525,10 @@ export interface AreaOccupancyRow {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOccupancyByArea(portId: string): Promise<AreaOccupancyRow[]> {
|
export async function getOccupancyByArea(
|
||||||
|
portId: string,
|
||||||
|
filters?: OperationalFilters,
|
||||||
|
): Promise<AreaOccupancyRow[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
area: berths.area,
|
area: berths.area,
|
||||||
@@ -519,7 +536,7 @@ export async function getOccupancyByArea(portId: string): Promise<AreaOccupancyR
|
|||||||
n: sql<number>`count(*)::int`,
|
n: sql<number>`count(*)::int`,
|
||||||
})
|
})
|
||||||
.from(berths)
|
.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);
|
.groupBy(berths.area, berths.status);
|
||||||
|
|
||||||
const byArea = new Map<string, AreaOccupancyRow>();
|
const byArea = new Map<string, AreaOccupancyRow>();
|
||||||
@@ -652,6 +669,7 @@ export interface VacantBerthRow {
|
|||||||
export async function getVacantBerths(
|
export async function getVacantBerths(
|
||||||
portId: string,
|
portId: string,
|
||||||
minDaysAvailable = 60,
|
minDaysAvailable = 60,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<VacantBerthRow[]> {
|
): Promise<VacantBerthRow[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -667,7 +685,12 @@ export async function getVacantBerths(
|
|||||||
})
|
})
|
||||||
.from(berths)
|
.from(berths)
|
||||||
.where(
|
.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);
|
.orderBy(berths.mooringNumber);
|
||||||
|
|
||||||
@@ -775,6 +798,7 @@ export interface HighestValueVacantRow {
|
|||||||
export async function getHighestValueVacant(
|
export async function getHighestValueVacant(
|
||||||
portId: string,
|
portId: string,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<HighestValueVacantRow[]> {
|
): Promise<HighestValueVacantRow[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -795,6 +819,7 @@ export async function getHighestValueVacant(
|
|||||||
eq(berths.status, 'available'),
|
eq(berths.status, 'available'),
|
||||||
isNull(berths.archivedAt),
|
isNull(berths.archivedAt),
|
||||||
isNotNull(berths.price),
|
isNotNull(berths.price),
|
||||||
|
areaCond(filters),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(berths.price))
|
.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<string[]> {
|
||||||
|
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<boolean> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ one: sql<number>`1` })
|
||||||
|
.from(berths)
|
||||||
|
.where(eq(berths.portId, portId))
|
||||||
|
.limit(1);
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function countActiveBerths(portId: string): Promise<number> {
|
async function countActiveBerths(portId: string, filters?: OperationalFilters): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(berths)
|
.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;
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countBerthsByStatusNow(portId: string, status: string): Promise<number> {
|
async function countBerthsByStatusNow(
|
||||||
|
portId: string,
|
||||||
|
status: string,
|
||||||
|
filters?: OperationalFilters,
|
||||||
|
): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ value: sql<number>`count(*)::int` })
|
.select({ value: sql<number>`count(*)::int` })
|
||||||
.from(berths)
|
.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;
|
return row?.value ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -848,11 +913,12 @@ async function countBerthsByStatusAtTimestamp(
|
|||||||
portId: string,
|
portId: string,
|
||||||
targetStatus: string,
|
targetStatus: string,
|
||||||
at: Date,
|
at: Date,
|
||||||
|
filters?: OperationalFilters,
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const berthRows = await db
|
const berthRows = await db
|
||||||
.select({ id: berths.id, status: berths.status, createdAt: berths.createdAt })
|
.select({ id: berths.id, status: berths.status, createdAt: berths.createdAt })
|
||||||
.from(berths)
|
.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<{
|
const auditRows = await db.execute<{
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user