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 { PaymentEntity } from "../payment/entities/payment.entity"; import { Train } from "../trains/entities/train.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, } from "./overview.constants"; import { Company } from "../companies/entities/company.entity"; export type OverviewBookingKpisRow = { 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; }; @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(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) private readonly containerRepository: Repository, @InjectRepository(Cargo) private readonly cargoRepository: Repository, @InjectRepository(Employee) private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, ) { } async getBookingKpis(): Promise { const row = await this.bookingRepository .createQueryBuilder("booking") .select( `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") .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES], urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD, }) .getRawOne>(); return { 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; containersInTransit: number; cargoesLoaded: number; }> { const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = 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.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(), ]); return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded, }; } 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(): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; pendingPayments: number; successfulPaymentsMtd: number; }> { 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)`, ) .getRawOne>(); const pendingPayments = await this.paymentRepository .createQueryBuilder("payment") .where("payment.status IN (:...statuses)", { statuses: ["action-required", "processing"], }) .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, ): Promise<{ date: string; count: number }[]> { 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(`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(): Promise> { const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.status", "status") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( rows.map((row) => [row.status, Number(row.count)]), ); } async getPaymentTrend( days: number, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { 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 - :days::int + 1`, { days }, ) .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): Promise { 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") .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(): Promise< { label: string; count: number }[] > { const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.freight_type", "label") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") .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(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository .createQueryBuilder("booking") .select("booking.payment_currency", "label") .addSelect("COUNT(*)::int", "count") .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") .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(): Promise<{ status: string; count: number }[]> { const rows = await this.paymentRepository .createQueryBuilder("payment") .select("payment.status", "status") .addSelect("COUNT(*)::int", "count") .groupBy("payment.status") .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ status: row.status, count: Number(row.count), })); } async getPaymentsByMethod(): Promise< { method: string; count: number; amountEtb: number; amountUsd: number }[] > { 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", ) .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(): Promise< { currency: string; amount: number }[] > { 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)`, ) .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ currency: row.currency, amount: Number(row.amount), })); } 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"); } 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, ): Promise<{ label: string; count: number }[]> { 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'") .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 }, ]; } }