import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; import { Employee } from "@tria-plc/iamapi-common"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Freight } from "@edr/types"; import { Repository, ObjectLiteral } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { Cargo } from "../cargoes/entities/cargoes.entity"; import { Container } from "../container-management/entities/container.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; import { CargoType } from "../rule-engine/entities/cargo-type.entity"; import { ContainerType } from "../rule-engine/entities/container-type.entity"; import { Yard } from "../rule-engine/entities/yard.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_CONTRACT_CLOSED_STATUSES, OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES, OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES, OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from "./overview.constants"; import { Company } from "../companies/entities/company.entity"; import { bookingRefScopeSql, directionScopeSql, } from "../user-trade-access/trade-scope.util"; /** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = "(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')"; export type OverviewBookingKpisRow = { total: number; totalActive: number; needsAction: number; urgent: number; inApproval: number; submittedToday: number; }; export type OverviewRecentBookingRow = { id: string; reference: string; customerLabel: string; status: string; priorityScore: number; totalAmount: number | null; paymentCurrency: string | null; createdAt: Date; }; /** One cell of a two-dimensional breakdown (bucket × stacked series). */ export type MatrixCell = { group: string; series: string; count: number }; export type TurnaroundRow = { trainSet: string; hours: number }; export type TrainLoadRow = { scheduleId: string; trainNumber: string; date: string; direction: string; wagonsAllocated: number; wagonsTotal: number; tons: number; }; export type OverviewContractKpisRow = { total: number; totalActive: number; needsAction: number; inApproval: number; inClearance: number; createdToday: number; }; export type OverviewRecentContractRow = { id: string; reference: string; customerLabel: string; status: string; contractKind: string; freightType: string; paymentCurrency: string | null; validUntil: Date | null; createdAt: Date; }; @Injectable() export class OverviewRepository { constructor( @InjectRepository(Booking) private readonly bookingRepository: Repository, @InjectRepository(PaymentEntity) private readonly paymentRepository: Repository, @InjectRepository(Company) private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, @InjectRepository(TrainSchedule) private readonly trainScheduleRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) private readonly containerRepository: Repository, @InjectRepository(Cargo) private readonly cargoRepository: Repository, @InjectRepository(Contract) private readonly contractRepository: Repository, @InjectRepository(Employee) private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, ) { } async getBookingKpis(dirs?: string[]): Promise { const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") .select("COUNT(*)::int", "total") .addSelect( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, "totalActive", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, "needsAction", ) .addSelect( `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, "urgent", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, "inApproval", ) .addSelect( `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, "submittedToday", ) .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, }) .getRawOne>(); return { total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), urgent: Number(row?.urgent ?? 0), inApproval: Number(row?.inApproval ?? 0), submittedToday: Number(row?.submittedToday ?? 0), }; } async getOperationsKpis(): Promise<{ trainsActive: number; wagonsAvailable: number; wagonsTotal: number; containersInTransit: number; cargoesLoaded: number; schedulesUpcoming: number; dispatchedToday: number; }> { const [ trainsActive, wagonsAvailable, wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, dispatchedToday, ] = await Promise.all([ this.trainRepository .createQueryBuilder("train") .where("train.deleted_at IS NULL") .andWhere("train.status IN (:...statuses)", { statuses: [ Freight.TrainStatus.InService, Freight.TrainStatus.Scheduled, ], }) .getCount(), this.wagonRepository .createQueryBuilder("wagon") .where("wagon.deleted_at IS NULL") .andWhere("wagon.status = :status", { status: Freight.WagonStatus.Available, }) .getCount(), this.wagonRepository .createQueryBuilder("wagon") .where("wagon.deleted_at IS NULL") .getCount(), this.containerRepository .createQueryBuilder("container") .where("container.deleted_at IS NULL") .andWhere("container.status = :status", { status: "IN_TRANSIT" }) .getCount(), this.cargoRepository .createQueryBuilder("cargo") .where("cargo.deleted_at IS NULL") .andWhere("cargo.status IN (:...statuses)", { statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), this.trainScheduleRepository .createQueryBuilder("schedule") .where("schedule.deleted_at IS NULL") .andWhere("schedule.status = :status", { status: Freight.TrainScheduleStatus.Scheduled, }) .andWhere("schedule.scheduled_departure_date >= CURRENT_DATE") .getCount(), this.trainScheduleRepository .createQueryBuilder("schedule") .where("schedule.deleted_at IS NULL") .andWhere("schedule.status = :status", { status: Freight.TrainScheduleStatus.Dispatched, }) .andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE") .getCount(), ]); return { trainsActive, wagonsAvailable, wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, dispatchedToday, }; } async getCustomerKpis(): Promise<{ totalCustomers: number; newCustomersThisMonth: number; }> { const row = await this.companyRepository .createQueryBuilder("customer") .select("COUNT(*)::int", "totalCustomers") .addSelect( `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, "newCustomersThisMonth", ) .where("customer.deleted_at IS NULL") .getRawOne>(); return { totalCustomers: Number(row?.totalCustomers ?? 0), newCustomersThisMonth: Number(row?.newCustomersThisMonth ?? 0), }; } async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const revenueRow = await this.paymentRepository .createQueryBuilder("payment") .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "revenueMtdEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .andWhere(scope.sql, scope.params) .getRawOne>(); const pendingPayments = await this.paymentRepository .createQueryBuilder("payment") .where("payment.status IN (:...statuses)", { statuses: ["action-required", "processing"], }) .andWhere(scope.sql, scope.params) .getCount(); return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; } async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number; }> { const [activeEmployees, activeUsers] = await Promise.all([ this.employeeRepository.count({ where: { isCurrent: true }, }), this.userRepository.count({ where: { isActive: true, status: EUserStatus.ACCEPTED, }, }), ]); return { activeEmployees, activeUsers }; } async getBookingTrend( days: number, dirs?: string[], ): Promise<{ date: string; count: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(scope.sql, scope.params) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("booking.created_at::date") .orderBy("booking.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getStatusCounts(dirs?: string[]): Promise> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.status", "status") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(scope.sql, scope.params) .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( rows.map((row) => [row.status, Number(row.count)]), ); } /** * Daily successful-payment revenue for a `days`-wide window shifted back by * `offsetDays` — `0` (default) is the current window ending today, * `offsetDays: days` is the immediately preceding window (the ghost-line * comparison series on the overview chart). */ async getPaymentTrend( days: number, dirs?: string[], offsetDays = 0, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select( `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, "date", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, { days, offsetDays }, ) .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } async getRecentBookings( limit: number, dirs?: string[], ): Promise { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") .select("booking.id", "id") .addSelect("booking.reference", "reference") .addSelect("COALESCE(company.name, '—')", "customerLabel") .addSelect("booking.status", "status") .addSelect("booking.priority_score", "priorityScore") .addSelect("booking.total_amount", "totalAmount") .addSelect("booking.payment_currency", "paymentCurrency") .addSelect("booking.created_at", "createdAt") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(scope.sql, scope.params) .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ id: string; reference: string; customerLabel: string; status: string; priorityScore: string; totalAmount: string | null; paymentCurrency: string | null; createdAt: Date; }>(); return rows.map((row) => ({ id: row.id, reference: row.reference, customerLabel: row.customerLabel, status: row.status, priorityScore: Number(row.priorityScore), totalAmount: row.totalAmount != null ? Number(row.totalAmount) : null, paymentCurrency: row.paymentCurrency, createdAt: row.createdAt, })); } async getBookingsByFreightType( dirs?: string[], ): Promise<{ label: string; count: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") .andWhere(scope.sql, scope.params) .groupBy("booking.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getBookingsByCurrency( dirs?: string[], ): Promise<{ label: string; count: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.payment_currency", "label") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere("booking.status != 'DRAFT'") .andWhere(scope.sql, scope.params) .groupBy("booking.payment_currency") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getPaymentsByStatus( dirs?: string[], ): Promise<{ status: string; count: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.status", "status") .addSelect("COUNT(*)::int", "count") .where(scope.sql, scope.params) .groupBy("payment.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getPaymentsByMethod( dirs?: string[], ): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.method", "method") .addSelect("COUNT(*)::int", "count") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string; }>(); return rows.map((row) => ({ method: row.method, count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } async getRevenueByCurrency( dirs?: string[], ): Promise<{ currency: string; amount: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.currency", "currency") .addSelect("COALESCE(SUM(payment.amount), 0)", "amount") .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .andWhere(scope.sql, scope.params) .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ currency: row.currency, amount: Number(row.amount), })); } /** * Bookings created, revenue and tonnage for one `days`-wide window, shifted * back by `offsetDays`. Called twice by the service — `offsetDays: 0` for * the current period, `offsetDays: days` for the immediately preceding * one-of-the-same-length period — so the page can show a real vs-prior-period * delta instead of a bare count. */ async getPeriodTotals( days: number, offsetDays: number, dirs?: string[], ): Promise<{ bookingsCreated: number; revenueEtb: number; revenueUsd: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); const paymentScope = bookingRefScopeSql("payment.ref_id", dirs); const cargoScope = directionScopeSql("booking.trade_direction", dirs); const windowSql = (column: string) => `${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`; const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([ this.bookingRepository .createQueryBuilder("booking") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(bookingScope.sql, bookingScope.params) .andWhere(windowSql("booking.created_at"), { days, offsetDays }) .getCount(), this.paymentRepository .createQueryBuilder("payment") .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "revenueEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") .select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") .where("cargo.deleted_at IS NULL") .andWhere(windowSql("cargo.created_at"), { days, offsetDays }) .andWhere(cargoScope.sql, cargoScope.params) .getRawOne<{ tons: string }>(), ]); return { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } /** Revenue for the selected range, split by booking trade direction. */ async getRevenueByDirection( days: number, dirs?: string[], ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") .select("booking.trade_direction", "label") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } /** Revenue for the selected range, split by booking freight type. */ async getRevenueByFreightType( days: number, dirs?: string[], ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") .select("booking.freight_type", "label") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } /** Daily cargo tonnage for the selected range — hero sparkline series. */ async getTonsTrend( days: number, dirs?: string[], ): Promise<{ date: string; tons: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") .select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") .where("cargo.deleted_at IS NULL") .andWhere(scope.sql, scope.params) .andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("cargo.created_at::date") .orderBy("cargo.created_at::date", "ASC") .getRawMany<{ date: string; tons: string }>(); return rows.map((row) => ({ date: row.date, tons: Number(row.tons) })); } /** * Revenue for the selected range as direction → freight-type flows — the * Sankey on the overview. One row per (direction, freight type) pair. */ async getRevenueFlows( days: number, dirs?: string[], ): Promise< { direction: string; freightType: string; amountEtb: number; amountUsd: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") .select("booking.trade_direction", "direction") .addSelect("booking.freight_type", "freightType") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.trade_direction") .addGroupBy("booking.freight_type") .getRawMany<{ direction: string; freightType: string; amountEtb: string; amountUsd: string; }>(); return rows.map((row) => ({ direction: row.direction, freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), })); } /** * Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour * block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use * the database server's timezone, same as every ::date grouping here. */ async getBookingHeatmap( days: number, dirs?: string[], ): Promise<{ dow: number; block: number; count: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow") .addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) .andWhere(scope.sql, scope.params) .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("EXTRACT(ISODOW FROM booking.created_at)::int") .addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int") .getRawMany<{ dow: string; block: string; count: string }>(); return rows.map((row) => ({ dow: Number(row.dow), block: Number(row.block), count: Number(row.count), })); } async getTrainStatusBreakdown(): Promise< { status: string; count: number }[] > { return this.statusBreakdown(this.trainRepository, "train"); } async getWagonStatusBreakdown(): Promise< { status: string; count: number }[] > { return this.statusBreakdown(this.wagonRepository, "wagon"); } async getContainerStatusBreakdown(): Promise< { status: string; count: number }[] > { return this.statusBreakdown(this.containerRepository, "container"); } async getCargoStatusBreakdown(): Promise< { status: string; count: number }[] > { return this.statusBreakdown(this.cargoRepository, "cargo"); } async getScheduleStatusBreakdown(): Promise< { status: string; count: number }[] > { return this.statusBreakdown(this.trainScheduleRepository, "schedule"); } /** Scheduled departures per day over the range, split by trade direction. */ async getDepartureTrend(days: number): Promise< { date: string; importCount: number; exportCount: number; domesticCount: number; }[] > { const rows = await this.trainScheduleRepository .createQueryBuilder("schedule") .select( `to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`, "date", ) .addSelect( `COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`, "importCount", ) .addSelect( `COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`, "exportCount", ) .addSelect( `COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`, "domesticCount", ) .where("schedule.deleted_at IS NULL") .andWhere("schedule.status != :draft", { draft: Freight.TrainScheduleStatus.Draft, }) .andWhere( `schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`, { days }, ) .andWhere( `schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`, { ahead: 8 }, ) .groupBy("schedule.scheduled_departure_date::date") .orderBy("schedule.scheduled_departure_date::date", "ASC") .getRawMany<{ date: string; importCount: string; exportCount: string; domesticCount: string; }>(); return rows.map((row) => ({ date: row.date, importCount: Number(row.importCount), exportCount: Number(row.exportCount), domesticCount: Number(row.domesticCount), })); } async getWagonsByType(): Promise<{ label: string; count: number }[]> { const rows = await this.wagonRepository .createQueryBuilder("wagon") .leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id") .select(`COALESCE(wagon_type.name, 'Unknown')`, "label") .addSelect("COUNT(*)::int", "count") .where("wagon.deleted_at IS NULL") .groupBy("wagon_type.name") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } async getWagonsByYard(limit: number): Promise< { label: string; count: number }[] > { const rows = await this.wagonRepository .createQueryBuilder("wagon") .innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id") .select("yard.label", "label") .addSelect("COUNT(*)::int", "count") .where("wagon.deleted_at IS NULL") .groupBy("yard.label") .orderBy("count", "DESC") .limit(limit) .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } async getContainersBySize(): Promise<{ label: string; count: number }[]> { const rows = await this.containerRepository .createQueryBuilder("container") .leftJoin( ContainerType, "container_type", "container_type.id = container.container_type_id", ) .select( `COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`, "label", ) .addSelect("COUNT(*)::int", "count") .where("container.deleted_at IS NULL") .groupBy("container_type.size_ft") .addGroupBy("container_type.code") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } /** Total cargo weight (tons) grouped by cargo type, heaviest first. */ async getCargoTonnageByType(limit: number): Promise< { label: string; tons: number }[] > { const rows = await this.cargoRepository .createQueryBuilder("cargo") .leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id") .select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label") .addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons") .where("cargo.deleted_at IS NULL") .groupBy("cargo_type.cargo_type_name") .orderBy("tons", "DESC") .limit(limit) .getRawMany<{ label: string; tons: string }>(); return rows .map((row) => ({ label: row.label, tons: Number(row.tons) })) .filter((row) => row.tons > 0); } private async statusBreakdown( repository: Repository, alias: string, ): Promise<{ status: string; count: number }[]> { const rows = await repository .createQueryBuilder(alias) .select(`${alias}.status`, "status") .addSelect("COUNT(*)::int", "count") .where(`${alias}.deleted_at IS NULL`) .groupBy(`${alias}.status`) .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getCustomerGrowthTrend( days: number, ): Promise<{ date: string; count: number }[]> { const rows = await this.companyRepository .createQueryBuilder("customer") .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("customer.deleted_at IS NULL") .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days, }) .groupBy("customer.created_at::date") .orderBy("customer.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getCustomersByType(): Promise<{ label: string; count: number }[]> { const rows = await this.companyRepository .createQueryBuilder("customer") .select( `COALESCE(NULLIF(customer.type, ''), 'Unknown')`, "label", ) .addSelect("COUNT(*)::int", "count") .where("customer.deleted_at IS NULL") .groupBy("customer.type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getTopCustomersByBookings( limit: number, dirs?: string[], ): Promise<{ label: string; count: number }[]> { const scope = directionScopeSql("booking.trade_direction", dirs); const rows = await this.bookingRepository .createQueryBuilder("booking") .leftJoin("booking.company", "company") .select(`COALESCE(company.name, 'Unknown')`, "label") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") .andWhere(scope.sql, scope.params) .groupBy("company.name") .orderBy("count", "DESC") .limit(limit) .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count), })); } async getUsersByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.userRepository .createQueryBuilder("user") .select("user.status", "status") .addSelect("COUNT(*)::int", "count") .groupBy("user.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getEmployeeGrowthTrend( days: number, ): Promise<{ date: string; count: number }[]> { const rows = await this.employeeRepository .createQueryBuilder("employee") .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("employee.is_current = true") .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days, }) .groupBy("employee.created_at::date") .orderBy("employee.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count), })); } async getActiveUsersBreakdown(): Promise<{ label: string; count: number }[]> { const [active, inactive] = await Promise.all([ this.userRepository.count({ where: { isActive: true, status: EUserStatus.ACCEPTED }, }), this.userRepository .createQueryBuilder("user") .where("user.is_active = false OR user.status != :status", { status: EUserStatus.ACCEPTED, }) .getCount(), ]); return [ { label: "Active", count: active }, { label: "Inactive", count: inactive }, ]; } // ── Contracts (overview Contract tab) ────────────────────────────────────── async getContractKpis(dirs?: string[]): Promise { const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") .select("COUNT(*)::int", "total") .addSelect( `COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`, "totalActive", ) .addSelect( `COUNT(*) FILTER (WHERE contract.status IN (:...needsActionStatuses))::int`, "needsAction", ) .addSelect( `COUNT(*) FILTER (WHERE contract.status IN (:...inApprovalStatuses))::int`, "inApproval", ) .addSelect( `COUNT(*) FILTER (WHERE contract.status IN (:...inClearanceStatuses))::int`, "inClearance", ) .addSelect( `COUNT(*) FILTER (WHERE contract.created_at >= CURRENT_DATE AND contract.status != 'DRAFT')::int`, "createdToday", ) .where("contract.deleted_at IS NULL") .andWhere(scope.sql, scope.params) .setParameters({ closedStatuses: [...OVERVIEW_CONTRACT_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_CONTRACT_NEEDS_ACTION_STATUSES], inApprovalStatuses: [...OVERVIEW_CONTRACT_IN_APPROVAL_STATUSES], inClearanceStatuses: [...OVERVIEW_CONTRACT_IN_CLEARANCE_STATUSES], }) .getRawOne>(); return { total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), inApproval: Number(row?.inApproval ?? 0), inClearance: Number(row?.inClearance ?? 0), createdToday: Number(row?.createdToday ?? 0), }; } async getContractStatusCounts( dirs?: string[], ): Promise> { const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.status", "status") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere(scope.sql, scope.params) .groupBy("contract.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); } async getContractTrend( days: number, dirs?: string[], ): Promise<{ date: string; count: number }[]> { const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select(`to_char(contract.created_at::date, 'YYYY-MM-DD')`, "date") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere(scope.sql, scope.params) .andWhere(`contract.created_at >= CURRENT_DATE - :days::int + 1`, { days }) .groupBy("contract.created_at::date") .orderBy("contract.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ date: row.date, count: Number(row.count) })); } async getContractsByKind( dirs?: string[], ): Promise<{ label: string; count: number }[]> { const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.contract_kind", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") .andWhere(scope.sql, scope.params) .groupBy("contract.contract_kind") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } async getContractsByFreightType( dirs?: string[], ): Promise<{ label: string; count: number }[]> { const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .select("contract.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("contract.deleted_at IS NULL") .andWhere("contract.status != 'DRAFT'") .andWhere(scope.sql, scope.params) .groupBy("contract.freight_type") .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } async getRecentContracts( limit: number, dirs?: string[], ): Promise { const scope = directionScopeSql("contract.trade_direction", dirs); const rows = await this.contractRepository .createQueryBuilder("contract") .leftJoin("contract.company", "company") .select("contract.id", "id") .addSelect("contract.reference", "reference") .addSelect("COALESCE(company.name, '—')", "customerLabel") .addSelect("contract.status", "status") .addSelect("contract.contract_kind", "contractKind") .addSelect("contract.freight_type", "freightType") .addSelect("contract.payment_currency", "paymentCurrency") .addSelect("contract.contract_valid_until", "validUntil") .addSelect("contract.created_at", "createdAt") .where("contract.deleted_at IS NULL") .andWhere(scope.sql, scope.params) .orderBy("contract.created_at", "DESC") .limit(limit) .getRawMany<{ id: string; reference: string; customerLabel: string; status: string; contractKind: string; freightType: string; paymentCurrency: string | null; validUntil: Date | null; createdAt: Date; }>(); return rows.map((row) => ({ id: row.id, reference: row.reference, customerLabel: row.customerLabel, status: row.status, contractKind: row.contractKind, freightType: row.freightType, paymentCurrency: row.paymentCurrency, validUntil: row.validUntil, createdAt: row.createdAt, })); } // --------------------------------------------------------------------------- // Fleet / operations detail. These read tables that have no repository // injected here (locomotives, train_set_wagons, document reviews, …), so they // go through the shared entity manager with plain SQL instead of adding six // more constructor arguments for one query each. // --------------------------------------------------------------------------- private get sql() { return this.wagonRepository.manager; } private async matrix( statement: string, params: unknown[] = [], ): Promise { const rows = await this.sql.query< { group: string; series: string; count: string }[] >(statement, params); return rows.map((row) => ({ group: row.group, series: row.series, count: Number(row.count), })); } private async labelCounts( statement: string, params: unknown[] = [], ): Promise<{ label: string; count: number }[]> { const rows = await this.sql.query<{ label: string; count: string }[]>( statement, params, ); return rows.map((row) => ({ label: row.label, count: Number(row.count) })); } private async statusCounts( statement: string, params: unknown[] = [], ): Promise<{ status: string; count: number }[]> { const rows = await this.sql.query<{ status: string; count: string }[]>( statement, params, ); return rows.map((row) => ({ status: row.status, count: Number(row.count) })); } /** Wagon lifecycle state crossed with wagon type — "how many flat wagons are detained". */ getWagonStatusByType(): Promise { return this.matrix(` SELECT COALESCE(wt.name, 'Unknown') AS "group", w.status AS series, COUNT(*)::int AS count FROM freight.wagons w LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id WHERE w.deleted_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2 `); } /** Same lifecycle state, per yard the wagon currently sits in. */ getWagonStatusByYard(): Promise { return this.matrix(` SELECT y.label AS "group", w.status AS series, COUNT(*)::int AS count FROM freight.wagons w INNER JOIN freight.yards y ON y.id = w.current_yard_id WHERE w.deleted_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2 `); } getLocomotiveStatusBreakdown(): Promise<{ status: string; count: number }[]> { return this.statusCounts(` SELECT status, COUNT(*)::int AS count FROM freight.locomotives WHERE deleted_at IS NULL GROUP BY status ORDER BY count DESC `); } /** Locomotives per station, split by status — the OCC's first question. */ getLocomotivesByYard(): Promise { return this.matrix(` SELECT COALESCE(y.label, 'Unassigned') AS "group", l.status AS series, COUNT(*)::int AS count FROM freight.locomotives l LEFT JOIN freight.yards y ON y.id = l.current_yard_id WHERE l.deleted_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2 `); } getLocomotivesByType(): Promise<{ label: string; count: number }[]> { return this.labelCounts(` SELECT COALESCE(locomotive_type, 'Unknown') AS label, COUNT(*)::int AS count FROM freight.locomotives WHERE deleted_at IS NULL GROUP BY 1 ORDER BY count DESC `); } /** * Departure-to-departure gap per train set over the range. Only consecutive * *actual* departures count — a schedule that never left says nothing about * how fast the set turned around. */ async getTrainTurnaround( days: number, limit: number, ): Promise<{ avgHours: number | null; rows: TurnaroundRow[] }> { const rows = await this.sql.query< { trainSet: string; hours: string }[] >( ` WITH departures AS ( SELECT s.train_set_id, s.actual_departure_at, LAG(s.actual_departure_at) OVER ( PARTITION BY s.train_set_id ORDER BY s.actual_departure_at ) AS previous_departure FROM freight.train_schedules s WHERE s.deleted_at IS NULL AND s.actual_departure_at IS NOT NULL AND s.actual_departure_at >= NOW() - make_interval(days => $1::int) ) SELECT COALESCE(t.train_number, t.code, 'Train set') AS "trainSet", ROUND( AVG( EXTRACT(EPOCH FROM (d.actual_departure_at - d.previous_departure)) / 3600 )::numeric, 1 ) AS hours FROM departures d LEFT JOIN freight.train_sets ts ON ts.id = d.train_set_id LEFT JOIN freight.trains t ON t.id = ts.train_id WHERE d.previous_departure IS NOT NULL GROUP BY 1 ORDER BY hours ASC LIMIT $2::int `, [days, limit], ); const mapped = rows.map((row) => ({ trainSet: row.trainSet, hours: Number(row.hours), })); const avgHours = mapped.length ? Number( ( mapped.reduce((sum, row) => sum + row.hours, 0) / mapped.length ).toFixed(1), ) : null; return { avgHours, rows: mapped }; } /** * Bookings per port yard. Import cargo enters at its origin yard, export * cargo leaves from its destination yard — anything else is counted at origin. */ getBookingsByPort(days: number): Promise<{ label: string; count: number }[]> { return this.labelCounts( ` SELECT y.label AS label, COUNT(*)::int AS count FROM freight.bookings b INNER JOIN freight.yards y ON y.id = CASE WHEN b.trade_direction = 'EXPORT' THEN b.destination_yard_id ELSE b.origin_yard_id END WHERE b.deleted_at IS NULL AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL') AND b.created_at >= NOW() - make_interval(days => $1::int) GROUP BY 1 ORDER BY count DESC `, [days], ); } getBookingStatusByPort(days: number): Promise { return this.matrix( ` SELECT y.label AS "group", b.status AS series, COUNT(*)::int AS count FROM freight.bookings b INNER JOIN freight.yards y ON y.id = CASE WHEN b.trade_direction = 'EXPORT' THEN b.destination_yard_id ELSE b.origin_yard_id END WHERE b.deleted_at IS NULL AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL') AND b.created_at >= NOW() - make_interval(days => $1::int) GROUP BY 1, 2 ORDER BY 1, 2 `, [days], ); } /** * Wagon fill and tonnage per scheduled train. Slots come from the train set's * wagon list, the load from confirmed booking allocations against those slots. */ async getTrainLoads(limit: number): Promise { const rows = await this.sql.query< { scheduleId: string; trainNumber: string; date: string; direction: string; wagonsTotal: string; wagonsAllocated: string; tons: string; }[] >( ` SELECT s.id AS "scheduleId", COALESCE(s.train_number, s.reference, '—') AS "trainNumber", to_char(s.scheduled_departure_date, 'YYYY-MM-DD') AS date, COALESCE(s.direction, 'DOMESTIC') AS direction, COALESCE(slots.total, 0)::int AS "wagonsTotal", COALESCE(load.wagons, 0)::int AS "wagonsAllocated", COALESCE(load.tons, 0)::float AS tons FROM freight.train_schedules s LEFT JOIN LATERAL ( SELECT COUNT(*)::int AS total FROM freight.train_set_wagons tsw WHERE tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL ) slots ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(DISTINCT wba.train_set_wagon_id)::int AS wagons, COALESCE(SUM(wba.allocated_weight_tons), 0) AS tons FROM freight.wagon_booking_allocations wba INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id WHERE tsw.train_set_id = s.train_set_id AND wba.deleted_at IS NULL AND tsw.deleted_at IS NULL ) load ON TRUE WHERE s.deleted_at IS NULL AND s.status <> 'DRAFT' ORDER BY s.scheduled_departure_date DESC LIMIT $1::int `, [limit], ); return rows.map((row) => ({ scheduleId: row.scheduleId, trainNumber: row.trainNumber, date: row.date, direction: row.direction, wagonsTotal: Number(row.wagonsTotal), wagonsAllocated: Number(row.wagonsAllocated), tons: Number(row.tons), })); } getBookingDocumentsByStatus(): Promise<{ status: string; count: number }[]> { return this.statusCounts(` SELECT status, COUNT(*)::int AS count FROM freight.booking_document_review WHERE deleted_at IS NULL GROUP BY status ORDER BY count DESC `); } getContractDocumentsByStatus(): Promise<{ status: string; count: number }[]> { return this.statusCounts(` SELECT status, COUNT(*)::int AS count FROM freight.contract_document_review WHERE deleted_at IS NULL GROUP BY status ORDER BY count DESC `); } getInvoicesByStatus(): Promise<{ status: string; count: number }[]> { return this.statusCounts(` SELECT status, COUNT(*)::int AS count FROM freight.invoices WHERE deleted_at IS NULL GROUP BY status ORDER BY count DESC `); } getInvoicesByType(): Promise<{ label: string; count: number }[]> { return this.labelCounts(` SELECT COALESCE(type, 'Other') AS label, COUNT(*)::int AS count FROM freight.invoices WHERE deleted_at IS NULL GROUP BY 1 ORDER BY count DESC `); } /** Handover papers per mile, split into signed and awaiting signature. */ getHandoversByMile(): Promise { return this.matrix(` SELECT COALESCE(mile_type, 'Unknown') AS "group", CASE WHEN signed_at IS NULL THEN 'PENDING' ELSE 'SIGNED' END AS series, COUNT(*)::int AS count FROM freight.booking_handovers WHERE deleted_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2 `); } /** Company profiles per type × status — active, pending and suspended per trade role. */ getProfilesByTypeStatus(): Promise { return this.matrix(` SELECT type AS "group", status AS series, COUNT(*)::int AS count FROM freight.company_profiles WHERE deleted_at IS NULL GROUP BY 1, 2 ORDER BY 1, 2 `); } }