mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Introduced OverviewContractKpisDto and OverviewRecentContractDto for contract metrics. - Implemented OverviewContractsTabDto to structure the contracts tab response. - Added contract-related constants for status groupings and pipeline stages. - Created OverviewContractsTabPanel and OverviewRecentContractsTable components for UI representation. - Updated OverviewService and OverviewRepository to fetch contract data. - Integrated contracts tab into OverviewController and OverviewTabContent. - Added hooks for fetching contracts data in useOverview. - Updated types in the overview module to include contracts. - Enhanced the contract detail page with a "View contract" button for generated PDFs.
771 lines
25 KiB
TypeScript
771 lines
25 KiB
TypeScript
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 { Train } from "../trains/entities/train.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";
|
|
|
|
/** 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 = {
|
|
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;
|
|
};
|
|
|
|
export type OverviewContractKpisRow = {
|
|
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<Booking>,
|
|
@InjectRepository(PaymentEntity)
|
|
private readonly paymentRepository: Repository<PaymentEntity>,
|
|
@InjectRepository(Company)
|
|
private readonly companyRepository: Repository<Company>,
|
|
@InjectRepository(Train)
|
|
private readonly trainRepository: Repository<Train>,
|
|
@InjectRepository(Wagon)
|
|
private readonly wagonRepository: Repository<Wagon>,
|
|
@InjectRepository(Container)
|
|
private readonly containerRepository: Repository<Container>,
|
|
@InjectRepository(Cargo)
|
|
private readonly cargoRepository: Repository<Cargo>,
|
|
@InjectRepository(Contract)
|
|
private readonly contractRepository: Repository<Contract>,
|
|
@InjectRepository(Employee)
|
|
private readonly employeeRepository: Repository<Employee>,
|
|
@InjectRepository(User)
|
|
private readonly userRepository: Repository<User>,
|
|
) { }
|
|
|
|
async getBookingKpis(): Promise<OverviewBookingKpisRow> {
|
|
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")
|
|
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
|
.setParameters({
|
|
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
|
|
needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES],
|
|
inApprovalStatuses: [...OVERVIEW_IN_APPROVAL_STATUSES],
|
|
urgentThreshold: OVERVIEW_URGENT_PRIORITY_THRESHOLD,
|
|
})
|
|
.getRawOne<Record<string, string>>();
|
|
|
|
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<Record<string, string>>();
|
|
|
|
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<Record<string, string>>();
|
|
|
|
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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
|
.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<Record<string, number>> {
|
|
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)
|
|
.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<OverviewRecentBookingRow[]> {
|
|
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)
|
|
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
|
.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(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
|
|
.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<ObjectLiteral>,
|
|
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 },
|
|
];
|
|
}
|
|
|
|
// ── Contracts (overview Contract tab) ──────────────────────────────────────
|
|
|
|
async getContractKpis(): Promise<OverviewContractKpisRow> {
|
|
const row = await this.contractRepository
|
|
.createQueryBuilder("contract")
|
|
.select(
|
|
`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")
|
|
.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<Record<string, string>>();
|
|
|
|
return {
|
|
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(): Promise<Record<string, number>> {
|
|
const rows = await this.contractRepository
|
|
.createQueryBuilder("contract")
|
|
.select("contract.status", "status")
|
|
.addSelect("COUNT(*)::int", "count")
|
|
.where("contract.deleted_at IS NULL")
|
|
.groupBy("contract.status")
|
|
.getRawMany<{ status: string; count: string }>();
|
|
|
|
return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)]));
|
|
}
|
|
|
|
async getContractTrend(days: number): Promise<{ date: string; count: number }[]> {
|
|
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(`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(): Promise<{ label: string; count: number }[]> {
|
|
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'")
|
|
.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(): Promise<{ label: string; count: number }[]> {
|
|
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'")
|
|
.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): Promise<OverviewRecentContractRow[]> {
|
|
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")
|
|
.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,
|
|
}));
|
|
}
|
|
}
|