mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1097 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module";
|
|||||||
import { RoutesModule } from "./modules/routes/routes.module";
|
import { RoutesModule } from "./modules/routes/routes.module";
|
||||||
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
import { WarehousesModule } from "./modules/warehouses/warehouses.module";
|
||||||
import { OverviewModule } from "./modules/overview/overview.module";
|
import { OverviewModule } from "./modules/overview/overview.module";
|
||||||
|
import { ReportsModule } from "./modules/reports/reports.module";
|
||||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
@@ -207,6 +208,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
|||||||
RoutesModule,
|
RoutesModule,
|
||||||
WarehousesModule,
|
WarehousesModule,
|
||||||
OverviewModule,
|
OverviewModule,
|
||||||
|
ReportsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a train schedule whose booking-window rule was configured by staff at
|
||||||
|
* creation rather than inherited from the live global rules.
|
||||||
|
*
|
||||||
|
* Without this flag `restampPendingWindows` — which re-derives EVERY still
|
||||||
|
* PRE_WINDOW schedule from the current global config after a global-rules edit —
|
||||||
|
* would silently overwrite those hand-picked settings, which is precisely what
|
||||||
|
* the per-schedule configuration exists to prevent.
|
||||||
|
*
|
||||||
|
* Defaults false, so every existing schedule keeps following the global rules.
|
||||||
|
*/
|
||||||
|
export class AddScheduleWindowRuleCustom3200000000000 implements MigrationInterface {
|
||||||
|
name = 'AddScheduleWindowRuleCustom3200000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."train_schedules" ADD COLUMN IF NOT EXISTS "window_rule_custom" boolean NOT NULL DEFAULT false`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class OverviewBookingKpisDto {
|
export class OverviewBookingKpisDto {
|
||||||
|
@ApiProperty() total!: number;
|
||||||
@ApiProperty() totalActive!: number;
|
@ApiProperty() totalActive!: number;
|
||||||
@ApiProperty() needsAction!: number;
|
@ApiProperty() needsAction!: number;
|
||||||
@ApiProperty() urgent!: number;
|
@ApiProperty() urgent!: number;
|
||||||
@@ -9,6 +10,7 @@ export class OverviewBookingKpisDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OverviewContractKpisDto {
|
export class OverviewContractKpisDto {
|
||||||
|
@ApiProperty() total!: number;
|
||||||
@ApiProperty() totalActive!: number;
|
@ApiProperty() totalActive!: number;
|
||||||
@ApiProperty() needsAction!: number;
|
@ApiProperty() needsAction!: number;
|
||||||
@ApiProperty() inApproval!: number;
|
@ApiProperty() inApproval!: number;
|
||||||
@@ -21,6 +23,8 @@ export class OverviewOperationsKpisDto {
|
|||||||
@ApiProperty() wagonsAvailable!: number;
|
@ApiProperty() wagonsAvailable!: number;
|
||||||
@ApiProperty() containersInTransit!: number;
|
@ApiProperty() containersInTransit!: number;
|
||||||
@ApiProperty() cargoesLoaded!: number;
|
@ApiProperty() cargoesLoaded!: number;
|
||||||
|
@ApiProperty() schedulesUpcoming!: number;
|
||||||
|
@ApiProperty() dispatchedToday!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverviewCustomerKpisDto {
|
export class OverviewCustomerKpisDto {
|
||||||
|
|||||||
@@ -104,10 +104,40 @@ export class OverviewBillingTabDto {
|
|||||||
generatedAt!: string;
|
generatedAt!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class OverviewDirectionTrendPointDto {
|
||||||
|
@ApiProperty({ example: '2026-08-01' }) date!: string;
|
||||||
|
@ApiProperty() importCount!: number;
|
||||||
|
@ApiProperty() exportCount!: number;
|
||||||
|
@ApiProperty() domesticCount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OverviewTonnagePointDto {
|
||||||
|
@ApiProperty() label!: string;
|
||||||
|
@ApiProperty() tons!: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class OverviewOperationsTabDto {
|
export class OverviewOperationsTabDto {
|
||||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||||
kpis!: OverviewOperationsKpisDto;
|
kpis!: OverviewOperationsKpisDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewDirectionTrendPointDto] })
|
||||||
|
departureTrend!: OverviewDirectionTrendPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||||
|
scheduleStatusBreakdown!: OverviewStatusCountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||||
|
wagonsByType!: OverviewLabelCountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||||
|
wagonsByYard!: OverviewLabelCountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||||
|
containersBySize!: OverviewLabelCountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [OverviewTonnagePointDto] })
|
||||||
|
cargoTonnageByType!: OverviewTonnagePointDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||||
trainStatusBreakdown!: OverviewStatusCountDto[];
|
trainStatusBreakdown!: OverviewStatusCountDto[];
|
||||||
|
|
||||||
|
|||||||
@@ -99,8 +99,10 @@ export class OverviewController {
|
|||||||
@BookingView()
|
@BookingView()
|
||||||
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
@ApiOperation({ summary: 'Operations tab metrics and charts' })
|
||||||
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
@ApiOkResponse({ type: OverviewOperationsTabDto })
|
||||||
getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
getOperationsTab(
|
||||||
return this.overviewService.getOperationsTab();
|
@Query() query: OverviewQueryDto,
|
||||||
|
): Promise<OverviewOperationsTabDto> {
|
||||||
|
return this.overviewService.getOperationsTab(query.range ?? '30d');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('customers')
|
@Get('customers')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity";
|
|||||||
import { Company } from "../companies/entities/company.entity";
|
import { Company } from "../companies/entities/company.entity";
|
||||||
import { Contract } from "../contracts/entities/contract.entity";
|
import { Contract } from "../contracts/entities/contract.entity";
|
||||||
import { PaymentEntity } from "../payment/entities/payment.entity";
|
import { PaymentEntity } from "../payment/entities/payment.entity";
|
||||||
|
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||||
import { Train } from "../trains/entities/train.entity";
|
import { Train } from "../trains/entities/train.entity";
|
||||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||||
@@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service";
|
|||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
Company,
|
Company,
|
||||||
Train,
|
Train,
|
||||||
|
TrainSchedule,
|
||||||
Wagon,
|
Wagon,
|
||||||
Container,
|
Container,
|
||||||
Cargo,
|
Cargo,
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity";
|
|||||||
import { Container } from "../container-management/entities/container.entity";
|
import { Container } from "../container-management/entities/container.entity";
|
||||||
import { Contract } from "../contracts/entities/contract.entity";
|
import { Contract } from "../contracts/entities/contract.entity";
|
||||||
import { PaymentEntity } from "../payment/entities/payment.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 { Train } from "../trains/entities/train.entity";
|
||||||
|
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||||
import { Wagon } from "../wagons/entities/wagon.entity";
|
import { Wagon } from "../wagons/entities/wagon.entity";
|
||||||
import {
|
import {
|
||||||
OVERVIEW_CLOSED_STATUSES,
|
OVERVIEW_CLOSED_STATUSES,
|
||||||
@@ -34,6 +39,7 @@ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
|
|||||||
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
|
||||||
|
|
||||||
export type OverviewBookingKpisRow = {
|
export type OverviewBookingKpisRow = {
|
||||||
|
total: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
urgent: number;
|
urgent: number;
|
||||||
@@ -53,6 +59,7 @@ export type OverviewRecentBookingRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type OverviewContractKpisRow = {
|
export type OverviewContractKpisRow = {
|
||||||
|
total: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
inApproval: number;
|
inApproval: number;
|
||||||
@@ -83,6 +90,8 @@ export class OverviewRepository {
|
|||||||
private readonly companyRepository: Repository<Company>,
|
private readonly companyRepository: Repository<Company>,
|
||||||
@InjectRepository(Train)
|
@InjectRepository(Train)
|
||||||
private readonly trainRepository: Repository<Train>,
|
private readonly trainRepository: Repository<Train>,
|
||||||
|
@InjectRepository(TrainSchedule)
|
||||||
|
private readonly trainScheduleRepository: Repository<TrainSchedule>,
|
||||||
@InjectRepository(Wagon)
|
@InjectRepository(Wagon)
|
||||||
private readonly wagonRepository: Repository<Wagon>,
|
private readonly wagonRepository: Repository<Wagon>,
|
||||||
@InjectRepository(Container)
|
@InjectRepository(Container)
|
||||||
@@ -101,7 +110,8 @@ export class OverviewRepository {
|
|||||||
const scope = directionScopeSql("booking.trade_direction", dirs);
|
const scope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
const row = await this.bookingRepository
|
const row = await this.bookingRepository
|
||||||
.createQueryBuilder("booking")
|
.createQueryBuilder("booking")
|
||||||
.select(
|
.select("COUNT(*)::int", "total")
|
||||||
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
`COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`,
|
||||||
"totalActive",
|
"totalActive",
|
||||||
)
|
)
|
||||||
@@ -133,6 +143,7 @@ export class OverviewRepository {
|
|||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
total: Number(row?.total ?? 0),
|
||||||
totalActive: Number(row?.totalActive ?? 0),
|
totalActive: Number(row?.totalActive ?? 0),
|
||||||
needsAction: Number(row?.needsAction ?? 0),
|
needsAction: Number(row?.needsAction ?? 0),
|
||||||
urgent: Number(row?.urgent ?? 0),
|
urgent: Number(row?.urgent ?? 0),
|
||||||
@@ -146,9 +157,17 @@ export class OverviewRepository {
|
|||||||
wagonsAvailable: number;
|
wagonsAvailable: number;
|
||||||
containersInTransit: number;
|
containersInTransit: number;
|
||||||
cargoesLoaded: number;
|
cargoesLoaded: number;
|
||||||
|
schedulesUpcoming: number;
|
||||||
|
dispatchedToday: number;
|
||||||
}> {
|
}> {
|
||||||
const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] =
|
const [
|
||||||
await Promise.all([
|
trainsActive,
|
||||||
|
wagonsAvailable,
|
||||||
|
containersInTransit,
|
||||||
|
cargoesLoaded,
|
||||||
|
schedulesUpcoming,
|
||||||
|
dispatchedToday,
|
||||||
|
] = await Promise.all([
|
||||||
this.trainRepository
|
this.trainRepository
|
||||||
.createQueryBuilder("train")
|
.createQueryBuilder("train")
|
||||||
.where("train.deleted_at IS NULL")
|
.where("train.deleted_at IS NULL")
|
||||||
@@ -178,6 +197,22 @@ export class OverviewRepository {
|
|||||||
statuses: ["LOADED", "IN_TRANSIT"],
|
statuses: ["LOADED", "IN_TRANSIT"],
|
||||||
})
|
})
|
||||||
.getCount(),
|
.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 {
|
return {
|
||||||
@@ -185,6 +220,8 @@ export class OverviewRepository {
|
|||||||
wagonsAvailable,
|
wagonsAvailable,
|
||||||
containersInTransit,
|
containersInTransit,
|
||||||
cargoesLoaded,
|
cargoesLoaded,
|
||||||
|
schedulesUpcoming,
|
||||||
|
dispatchedToday,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,6 +570,141 @@ export class OverviewRepository {
|
|||||||
return this.statusBreakdown(this.cargoRepository, "cargo");
|
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(
|
private async statusBreakdown(
|
||||||
repository: Repository<ObjectLiteral>,
|
repository: Repository<ObjectLiteral>,
|
||||||
alias: string,
|
alias: string,
|
||||||
@@ -677,7 +849,8 @@ export class OverviewRepository {
|
|||||||
const scope = directionScopeSql("contract.trade_direction", dirs);
|
const scope = directionScopeSql("contract.trade_direction", dirs);
|
||||||
const row = await this.contractRepository
|
const row = await this.contractRepository
|
||||||
.createQueryBuilder("contract")
|
.createQueryBuilder("contract")
|
||||||
.select(
|
.select("COUNT(*)::int", "total")
|
||||||
|
.addSelect(
|
||||||
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
|
`COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`,
|
||||||
"totalActive",
|
"totalActive",
|
||||||
)
|
)
|
||||||
@@ -708,6 +881,7 @@ export class OverviewRepository {
|
|||||||
.getRawOne<Record<string, string>>();
|
.getRawOne<Record<string, string>>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
total: Number(row?.total ?? 0),
|
||||||
totalActive: Number(row?.totalActive ?? 0),
|
totalActive: Number(row?.totalActive ?? 0),
|
||||||
needsAction: Number(row?.needsAction ?? 0),
|
needsAction: Number(row?.needsAction ?? 0),
|
||||||
inApproval: Number(row?.inApproval ?? 0),
|
inApproval: Number(row?.inApproval ?? 0),
|
||||||
|
|||||||
@@ -202,15 +202,31 @@ export class OverviewService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOperationsTab(): Promise<OverviewOperationsTabDto> {
|
async getOperationsTab(
|
||||||
|
range: OverviewRangeQuery = '30d',
|
||||||
|
): Promise<OverviewOperationsTabDto> {
|
||||||
|
const days = OVERVIEW_RANGE_DAYS[range];
|
||||||
|
|
||||||
const [
|
const [
|
||||||
kpis,
|
kpis,
|
||||||
|
departureTrend,
|
||||||
|
scheduleStatusBreakdown,
|
||||||
|
wagonsByType,
|
||||||
|
wagonsByYard,
|
||||||
|
containersBySize,
|
||||||
|
cargoTonnageByType,
|
||||||
trainStatusBreakdown,
|
trainStatusBreakdown,
|
||||||
wagonStatusBreakdown,
|
wagonStatusBreakdown,
|
||||||
containerStatusBreakdown,
|
containerStatusBreakdown,
|
||||||
cargoStatusBreakdown,
|
cargoStatusBreakdown,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.overviewRepository.getOperationsKpis(),
|
this.overviewRepository.getOperationsKpis(),
|
||||||
|
this.overviewRepository.getDepartureTrend(days),
|
||||||
|
this.overviewRepository.getScheduleStatusBreakdown(),
|
||||||
|
this.overviewRepository.getWagonsByType(),
|
||||||
|
this.overviewRepository.getWagonsByYard(8),
|
||||||
|
this.overviewRepository.getContainersBySize(),
|
||||||
|
this.overviewRepository.getCargoTonnageByType(8),
|
||||||
this.overviewRepository.getTrainStatusBreakdown(),
|
this.overviewRepository.getTrainStatusBreakdown(),
|
||||||
this.overviewRepository.getWagonStatusBreakdown(),
|
this.overviewRepository.getWagonStatusBreakdown(),
|
||||||
this.overviewRepository.getContainerStatusBreakdown(),
|
this.overviewRepository.getContainerStatusBreakdown(),
|
||||||
@@ -219,6 +235,12 @@ export class OverviewService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
kpis,
|
kpis,
|
||||||
|
departureTrend,
|
||||||
|
scheduleStatusBreakdown,
|
||||||
|
wagonsByType,
|
||||||
|
wagonsByYard,
|
||||||
|
containersBySize,
|
||||||
|
cargoTonnageByType,
|
||||||
trainStatusBreakdown,
|
trainStatusBreakdown,
|
||||||
wagonStatusBreakdown,
|
wagonStatusBreakdown,
|
||||||
containerStatusBreakdown,
|
containerStatusBreakdown,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class ReportQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Inclusive start date (YYYY-MM-DD). Default: 30 days ago.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateFrom?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Inclusive end date (YYYY-MM-DD). Default: today.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
dateTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['day', 'week', 'month'], default: 'day' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['day', 'week', 'month'])
|
||||||
|
granularity?: 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated company UUIDs' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyIds?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated route UUIDs' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
routeIds?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated yard UUIDs (matches origin or destination)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
yardIds?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated cargo type UUIDs' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cargoTypeIds?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Comma-separated status values (report-specific)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
statuses?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Trade direction filter' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
direction?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['CONTAINER', 'BULK'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['CONTAINER', 'BULK'])
|
||||||
|
freightType?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ReportKpiDto {
|
||||||
|
@ApiProperty()
|
||||||
|
label!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
value!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportResultDto {
|
||||||
|
@ApiProperty({ type: [ReportKpiDto] })
|
||||||
|
kpis!: ReportKpiDto[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'object', additionalProperties: true },
|
||||||
|
description: 'Report rows; columns vary per report key',
|
||||||
|
})
|
||||||
|
rows!: Record<string, unknown>[];
|
||||||
|
}
|
||||||
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
669
apps/edr-freight-api/src/modules/reports/report-queries.ts
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
export interface ReportFilters {
|
||||||
|
/** ISO timestamp, inclusive lower bound. null = no lower bound (all time). */
|
||||||
|
dateFrom: string | null;
|
||||||
|
/** ISO timestamp, exclusive upper bound. null = no upper bound. */
|
||||||
|
dateTo: string | null;
|
||||||
|
granularity: 'day' | 'week' | 'month';
|
||||||
|
companyIds: string[] | null;
|
||||||
|
routeIds: string[] | null;
|
||||||
|
yardIds: string[] | null;
|
||||||
|
cargoTypeIds: string[] | null;
|
||||||
|
statuses: string[] | null;
|
||||||
|
/** Trade-scope-resolved directions. null = unrestricted, [] = show nothing. */
|
||||||
|
directions: string[] | null;
|
||||||
|
freightType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportKpi {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportResult {
|
||||||
|
kpis: ReportKpi[];
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise<ReportResult>;
|
||||||
|
|
||||||
|
// For PER_ITEM bulk bookings cargo_total_weight_vgm holds an item COUNT, and
|
||||||
|
// the real tonnage lives in bulk_total_weight_tons — hence the COALESCE order.
|
||||||
|
const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)';
|
||||||
|
// adjusted_total_amount silently overrides total_amount when set.
|
||||||
|
const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)';
|
||||||
|
// GENERAL contract_kind rows are umbrella contracts, not shipments; counting
|
||||||
|
// them double-counts every child booking (same guard as overview.repository).
|
||||||
|
const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')";
|
||||||
|
const DEAD_STATUSES = "'DRAFT','CANCELLED','REJECTED','EXPIRED'";
|
||||||
|
|
||||||
|
const num = (v: unknown): number => (v === null || v === undefined ? 0 : Number(v));
|
||||||
|
const sum = (rows: Record<string, unknown>[], col: string): number =>
|
||||||
|
rows.reduce((acc, r) => acc + num(r[col]), 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared WHERE for booking-based reports (alias `b`).
|
||||||
|
* Params occupy $1..$8 in this fixed order; report SQL continues at $9.
|
||||||
|
*/
|
||||||
|
function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } {
|
||||||
|
return {
|
||||||
|
where: `
|
||||||
|
b.deleted_at IS NULL
|
||||||
|
AND ${NOT_UMBRELLA}
|
||||||
|
AND ($1::timestamptz IS NULL OR b.created_at >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR b.created_at < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR b.company_id = ANY($3))
|
||||||
|
AND ($4::uuid[] IS NULL OR b.cargo_type_id = ANY($4))
|
||||||
|
AND ($5::text[] IS NULL OR b.trade_direction = ANY($5))
|
||||||
|
AND ($6::text IS NULL OR b.freight_type = $6)
|
||||||
|
AND (CASE WHEN $7::text[] IS NULL
|
||||||
|
THEN b.status NOT IN (${DEAD_STATUSES})
|
||||||
|
ELSE b.status = ANY($7) END)
|
||||||
|
AND ($8::uuid[] IS NULL OR b.origin_yard_id = ANY($8) OR b.destination_yard_id = ANY($8))`,
|
||||||
|
params: [
|
||||||
|
f.dateFrom,
|
||||||
|
f.dateTo,
|
||||||
|
f.companyIds,
|
||||||
|
f.cargoTypeIds,
|
||||||
|
f.directions,
|
||||||
|
f.freightType,
|
||||||
|
f.statuses,
|
||||||
|
f.yardIds,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction scope for rows that reference a booking through a varchar id
|
||||||
|
* column (invoices.source_id, payments.ref_id). Rows not pointing at a
|
||||||
|
* booking stay visible — they carry no direction to scope by.
|
||||||
|
* (Positional-param port of trade-scope.util's bookingRefScopeSql.)
|
||||||
|
*/
|
||||||
|
const refDirScope = (refColumn: string, param: string): string => `
|
||||||
|
(${param}::text[] IS NULL OR NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.bookings sb
|
||||||
|
WHERE sb.id::text = ${refColumn} AND NOT (sb.trade_direction = ANY(${param}))))`;
|
||||||
|
|
||||||
|
const bookingsTrend: ReportQuery = async (ds, f) => {
|
||||||
|
const { where, params } = bookingWhere(f);
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT to_char(date_trunc($9, b.created_at), 'YYYY-MM-DD') AS period,
|
||||||
|
COUNT(*)::int AS bookings,
|
||||||
|
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||||
|
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE ${where}
|
||||||
|
GROUP BY 1 ORDER BY 1`,
|
||||||
|
[...params, f.granularity],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Bookings', value: sum(rows, 'bookings') },
|
||||||
|
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||||
|
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const revenueByCustomer: ReportQuery = async (ds, f) => {
|
||||||
|
const { where, params } = bookingWhere(f);
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT c.name AS customer,
|
||||||
|
COUNT(*)::int AS bookings,
|
||||||
|
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||||
|
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.companies c ON c.id = b.company_id
|
||||||
|
WHERE ${where}
|
||||||
|
GROUP BY c.name ORDER BY revenue DESC LIMIT 100`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
const total = sum(rows, 'revenue');
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Customers', value: rows.length },
|
||||||
|
{ label: 'Revenue', value: total, unit: 'ETB' },
|
||||||
|
{
|
||||||
|
label: 'Top customer share',
|
||||||
|
value: total > 0 ? Math.round((num(rows[0]?.revenue) / total) * 100) : 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const revenueByLane: ReportQuery = async (ds, f) => {
|
||||||
|
const { where, params } = bookingWhere(f);
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT o.label AS origin, d.label AS destination,
|
||||||
|
COUNT(*)::int AS bookings,
|
||||||
|
ROUND(COALESCE(SUM(${TONS}), 0))::float8 AS tons,
|
||||||
|
ROUND(COALESCE(SUM(${REVENUE}), 0))::float8 AS revenue
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||||
|
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||||
|
WHERE ${where}
|
||||||
|
GROUP BY 1, 2 ORDER BY revenue DESC LIMIT 100`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Lanes', value: rows.length },
|
||||||
|
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||||
|
{ label: 'Revenue', value: sum(rows, 'revenue'), unit: 'ETB' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractUtilization: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT ct.reference, c.name AS customer, ct.status, ct.contract_kind AS kind,
|
||||||
|
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||||
|
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||||
|
cap.committed::float8 AS committed,
|
||||||
|
booked.tons::float8 AS booked_tons,
|
||||||
|
booked.cnt AS bookings,
|
||||||
|
CASE WHEN cap.committed > 0
|
||||||
|
THEN ROUND(booked.tons / cap.committed * 100)::float8 END AS utilization_pct
|
||||||
|
FROM freight.contracts ct
|
||||||
|
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(s.quantity_cap), 0) AS committed
|
||||||
|
FROM freight.contract_cargo_scope s
|
||||||
|
WHERE s.contract_id = ct.id AND s.deleted_at IS NULL) cap ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(${TONS}), 0) AS tons, COUNT(*)::int AS cnt
|
||||||
|
FROM freight.bookings b
|
||||||
|
WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
|
||||||
|
AND b.status NOT IN (${DEAD_STATUSES})) booked ON true
|
||||||
|
WHERE ct.deleted_at IS NULL
|
||||||
|
AND ct.status NOT IN ('DRAFT')
|
||||||
|
AND ct.contract_valid_from < COALESCE($2::timestamptz, 'infinity')
|
||||||
|
AND (ct.contract_valid_until IS NULL
|
||||||
|
OR ct.contract_valid_until >= COALESCE($1::timestamptz, '-infinity'))
|
||||||
|
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||||
|
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||||
|
ORDER BY utilization_pct DESC NULLS LAST LIMIT 200`,
|
||||||
|
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||||
|
);
|
||||||
|
const capped = rows.filter((r: Record<string, unknown>) => num(r.committed) > 0);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Contracts', value: rows.length },
|
||||||
|
{
|
||||||
|
label: 'Avg utilization',
|
||||||
|
value: capped.length
|
||||||
|
? Math.round(sum(capped, 'utilization_pct') / capped.length)
|
||||||
|
: 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ponytail: 60-min departure grace is a constant; make it a query param if ops
|
||||||
|
// ever wants a configurable threshold.
|
||||||
|
const trainOnTime: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT o.label AS origin, d.label AS destination,
|
||||||
|
COUNT(*)::int AS trips,
|
||||||
|
COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL)::int AS departed,
|
||||||
|
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_departure_at - ts.scheduled_departure_date)) / 60)
|
||||||
|
FILTER (WHERE ts.actual_departure_at IS NOT NULL))::float8 AS avg_dep_delay_min,
|
||||||
|
ROUND(AVG(EXTRACT(EPOCH FROM (ts.actual_arrival_at - ts.scheduled_arrival_date)) / 60)
|
||||||
|
FILTER (WHERE ts.actual_arrival_at IS NOT NULL
|
||||||
|
AND ts.scheduled_arrival_date IS NOT NULL))::float8 AS avg_arr_delay_min,
|
||||||
|
ROUND(100.0 * COUNT(*) FILTER (WHERE ts.actual_departure_at
|
||||||
|
<= ts.scheduled_departure_date + interval '60 minutes')
|
||||||
|
/ NULLIF(COUNT(*) FILTER (WHERE ts.actual_departure_at IS NOT NULL), 0))::float8 AS on_time_pct
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||||
|
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||||
|
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||||
|
GROUP BY 1, 2 ORDER BY trips DESC`,
|
||||||
|
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||||
|
);
|
||||||
|
const departed = sum(rows, 'departed');
|
||||||
|
const weighted = rows.reduce(
|
||||||
|
(acc: number, r: Record<string, unknown>) =>
|
||||||
|
acc + (num(r.on_time_pct) * num(r.departed)) / 100,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||||
|
{
|
||||||
|
label: 'On-time departures',
|
||||||
|
value: departed > 0 ? Math.round((weighted / departed) * 100) : 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg departure delay',
|
||||||
|
value: rows.length ? Math.round(sum(rows, 'avg_dep_delay_min') / rows.length) : 0,
|
||||||
|
unit: 'min',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleFillRate: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT ts.train_number, ts.reference,
|
||||||
|
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD') AS departure,
|
||||||
|
o.label AS origin, d.label AS destination, ts.direction, ts.status,
|
||||||
|
ts.max_wagons, tset.wagon_count,
|
||||||
|
ROUND(w.cap_tons)::float8 AS capacity_tons,
|
||||||
|
ROUND(w.booked_tons)::float8 AS booked_tons,
|
||||||
|
CASE WHEN w.cap_tons > 0
|
||||||
|
THEN ROUND(w.booked_tons / w.cap_tons * 100)::float8 END AS fill_pct
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||||
|
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(tw.capacity_tons), 0) AS cap_tons,
|
||||||
|
COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||||
|
FROM freight.train_set_wagons tw
|
||||||
|
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ts.status <> 'CANCELLED'
|
||||||
|
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||||
|
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||||
|
ORDER BY ts.scheduled_departure_date DESC LIMIT 200`,
|
||||||
|
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||||
|
);
|
||||||
|
const withCap = rows.filter((r: Record<string, unknown>) => num(r.capacity_tons) > 0);
|
||||||
|
const capTons = sum(withCap, 'capacity_tons');
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Schedules', value: rows.length },
|
||||||
|
{
|
||||||
|
label: 'Avg fill rate',
|
||||||
|
value: capTons > 0 ? Math.round((sum(withCap, 'booked_tons') / capTons) * 100) : 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
{ label: 'Booked tonnage', value: sum(rows, 'booked_tons'), unit: 't' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const tripsPerRoute: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT o.label AS origin, d.label AS destination, ts.direction,
|
||||||
|
COUNT(*)::int AS trips,
|
||||||
|
ROUND(COALESCE(SUM(w.booked_tons), 0))::float8 AS tons_hauled,
|
||||||
|
ROUND(COALESCE(AVG(w.booked_tons), 0))::float8 AS avg_tons_per_trip
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT COALESCE(SUM(tw.assigned_weight_tons), 0) AS booked_tons
|
||||||
|
FROM freight.train_set_wagons tw
|
||||||
|
WHERE tw.train_set_id = ts.train_set_id AND tw.deleted_at IS NULL) w ON true
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ts.status IN ('DISPATCHED', 'ARRIVED')
|
||||||
|
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR ts.route_id = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ts.direction = ANY($4))
|
||||||
|
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||||
|
GROUP BY 1, 2, 3 ORDER BY trips DESC`,
|
||||||
|
[f.dateFrom, f.dateTo, f.routeIds, f.directions, f.yardIds],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Trips', value: sum(rows, 'trips') },
|
||||||
|
{ label: 'Routes served', value: rows.length },
|
||||||
|
{ label: 'Tonnage hauled', value: sum(rows, 'tons_hauled'), unit: 't' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const invoicedVsCollected: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT to_char(date_trunc($5, COALESCE(i.issued_at, i.created_at)), 'YYYY-MM-DD') AS period,
|
||||||
|
COUNT(*)::int AS invoices,
|
||||||
|
ROUND(SUM(i.total_amount))::float8 AS invoiced,
|
||||||
|
ROUND(SUM(i.paid_amount))::float8 AS collected,
|
||||||
|
ROUND(SUM(i.balance_amount))::float8 AS outstanding
|
||||||
|
FROM freight.invoices i
|
||||||
|
WHERE i.deleted_at IS NULL
|
||||||
|
AND i.status NOT IN ('DRAFT', 'CANCELLED')
|
||||||
|
AND ($1::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR COALESCE(i.issued_at, i.created_at) < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR i.company_id = ANY($3))
|
||||||
|
AND ${refDirScope('i.source_id', '$4')}
|
||||||
|
GROUP BY 1 ORDER BY 1`,
|
||||||
|
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.granularity],
|
||||||
|
);
|
||||||
|
const invoiced = sum(rows, 'invoiced');
|
||||||
|
const collected = sum(rows, 'collected');
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Invoiced', value: invoiced, unit: 'ETB' },
|
||||||
|
{ label: 'Collected', value: collected, unit: 'ETB' },
|
||||||
|
{
|
||||||
|
label: 'Collection rate',
|
||||||
|
value: invoiced > 0 ? Math.round((collected / invoiced) * 100) : 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
{ label: 'Outstanding', value: sum(rows, 'outstanding'), unit: 'ETB' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aging is an as-of snapshot: dateTo is the as-of moment (default now),
|
||||||
|
// dateFrom is ignored.
|
||||||
|
const agingReceivables: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT c.name AS customer,
|
||||||
|
COUNT(*)::int AS invoices,
|
||||||
|
ROUND(SUM(i.balance_amount))::float8 AS outstanding,
|
||||||
|
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at >= COALESCE($1::timestamptz, now())), 0))::float8 AS current,
|
||||||
|
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now())
|
||||||
|
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '30 days'), 0))::float8 AS overdue_0_30,
|
||||||
|
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '30 days'
|
||||||
|
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '60 days'), 0))::float8 AS overdue_31_60,
|
||||||
|
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '60 days'
|
||||||
|
AND i.due_at >= COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_61_90,
|
||||||
|
ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE($1::timestamptz, now()) - interval '90 days'), 0))::float8 AS overdue_90_plus
|
||||||
|
FROM freight.invoices i
|
||||||
|
JOIN freight.companies c ON c.id = i.company_id
|
||||||
|
WHERE i.deleted_at IS NULL
|
||||||
|
AND i.status IN ('ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE')
|
||||||
|
AND i.balance_amount > 0
|
||||||
|
AND ($1::timestamptz IS NULL OR i.created_at < $1)
|
||||||
|
AND ($2::uuid[] IS NULL OR i.company_id = ANY($2))
|
||||||
|
AND ${refDirScope('i.source_id', '$3')}
|
||||||
|
GROUP BY 1 ORDER BY outstanding DESC LIMIT 200`,
|
||||||
|
[f.dateTo, f.companyIds, f.directions],
|
||||||
|
);
|
||||||
|
const outstanding = sum(rows, 'outstanding');
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Outstanding', value: outstanding, unit: 'ETB' },
|
||||||
|
{ label: 'Overdue', value: outstanding - sum(rows, 'current'), unit: 'ETB' },
|
||||||
|
{ label: 'Customers with balance', value: rows.length },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const revenueByPaymentMethod: ReportQuery = async (ds, f) => {
|
||||||
|
// payments.status values are lowercase-hyphenated ('success'), unlike every
|
||||||
|
// other status enum in the schema. No deleted_at on this table.
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT p.method::text AS method,
|
||||||
|
COUNT(*)::int AS payments,
|
||||||
|
ROUND(SUM(p.amount))::float8 AS amount
|
||||||
|
FROM freight.payments p
|
||||||
|
WHERE p.status = 'success'
|
||||||
|
AND ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||||
|
AND ${refDirScope('p.ref_id', '$3')}
|
||||||
|
GROUP BY 1 ORDER BY amount DESC`,
|
||||||
|
[f.dateFrom, f.dateTo, f.directions],
|
||||||
|
);
|
||||||
|
const total = sum(rows, 'amount');
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Collected', value: total, unit: 'ETB' },
|
||||||
|
{ label: 'Payments', value: sum(rows, 'payments') },
|
||||||
|
{
|
||||||
|
label: 'Top method share',
|
||||||
|
value: total > 0 ? Math.round((num(rows[0]?.amount) / total) * 100) : 0,
|
||||||
|
unit: '%',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Record-level list exports. Same engine, raw rows instead of aggregates.
|
||||||
|
// ponytail: flat LIMIT 5000 per list — stream/paginate the export if a table
|
||||||
|
// ever outgrows that.
|
||||||
|
const LIST_LIMIT = 5000;
|
||||||
|
|
||||||
|
const bookingsList: ReportQuery = async (ds, f) => {
|
||||||
|
const { where, params } = bookingWhere(f);
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT b.reference,
|
||||||
|
to_char(b.created_at, 'YYYY-MM-DD') AS created,
|
||||||
|
c.name AS customer, b.status, b.freight_type,
|
||||||
|
b.trade_direction AS direction,
|
||||||
|
o.label AS origin, d.label AS destination,
|
||||||
|
COALESCE(cty.cargo_type_name, b.cargo_free_text) AS cargo,
|
||||||
|
ROUND(${TONS})::float8 AS tons,
|
||||||
|
ROUND(${REVENUE})::float8 AS amount,
|
||||||
|
b.payment_status, b.scheduling_status
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.companies c ON c.id = b.company_id
|
||||||
|
JOIN freight.yards o ON o.id = b.origin_yard_id
|
||||||
|
JOIN freight.yards d ON d.id = b.destination_yard_id
|
||||||
|
LEFT JOIN freight.cargo_types cty ON cty.id = b.cargo_type_id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY b.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Bookings', value: rows.length },
|
||||||
|
{ label: 'Tonnage', value: sum(rows, 'tons'), unit: 't' },
|
||||||
|
{ label: 'Amount', value: sum(rows, 'amount'), unit: 'ETB' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const contractsList: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT ct.reference, c.name AS customer, ct.contract_kind AS kind,
|
||||||
|
ct.status, ct.trade_direction AS direction, ct.freight_type,
|
||||||
|
to_char(ct.contract_valid_from, 'YYYY-MM-DD') AS valid_from,
|
||||||
|
to_char(ct.contract_valid_until, 'YYYY-MM-DD') AS valid_until,
|
||||||
|
to_char(ct.created_at, 'YYYY-MM-DD') AS created
|
||||||
|
FROM freight.contracts ct
|
||||||
|
LEFT JOIN freight.companies c ON c.id = ct.company_id
|
||||||
|
WHERE ct.deleted_at IS NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR ct.created_at >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR ct.created_at < $2)
|
||||||
|
AND ($3::uuid[] IS NULL OR ct.company_id = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ct.trade_direction = ANY($4))
|
||||||
|
AND ($5::text[] IS NULL OR ct.status = ANY($5))
|
||||||
|
ORDER BY ct.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.dateFrom, f.dateTo, f.companyIds, f.directions, f.statuses],
|
||||||
|
);
|
||||||
|
const active = rows.filter((r: Record<string, unknown>) =>
|
||||||
|
['CONTRACT_ACTIVE', 'ACTIVE_SHIPMENT_IN_PROGRESS'].includes(String(r.status)),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Contracts', value: rows.length },
|
||||||
|
{ label: 'Active', value: active },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulesList: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT ts.train_number, ts.reference, ts.direction, ts.status,
|
||||||
|
o.label AS origin, d.label AS destination,
|
||||||
|
to_char(ts.scheduled_departure_date, 'YYYY-MM-DD HH24:MI') AS scheduled_departure,
|
||||||
|
to_char(ts.actual_departure_at, 'YYYY-MM-DD HH24:MI') AS actual_departure,
|
||||||
|
to_char(ts.scheduled_arrival_date, 'YYYY-MM-DD HH24:MI') AS scheduled_arrival,
|
||||||
|
to_char(ts.actual_arrival_at, 'YYYY-MM-DD HH24:MI') AS actual_arrival,
|
||||||
|
ts.max_wagons, tset.wagon_count
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id
|
||||||
|
LEFT JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
WHERE ts.deleted_at IS NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR ts.scheduled_departure_date >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR ts.scheduled_departure_date < $2)
|
||||||
|
AND ($3::text[] IS NULL OR ts.direction = ANY($3))
|
||||||
|
AND ($4::text[] IS NULL OR ts.status = ANY($4))
|
||||||
|
AND ($5::uuid[] IS NULL OR ts.origin_station_id = ANY($5) OR ts.destination_station_id = ANY($5))
|
||||||
|
ORDER BY ts.scheduled_departure_date DESC LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.dateFrom, f.dateTo, f.directions, f.statuses, f.yardIds],
|
||||||
|
);
|
||||||
|
const count = (s: string) =>
|
||||||
|
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Schedules', value: rows.length },
|
||||||
|
{ label: 'Dispatched', value: count('DISPATCHED') },
|
||||||
|
{ label: 'Arrived', value: count('ARRIVED') },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fleetWagons: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT w.wagon_number, wt.name AS type,
|
||||||
|
wt.capacity_tons::float8 AS capacity_tons,
|
||||||
|
w.status, y.label AS current_yard
|
||||||
|
FROM freight.wagons w
|
||||||
|
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||||
|
LEFT JOIN freight.yards y ON y.id = w.current_yard_id
|
||||||
|
WHERE w.deleted_at IS NULL
|
||||||
|
AND ($1::text[] IS NULL OR w.status = ANY($1))
|
||||||
|
AND ($2::uuid[] IS NULL OR w.current_yard_id = ANY($2))
|
||||||
|
ORDER BY w.wagon_number LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.statuses, f.yardIds],
|
||||||
|
);
|
||||||
|
const count = (s: string) =>
|
||||||
|
rows.filter((r: Record<string, unknown>) => r.status === s).length;
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Wagons', value: rows.length },
|
||||||
|
{ label: 'Available', value: count('AVAILABLE') },
|
||||||
|
{ label: 'Assigned', value: count('ASSIGNED') },
|
||||||
|
{ label: 'Maintenance', value: count('MAINTENANCE') },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fleetLocomotives: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT l.code, l.name, l.locomotive_type,
|
||||||
|
l.max_pull_weight_tons::float8 AS max_pull_tons,
|
||||||
|
l.status, y.label AS current_yard
|
||||||
|
FROM freight.locomotives l
|
||||||
|
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||||
|
WHERE l.deleted_at IS NULL
|
||||||
|
AND ($1::text[] IS NULL OR l.status = ANY($1))
|
||||||
|
AND ($2::uuid[] IS NULL OR l.current_yard_id = ANY($2))
|
||||||
|
ORDER BY l.code LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.statuses, f.yardIds],
|
||||||
|
);
|
||||||
|
const available = rows.filter(
|
||||||
|
(r: Record<string, unknown>) => r.status === 'AVAILABLE',
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Locomotives', value: rows.length },
|
||||||
|
{ label: 'Available', value: available },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const customersList: ReportQuery = async (ds, f) => {
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT c.name, c.type, c.kind, c.status, c.tin,
|
||||||
|
to_char(c.approved_at, 'YYYY-MM-DD') AS approved,
|
||||||
|
to_char(c.created_at, 'YYYY-MM-DD') AS created
|
||||||
|
FROM freight.companies c
|
||||||
|
WHERE c.deleted_at IS NULL
|
||||||
|
AND ($1::timestamptz IS NULL OR c.created_at >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR c.created_at < $2)
|
||||||
|
AND ($3::text[] IS NULL OR c.status = ANY($3))
|
||||||
|
ORDER BY c.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.dateFrom, f.dateTo, f.statuses],
|
||||||
|
);
|
||||||
|
const active = rows.filter(
|
||||||
|
(r: Record<string, unknown>) => r.status === 'active',
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Customers', value: rows.length },
|
||||||
|
{ label: 'Active', value: active },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentsList: ReportQuery = async (ds, f) => {
|
||||||
|
// No deleted_at on freight.payments; statuses are lowercase-hyphenated.
|
||||||
|
const rows = await ds.query(
|
||||||
|
`SELECT to_char(p.created_at, 'YYYY-MM-DD HH24:MI') AS created,
|
||||||
|
p.method::text AS method, p.status::text AS status,
|
||||||
|
p.currency::text AS currency,
|
||||||
|
ROUND(p.amount)::float8 AS amount,
|
||||||
|
p.transaction_id, p.merchant_order_id,
|
||||||
|
to_char(p.paid_at, 'YYYY-MM-DD') AS paid
|
||||||
|
FROM freight.payments p
|
||||||
|
WHERE ($1::timestamptz IS NULL OR p.created_at >= $1)
|
||||||
|
AND ($2::timestamptz IS NULL OR p.created_at < $2)
|
||||||
|
AND ($3::text[] IS NULL OR p.status::text = ANY($3))
|
||||||
|
AND ${refDirScope('p.ref_id', '$4')}
|
||||||
|
ORDER BY p.created_at DESC LIMIT ${LIST_LIMIT}`,
|
||||||
|
[f.dateFrom, f.dateTo, f.statuses, f.directions],
|
||||||
|
);
|
||||||
|
const success = rows.filter(
|
||||||
|
(r: Record<string, unknown>) => r.status === 'success',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
kpis: [
|
||||||
|
{ label: 'Payments', value: rows.length },
|
||||||
|
{ label: 'Successful', value: success.length },
|
||||||
|
{ label: 'Collected', value: sum(success, 'amount'), unit: 'ETB' },
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REPORT_QUERIES: Record<string, ReportQuery> = {
|
||||||
|
'bookings-list': bookingsList,
|
||||||
|
'contracts-list': contractsList,
|
||||||
|
'schedules-list': schedulesList,
|
||||||
|
'fleet-wagons': fleetWagons,
|
||||||
|
'fleet-locomotives': fleetLocomotives,
|
||||||
|
'customers-list': customersList,
|
||||||
|
'payments-list': paymentsList,
|
||||||
|
'bookings-trend': bookingsTrend,
|
||||||
|
'revenue-by-customer': revenueByCustomer,
|
||||||
|
'revenue-by-lane': revenueByLane,
|
||||||
|
'contract-utilization': contractUtilization,
|
||||||
|
'train-on-time': trainOnTime,
|
||||||
|
'schedule-fill-rate': scheduleFillRate,
|
||||||
|
'trips-per-route': tripsPerRoute,
|
||||||
|
'invoiced-vs-collected': invoicedVsCollected,
|
||||||
|
'aging-receivables': agingReceivables,
|
||||||
|
'revenue-by-payment-method': revenueByPaymentMethod,
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
|
import { BookingView } from '../../common/booking-guards';
|
||||||
|
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||||
|
import { ReportQueryDto } from './dto/report-query.dto';
|
||||||
|
import { ReportResultDto } from './dto/report-result.dto';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@ApiTags('Reports')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('reports')
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(
|
||||||
|
private readonly reportsService: ReportsService,
|
||||||
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get(':key')
|
||||||
|
@BookingView()
|
||||||
|
@ApiOperation({ summary: 'Run a canned report by key with optional filters' })
|
||||||
|
@ApiOkResponse({ type: ReportResultDto })
|
||||||
|
async run(
|
||||||
|
@Param('key') key: string,
|
||||||
|
@Query() query: ReportQueryDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
): Promise<ReportResultDto> {
|
||||||
|
const allowed = await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||||
|
return this.reportsService.run(key, query, allowed);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
13
apps/edr-freight-api/src/modules/reports/reports.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
|
import { ReportsController } from './reports.controller';
|
||||||
|
import { ReportsRepository } from './reports.repository';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [UserTradeAccessModule],
|
||||||
|
controllers: [ReportsController],
|
||||||
|
providers: [ReportsService, ReportsRepository],
|
||||||
|
})
|
||||||
|
export class ReportsModule {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsRepository {
|
||||||
|
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
run(key: keyof typeof REPORT_QUERIES, filters: ReportFilters): Promise<ReportResult> {
|
||||||
|
return REPORT_QUERIES[key](this.dataSource, filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
46
apps/edr-freight-api/src/modules/reports/reports.service.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||||
|
import { ReportQueryDto } from './dto/report-query.dto';
|
||||||
|
import { REPORT_QUERIES, ReportFilters, ReportResult } from './report-queries';
|
||||||
|
import { ReportsRepository } from './reports.repository';
|
||||||
|
import type { Freight } from '@edr/types';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const list = (csv?: string): string[] | null => {
|
||||||
|
const items = csv?.split(',').map((s) => s.trim()).filter(Boolean) ?? [];
|
||||||
|
return items.length ? items : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(private readonly repository: ReportsRepository) {}
|
||||||
|
|
||||||
|
run(
|
||||||
|
key: string,
|
||||||
|
dto: ReportQueryDto,
|
||||||
|
allowedDirections: Freight.ScheduleTradeDirection[] | null,
|
||||||
|
): Promise<ReportResult> {
|
||||||
|
if (!(key in REPORT_QUERIES)) {
|
||||||
|
throw new NotFoundException(`Unknown report: ${key}`);
|
||||||
|
}
|
||||||
|
// No default range: absent dates mean all time, so exports cover everything.
|
||||||
|
const to = dto.dateTo ? new Date(dto.dateTo) : null;
|
||||||
|
const from = dto.dateFrom ? new Date(dto.dateFrom) : null;
|
||||||
|
const filters: ReportFilters = {
|
||||||
|
dateFrom: from ? from.toISOString() : null,
|
||||||
|
// dateTo is inclusive in the API; queries treat the bound as exclusive.
|
||||||
|
dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null,
|
||||||
|
granularity: dto.granularity ?? 'day',
|
||||||
|
companyIds: list(dto.companyIds),
|
||||||
|
routeIds: list(dto.routeIds),
|
||||||
|
yardIds: list(dto.yardIds),
|
||||||
|
cargoTypeIds: list(dto.cargoTypeIds),
|
||||||
|
statuses: list(dto.statuses),
|
||||||
|
directions: scopedDirections(allowedDirections, dto.direction),
|
||||||
|
freightType: dto.freightType ?? null,
|
||||||
|
};
|
||||||
|
return this.repository.run(key, filters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
|
@Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true })
|
||||||
rulePaymentWindowMinutes?: number | null;
|
rulePaymentWindowMinutes?: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff configured this schedule's booking window by hand (at creation or via
|
||||||
|
* the per-schedule override) instead of inheriting the live global rules.
|
||||||
|
* `restampPendingWindows` skips these, so a later global-rules edit cannot
|
||||||
|
* silently overwrite the hand-picked settings.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'window_rule_custom', type: 'boolean', default: false })
|
||||||
|
windowRuleCustom!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
|
@Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true })
|
||||||
ruleImportWindowLeadDays?: number | null;
|
ruleImportWindowLeadDays?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ export interface ExportTrainOption {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||||
|
export interface AllocationCandidate {
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
scheduledDepartureDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
interface RouteDayGroup {
|
interface RouteDayGroup {
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
@@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trains a paid-but-unallocated booking can board right now: OPEN window,
|
||||||
|
* future departure, route covers the booking's leg, and remaining corridor
|
||||||
|
* capacity fits it. Split by the booking's own scheduled day so the UI can
|
||||||
|
* offer one-click same-day allocation vs an explicit "another date" choice.
|
||||||
|
*/
|
||||||
|
async allocationCandidates(bookingId: string): Promise<{
|
||||||
|
sameDay: AllocationCandidate[];
|
||||||
|
otherDays: AllocationCandidate[];
|
||||||
|
}> {
|
||||||
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
relations: {
|
||||||
|
bookingContainers: { containerType: true },
|
||||||
|
// wagonTypes drives the break-bulk items-per-wagon fit — size the
|
||||||
|
// booking exactly as the intercity accept check does.
|
||||||
|
cargoType: { wagonTypes: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{ status: TrainScheduleStatusEnum.Draft },
|
||||||
|
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const today = eatDay(new Date());
|
||||||
|
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||||||
|
const sameDay: AllocationCandidate[] = [];
|
||||||
|
const otherDays: AllocationCandidate[] = [];
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
|
||||||
|
if (s.bookingWindowStatus !== "OPEN") continue;
|
||||||
|
if (s.id === booking.trainScheduleId) continue;
|
||||||
|
const stops = await this.stopsForSchedule(s);
|
||||||
|
const fromIdx = stops.indexOf(booking.originYardId);
|
||||||
|
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||||
|
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
|
||||||
|
// ponytail: full capacity build per candidate is heavy; the set is small
|
||||||
|
// (future OPEN trains on the booking's route) — precompute if it grows.
|
||||||
|
const cap = await this.intercityCapacity(s.id);
|
||||||
|
if (!cap) continue;
|
||||||
|
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
|
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
|
||||||
|
const candidate: AllocationCandidate = {
|
||||||
|
id: s.id,
|
||||||
|
reference: s.reference ?? s.trainNumber ?? null,
|
||||||
|
direction: s.direction ?? null,
|
||||||
|
scheduledDepartureDate: s.scheduledDepartureDate,
|
||||||
|
};
|
||||||
|
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
|
||||||
|
}
|
||||||
|
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
|
||||||
|
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
|
||||||
|
sameDay.sort(byDate);
|
||||||
|
otherDays.sort(byDate);
|
||||||
|
return { sameDay, otherDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place a PAID booking that lost (or never got) its train: re-point via
|
||||||
|
* moveToSchedule (window/route validation + day sync), then allocate it
|
||||||
|
* immediately — payment already landed, so no new pay window opens. The
|
||||||
|
* customer gets an in-app notice when the new train departs on a different
|
||||||
|
* day than their original choice.
|
||||||
|
*/
|
||||||
|
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
|
||||||
|
const before = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: bookingId } });
|
||||||
|
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Booking is not paid — use the regular scheduling flow",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
|
||||||
|
await this.moveToSchedule(bookingId, scheduleId);
|
||||||
|
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||||
|
});
|
||||||
|
if (!fresh) return;
|
||||||
|
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
|
||||||
|
await this.allocate(scheduleId, fresh, "paid");
|
||||||
|
}
|
||||||
|
const schedule = await this.dataSource
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.findOne({ where: { id: scheduleId } });
|
||||||
|
if (
|
||||||
|
previousDay &&
|
||||||
|
schedule?.scheduledDepartureDate &&
|
||||||
|
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||||||
|
) {
|
||||||
|
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One reminder per hold, shortly before its pay deadline (the window tick
|
* One reminder per hold, shortly before its pay deadline (the window tick
|
||||||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||||||
@@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Paid but detached from any train (staff removed it from an allocation,
|
||||||
|
// or a sweep caught it unpinned): money was taken, so it must board — it
|
||||||
|
// stays paid-unallocated for staff to place via the allocate action.
|
||||||
|
if (paid) {
|
||||||
|
this.logger.log(
|
||||||
|
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
|
||||||
|
`but no train attached; left paid-unallocated for manual placement`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Reconcile-before-expire (only when a pay window was actually open):
|
// Reconcile-before-expire (only when a pay window was actually open):
|
||||||
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||||||
// landed. A late capture found there is registered as SUCCEEDED and
|
// landed. A late capture found there is registered as SUCCEEDED and
|
||||||
@@ -3854,12 +3970,26 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// booking can use — don't kill it for nothing.
|
// booking can use — don't kill it for nothing.
|
||||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||||
if (!overlaps) continue;
|
if (!overlaps) continue;
|
||||||
|
const victimPaid =
|
||||||
|
victim.paymentStatus === "PAID" || victim.status === "PAID";
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
victim.id,
|
victim.id,
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
if (victimPaid) {
|
||||||
|
// Paid bookings are never expired — money was taken, so it boards.
|
||||||
|
// Detach it so it surfaces in the paid-unallocated queue for staff
|
||||||
|
// to re-place; the settled invoice stays untouched.
|
||||||
|
await manager.getRepository(Booking).update(victim.id, {
|
||||||
|
trainScheduleId: null,
|
||||||
|
schedulingStatus: "ELIGIBLE",
|
||||||
|
paymentDeadline: null,
|
||||||
|
selectedForBatchAt: null,
|
||||||
|
} as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await manager.getRepository(Booking).update(victim.id, {
|
await manager.getRepository(Booking).update(victim.id, {
|
||||||
status: "EXPIRED",
|
status: "EXPIRED",
|
||||||
schedulingStatus: "ELIGIBLE",
|
schedulingStatus: "ELIGIBLE",
|
||||||
|
|||||||
@@ -275,6 +275,19 @@ export class BookingNotifierService {
|
|||||||
this.inApp(b, 'Booking rescheduled', msg);
|
this.inApp(b, 'Booking rescheduled', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff placed a paid booking onto a train departing on a DIFFERENT day than
|
||||||
|
* the customer's original choice. In-app only — staff drove the change and
|
||||||
|
* the allocation itself already notifies through the secured path.
|
||||||
|
*/
|
||||||
|
allocatedOtherDay(b: Booking, newDeparture: Date): void {
|
||||||
|
const when = newDeparture.toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE });
|
||||||
|
const msg =
|
||||||
|
`Booking ${b.reference ?? b.id} has been allocated to a train on a different date. ` +
|
||||||
|
`New departure date: ${when}.`;
|
||||||
|
this.inApp(b, 'Booking allocated to another date', msg);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Booking was removed from its train during a staff reschedule (not a government
|
* Booking was removed from its train during a staff reschedule (not a government
|
||||||
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
* pre-empt). It returns to eligible — the customer must rebook or reschedule.
|
||||||
|
|||||||
@@ -9,9 +9,107 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Max,
|
||||||
Min,
|
Min,
|
||||||
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-schedule booking-window rule chosen AT CREATION, instead of inheriting the
|
||||||
|
* live global rules. Mirrors {@link UpdateScheduleWindowRuleDto}, plus the
|
||||||
|
* booking-close offset (which the post-creation override deliberately never
|
||||||
|
* touches). Every field is optional — an omitted field falls back to the global
|
||||||
|
* value, so staff can override just the one knob they care about.
|
||||||
|
*/
|
||||||
|
export class CreateScheduleWindowRuleDto {
|
||||||
|
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(23)
|
||||||
|
windowOpenHour?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 17,
|
||||||
|
description:
|
||||||
|
'Local EAT hour the booking desk shuts each day. Equal to windowOpenHour = 24-hour desk',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Max(23)
|
||||||
|
windowCloseHour?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.0166)
|
||||||
|
@Max(12)
|
||||||
|
windowDurationHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
docReviewMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
paymentWindowMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 3,
|
||||||
|
description: 'Days before departure the IMPORT/DOMESTIC booking window starts',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
importWindowLeadDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 24,
|
||||||
|
description: 'Hours before departure the single FCFS EXPORT window opens',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
exportBookingLeadHours?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 180,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Minutes before departure the booking window closes; 0/null = close at departure. ' +
|
||||||
|
'Only the offset matching the schedule direction is used (import offset for ' +
|
||||||
|
'IMPORT/DOMESTIC, export offset for EXPORT).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
importCloseOffsetMinutes?: number | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 1440,
|
||||||
|
nullable: true,
|
||||||
|
description: 'Minutes before departure an EXPORT booking window closes; 0/null = at departure',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
exportCloseOffsetMinutes?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class CreateContainerTrainScheduleDto {
|
export class CreateContainerTrainScheduleDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
reverseWagonOrder?: boolean;
|
reverseWagonOrder?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: CreateScheduleWindowRuleDto,
|
||||||
|
description:
|
||||||
|
'Configure the booking window for THIS schedule instead of inheriting the live ' +
|
||||||
|
'global rules. Omit to use the global rules (the default). The values sent are ' +
|
||||||
|
'frozen onto the schedule as its rule snapshot, exactly as a post-creation ' +
|
||||||
|
'override would. Rejected for an IMPORT/DOMESTIC train that joins an existing ' +
|
||||||
|
'route+day group — those siblings share one window timeline, so edit the group ' +
|
||||||
|
"window instead of giving one member its own.",
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => CreateScheduleWindowRuleDto)
|
||||||
|
windowRule?: CreateScheduleWindowRuleDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -856,6 +856,32 @@ export class TrainSchedulingController {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("bookings/:bookingId/allocation-candidates")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Trains a paid-unallocated booking fits, split same-day vs other days",
|
||||||
|
})
|
||||||
|
getAllocationCandidates(
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
) {
|
||||||
|
return this.bookingBatchService.allocationCandidates(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("bookings/:bookingId/allocate")
|
||||||
|
@TrainSchedulingUpdate()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Staff: place a paid booking onto a fitting train (notifies customer on date change)",
|
||||||
|
})
|
||||||
|
async allocatePaidBooking(
|
||||||
|
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||||
|
@Body("trainScheduleId", ParseUUIDPipe) trainScheduleId: string,
|
||||||
|
) {
|
||||||
|
await this.bookingBatchService.allocatePaid(bookingId, trainScheduleId);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
@Get("schedules/:id/checkpoints")
|
@Get("schedules/:id/checkpoints")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('restampPendingWindows (hand-configured windows are exempt)', () => {
|
||||||
|
const future = new Date(Date.now() + 30 * 24 * 3600_000);
|
||||||
|
const update = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
update.mockClear();
|
||||||
|
// Global rules read + the TrainSchedule repo the restamp writes through.
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
const name = (entity as { name?: string })?.name;
|
||||||
|
if (name === 'TrainSchedulingGlobalRules') {
|
||||||
|
return { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
}
|
||||||
|
return { update };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-stamps a schedule that follows the global rules', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'sched-global',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
scheduledDepartureDate: future,
|
||||||
|
windowRuleCustom: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expect(service.restampPendingWindows()).resolves.toBe(1);
|
||||||
|
expect(update).toHaveBeenCalledWith('sched-global', expect.anything());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a hand-configured schedule alone', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'sched-custom',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
scheduledDepartureDate: future,
|
||||||
|
windowRuleCustom: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
// Staff picked these times deliberately — a global-rules edit must not
|
||||||
|
// overwrite them, or the per-schedule configuration would be pointless.
|
||||||
|
await expect(service.restampPendingWindows()).resolves.toBe(0);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getUnassignedBookings', () => {
|
describe('getUnassignedBookings', () => {
|
||||||
const scheduleId = 'sched-unassigned-1';
|
const scheduleId = 'sched-unassigned-1';
|
||||||
const trainSetId = 'train-set-unassigned';
|
const trainSetId = 'train-set-unassigned';
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ import {
|
|||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||||
|
|
||||||
|
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
|
||||||
|
function pickDefined<T extends object>(source: T): Partial<T> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(source).filter(([, v]) => v !== undefined),
|
||||||
|
) as Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The booking-window rule fields frozen onto a train schedule at creation (and
|
* The booking-window rule fields frozen onto a train schedule at creation (and
|
||||||
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
|
||||||
@@ -906,6 +913,9 @@ export class TrainSchedulingService {
|
|||||||
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||||
...ruleFields,
|
...ruleFields,
|
||||||
rulePaymentWindowMinutes,
|
rulePaymentWindowMinutes,
|
||||||
|
// Deliberately overridden — exempt from the global re-stamp, which would
|
||||||
|
// otherwise revert this schedule the next time global rules are saved.
|
||||||
|
windowRuleCustom: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1197,6 +1207,9 @@ export class TrainSchedulingService {
|
|||||||
let restamped = 0;
|
let restamped = 0;
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
|
||||||
|
// Hand-configured windows are not "pending the global rule" — staff picked
|
||||||
|
// these times deliberately, so a global-rules edit must leave them alone.
|
||||||
|
if (s.windowRuleCustom) continue;
|
||||||
const times =
|
const times =
|
||||||
s.direction === 'EXPORT'
|
s.direction === 'EXPORT'
|
||||||
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
|
||||||
@@ -1460,29 +1473,8 @@ export class TrainSchedulingService {
|
|||||||
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
// it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
|
||||||
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
// (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
|
||||||
// 24h before departure (FCFS). No schedule is ever always-open now.
|
// 24h before departure (FCFS). No schedule is ever always-open now.
|
||||||
const windowCfg = await this.getWindowConfig();
|
const globalCfg = await this.getWindowConfig();
|
||||||
|
|
||||||
// Staff cannot schedule inside the lead window — there must be room for a
|
|
||||||
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
|
||||||
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
|
||||||
// lead is in hours (24h = 1 day ahead).
|
|
||||||
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
|
||||||
if (departure.getTime() < earliest.getTime()) {
|
|
||||||
const detail =
|
|
||||||
direction === 'EXPORT'
|
|
||||||
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
|
||||||
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
|
||||||
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
|
||||||
`(earliest ${earliest.toISOString()})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Freeze the rule this schedule is born with. A later global-rules edit
|
|
||||||
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
|
||||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
|
||||||
// windows from it rather than the live config.
|
|
||||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
|
||||||
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||||||
// on this origin + destination + EAT departure day, this new train JOINS
|
// on this origin + destination + EAT departure day, this new train JOINS
|
||||||
// its group and adopts the group's shared window timeline (open/close +
|
// its group and adopts the group's shared window timeline (open/close +
|
||||||
@@ -1506,6 +1498,77 @@ export class TrainSchedulingService {
|
|||||||
route.destinationYardId,
|
route.destinationYardId,
|
||||||
departure,
|
departure,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Per-schedule window rule chosen at creation. Refused for a train that
|
||||||
|
// JOINS an existing route+day group: the group shares ONE window timeline,
|
||||||
|
// so a joining train adopts the anchor's times verbatim and its own
|
||||||
|
// settings would be silently discarded. Staff edit the group's window
|
||||||
|
// instead (Booking window settings, which fans out to every sibling).
|
||||||
|
if (dto.windowRule && groupAnchor) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This train joins an existing booking group (same route and departure day), ' +
|
||||||
|
'which shares one booking window across all its trains. Create it with the ' +
|
||||||
|
'group settings, then use Booking window settings to change the window for ' +
|
||||||
|
'the whole group.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule this schedule is born under: staff overrides on top of the live
|
||||||
|
// global config, so an omitted field still follows the global value.
|
||||||
|
const windowCfg: BookingWindowConfig = dto.windowRule
|
||||||
|
? {
|
||||||
|
...globalCfg,
|
||||||
|
...pickDefined({
|
||||||
|
windowOpenHour: dto.windowRule.windowOpenHour,
|
||||||
|
windowCloseHour: dto.windowRule.windowCloseHour,
|
||||||
|
windowDurationHours: dto.windowRule.windowDurationHours,
|
||||||
|
docReviewMinutes: dto.windowRule.docReviewMinutes,
|
||||||
|
importWindowLeadDays: dto.windowRule.importWindowLeadDays,
|
||||||
|
exportBookingLeadHours: dto.windowRule.exportBookingLeadHours,
|
||||||
|
}),
|
||||||
|
// One pay-window override drives both directions (only the one
|
||||||
|
// matching this schedule's direction is ever read).
|
||||||
|
...(dto.windowRule.paymentWindowMinutes !== undefined
|
||||||
|
? {
|
||||||
|
paymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||||
|
exportPaymentWindowMinutes: dto.windowRule.paymentWindowMinutes,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// Close offsets are nullable-by-intent: null/0 means "close at
|
||||||
|
// departure", which must override a non-null global, so these are
|
||||||
|
// merged on presence rather than on definedness.
|
||||||
|
...(dto.windowRule.importCloseOffsetMinutes !== undefined
|
||||||
|
? { importCloseOffsetMinutes: dto.windowRule.importCloseOffsetMinutes ?? null }
|
||||||
|
: {}),
|
||||||
|
...(dto.windowRule.exportCloseOffsetMinutes !== undefined
|
||||||
|
? { exportCloseOffsetMinutes: dto.windowRule.exportCloseOffsetMinutes ?? null }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: globalCfg;
|
||||||
|
|
||||||
|
// Staff cannot schedule inside the lead window — there must be room for a
|
||||||
|
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
|
||||||
|
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
|
||||||
|
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
|
||||||
|
// lead, so a custom lead is honoured rather than rejected by the global one.
|
||||||
|
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
|
||||||
|
if (departure.getTime() < earliest.getTime()) {
|
||||||
|
const detail =
|
||||||
|
direction === 'EXPORT'
|
||||||
|
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
|
||||||
|
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
|
||||||
|
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
|
||||||
|
`(earliest ${earliest.toISOString()})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freeze the rule this schedule is born with. A later global-rules edit
|
||||||
|
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
|
||||||
|
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||||
|
// windows from it rather than the live config.
|
||||||
|
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||||
const computedTimes =
|
const computedTimes =
|
||||||
direction === 'EXPORT'
|
direction === 'EXPORT'
|
||||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||||
@@ -1514,12 +1577,30 @@ export class TrainSchedulingService {
|
|||||||
...ruleSnapshot,
|
...ruleSnapshot,
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||||
};
|
};
|
||||||
|
if (
|
||||||
|
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'These booking-window settings leave no window before departure — with the ' +
|
||||||
|
'desk hours and close offset applied, the window would only open once the ' +
|
||||||
|
'train has left.',
|
||||||
|
);
|
||||||
|
}
|
||||||
const windowFields = {
|
const windowFields = {
|
||||||
bookingWindowStatus: 'CLOSED',
|
bookingWindowStatus: 'CLOSED',
|
||||||
windowPhase: 'PRE_WINDOW',
|
windowPhase: 'PRE_WINDOW',
|
||||||
...(groupAnchor
|
...(groupAnchor
|
||||||
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||||
: computedTimes),
|
: computedTimes),
|
||||||
|
// `windowRuleSnapshot` never stamps the pay window (NULL = follow the
|
||||||
|
// live global value for the direction), so an explicit staff override is
|
||||||
|
// persisted here — the same field the post-creation override writes.
|
||||||
|
...(dto.windowRule?.paymentWindowMinutes !== undefined
|
||||||
|
? { rulePaymentWindowMinutes: dto.windowRule.paymentWindowMinutes }
|
||||||
|
: {}),
|
||||||
|
// Hand-configured windows opt OUT of the global re-stamp, or the next
|
||||||
|
// global-rules edit would overwrite exactly what staff chose here.
|
||||||
|
windowRuleCustom: dto.windowRule != null,
|
||||||
};
|
};
|
||||||
// A built train's own consist is the schedule's capacity: full when all
|
// A built train's own consist is the schedule's capacity: full when all
|
||||||
// its wagons are allocated. Trains built without wagons yet fall back to
|
// its wagons are allocated. Trains built without wagons yet fall back to
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
|
BarChart3,
|
||||||
Container,
|
Container,
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
|||||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
|
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||||
|
import ReportPage from "./pages/reports/ReportPage";
|
||||||
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage";
|
||||||
import PaymentsPage from "./pages/payments/PaymentsPage";
|
import PaymentsPage from "./pages/payments/PaymentsPage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
@@ -154,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <LayoutDashboard />,
|
icon: <LayoutDashboard />,
|
||||||
permission: FREIGHT_PERMS.overview.view,
|
permission: FREIGHT_PERMS.overview.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Reports",
|
||||||
|
href: "/dashboard/reports",
|
||||||
|
icon: <BarChart3 />,
|
||||||
|
permission: FREIGHT_PERMS.bookings.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
href: "/dashboard/customers",
|
href: "/dashboard/customers",
|
||||||
@@ -823,6 +832,8 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
<Route path="reports" element={<ReportsHubPage />} />
|
||||||
|
<Route path="reports/:reportKey" element={<ReportPage />} />
|
||||||
{/* Dev/testing page for the mock AI booking assistant. */}
|
{/* Dev/testing page for the mock AI booking assistant. */}
|
||||||
<Route
|
<Route
|
||||||
path="ai-booking-mock-test"
|
path="ai-booking-mock-test"
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { ArrowRight, FileText, Train, Users } from "lucide-react";
|
|
||||||
import { Card, Group, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core";
|
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
|
||||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
{
|
|
||||||
title: "Booking requests",
|
|
||||||
description: "Review and action incoming freight bookings",
|
|
||||||
href: "/dashboard/booking-requests",
|
|
||||||
icon: FileText,
|
|
||||||
permission: [FREIGHT_PERMS.bookings.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Train scheduling v2",
|
|
||||||
description: "Full allocation workflow — assign, pin wagons, finalize",
|
|
||||||
href: "/dashboard/operations/train-scheduling-v2",
|
|
||||||
icon: Train,
|
|
||||||
permission: [FREIGHT_PERMS.trainScheduling.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Trains",
|
|
||||||
description: "Manage train master data and fleet status",
|
|
||||||
href: "/dashboard/trains",
|
|
||||||
icon: Train,
|
|
||||||
permission: [FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.trains.view],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "User management",
|
|
||||||
description: "Employees, roles, and permissions",
|
|
||||||
href: "/user-management",
|
|
||||||
icon: Users,
|
|
||||||
permission: [
|
|
||||||
FREIGHT_PERMS.admin,
|
|
||||||
FREIGHT_PERMS.staff.roles.view,
|
|
||||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
|
||||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function OverviewQuickLinks() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const visible = links.filter((link) =>
|
|
||||||
link.permission.some((key) => hasPermission(user, key)),
|
|
||||||
);
|
|
||||||
if (!visible.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md" h="100%">
|
|
||||||
<Text fw={600}>Quick links</Text>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
|
||||||
{visible.map((link) => {
|
|
||||||
const Icon = link.icon;
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={link.href}
|
|
||||||
p="md"
|
|
||||||
radius="lg"
|
|
||||||
withBorder
|
|
||||||
style={{ cursor: "pointer" }}
|
|
||||||
onClick={() => navigate(link.href)}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
|
||||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
|
||||||
<ThemeIcon variant="light" color="edr-green" size="lg" radius="md">
|
|
||||||
<Icon size={18} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Text fw={600} size="sm">
|
|
||||||
{link.title}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{link.description}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
<ArrowRight size={16} color="var(--mantine-color-gray-5)" />
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Paper, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
export interface StackedBarSeries {
|
||||||
|
/** Key into each data row holding this series' value. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewStackedBarChartProps<T extends object> {
|
||||||
|
title: string;
|
||||||
|
data: T[];
|
||||||
|
/** Fixed order + fixed color per series — colors follow the entity, not the rank. */
|
||||||
|
series: StackedBarSeries[];
|
||||||
|
xKey?: string;
|
||||||
|
emptyMessage?: string;
|
||||||
|
formatXLabel?: (value: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewStackedBarChart<T extends object>({
|
||||||
|
title,
|
||||||
|
data,
|
||||||
|
series,
|
||||||
|
xKey = "date",
|
||||||
|
emptyMessage = "No data available",
|
||||||
|
formatXLabel,
|
||||||
|
}: OverviewStackedBarChartProps<T>) {
|
||||||
|
const hasData = data.some((row) =>
|
||||||
|
series.some((s) => Number((row as Record<string, unknown>)[s.key]) > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper p="md" radius="lg" withBorder h="100%" style={{ minHeight: 260 }}>
|
||||||
|
<Stack gap="sm" h="100%">
|
||||||
|
<Text fw={600}>{title}</Text>
|
||||||
|
{!hasData ? (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={230}>
|
||||||
|
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xKey}
|
||||||
|
tickFormatter={formatXLabel}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
stroke="#94a3b8"
|
||||||
|
/>
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
|
<Tooltip labelFormatter={formatXLabel && ((v) => formatXLabel(String(v)))} />
|
||||||
|
<Legend iconType="circle" iconSize={9} />
|
||||||
|
{series.map((s, index) => (
|
||||||
|
<Bar
|
||||||
|
key={s.key}
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
stackId="stack"
|
||||||
|
fill={s.color}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={1}
|
||||||
|
barSize={18}
|
||||||
|
radius={index === series.length - 1 ? [4, 4, 0, 0] : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
|||||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||||
|
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
||||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||||
|
|
||||||
@@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|||||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||||
const operations = useOverviewOperationsTab(tab === "operations");
|
// Fleet reuses the operations dataset — same query key, so switching between
|
||||||
|
// the two tabs costs one fetch.
|
||||||
|
const operations = useOverviewOperationsTab(
|
||||||
|
range,
|
||||||
|
tab === "operations" || tab === "fleet",
|
||||||
|
);
|
||||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|||||||
? contracts
|
? contracts
|
||||||
: tab === "billing"
|
: tab === "billing"
|
||||||
? billing
|
? billing
|
||||||
: tab === "operations"
|
: tab === "operations" || tab === "fleet"
|
||||||
? operations
|
? operations
|
||||||
: tab === "customers"
|
: tab === "customers"
|
||||||
? customers
|
? customers
|
||||||
@@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
|||||||
{tab === "operations" && operations.data && (
|
{tab === "operations" && operations.data && (
|
||||||
<OverviewOperationsTabPanel data={operations.data} />
|
<OverviewOperationsTabPanel data={operations.data} />
|
||||||
)}
|
)}
|
||||||
|
{tab === "fleet" && operations.data && (
|
||||||
|
<OverviewFleetTabPanel data={operations.data} />
|
||||||
|
)}
|
||||||
{tab === "customers" && customers.data && (
|
{tab === "customers" && customers.data && (
|
||||||
<OverviewCustomersTabPanel data={customers.data} />
|
<OverviewCustomersTabPanel data={customers.data} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<OverviewKpiStrip
|
<OverviewKpiStrip
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
label: "Total bookings",
|
||||||
|
value: data.kpis.total,
|
||||||
|
icon: FileText,
|
||||||
|
accent: "gold",
|
||||||
|
hint: "All time",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Active bookings",
|
label: "Active bookings",
|
||||||
value: data.kpis.totalActive,
|
value: data.kpis.totalActive,
|
||||||
@@ -71,7 +78,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid gap="md">
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="By status"
|
title="By status"
|
||||||
data={data.bookingsByStatus.map((item) => ({
|
data={data.bookingsByStatus.map((item) => ({
|
||||||
@@ -81,10 +88,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
|||||||
emptyMessage="No bookings yet"
|
emptyMessage="No bookings yet"
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
<OverviewDonutChart
|
<OverviewHorizontalBarChart
|
||||||
title="By freight type"
|
title="By freight type"
|
||||||
data={data.bookingsByFreightType.map((item) => ({
|
data={data.bookingsByFreightType.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
valueLabel="Bookings"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="By payment currency"
|
||||||
|
data={data.bookingsByCurrency.map((item) => ({
|
||||||
name: item.label,
|
name: item.label,
|
||||||
value: item.count,
|
value: item.count,
|
||||||
}))}
|
}))}
|
||||||
@@ -92,14 +109,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<OverviewHorizontalBarChart
|
|
||||||
title="By payment currency"
|
|
||||||
data={data.bookingsByCurrency.map((item) => ({
|
|
||||||
label: item.label,
|
|
||||||
value: item.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OverviewRecentBookingsTable bookings={data.recentBookings} />
|
<OverviewRecentBookingsTable bookings={data.recentBookings} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<OverviewKpiStrip
|
<OverviewKpiStrip
|
||||||
items={[
|
items={[
|
||||||
|
{
|
||||||
|
label: "Total contracts",
|
||||||
|
value: data.kpis.total,
|
||||||
|
icon: FileSignature,
|
||||||
|
accent: "gold",
|
||||||
|
hint: "All time",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Active contracts",
|
label: "Active contracts",
|
||||||
value: data.kpis.totalActive,
|
value: data.kpis.totalActive,
|
||||||
@@ -94,7 +101,7 @@ export function OverviewContractsTabPanel({
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid gap="md">
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="By status"
|
title="By status"
|
||||||
data={data.contractsByStatus.map((item) => ({
|
data={data.contractsByStatus.map((item) => ({
|
||||||
@@ -104,7 +111,7 @@ export function OverviewContractsTabPanel({
|
|||||||
emptyMessage="No contracts yet"
|
emptyMessage="No contracts yet"
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="By kind"
|
title="By kind"
|
||||||
data={data.contractsByKind.map((item) => ({
|
data={data.contractsByKind.map((item) => ({
|
||||||
@@ -113,16 +120,17 @@ export function OverviewContractsTabPanel({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="By freight type"
|
||||||
|
data={data.contractsByFreightType.map((item) => ({
|
||||||
|
name: item.label === "CONTAINER" ? "Container" : "Bulk",
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<OverviewHorizontalBarChart
|
|
||||||
title="By freight type"
|
|
||||||
data={data.contractsByFreightType.map((item) => ({
|
|
||||||
label: item.label === "CONTAINER" ? "Container" : "Bulk",
|
|
||||||
value: item.count,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OverviewRecentContractsTable contracts={data.recentContracts} />
|
<OverviewRecentContractsTable contracts={data.recentContracts} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Train, Truck, Wrench } from "lucide-react";
|
||||||
|
import { Grid, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import type { IOverviewOperationsTab, IOverviewStatusCount } from "@/types/overview";
|
||||||
|
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||||
|
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||||
|
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||||
|
|
||||||
|
function formatStatusLabel(status: string) {
|
||||||
|
return status
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounts(items: IOverviewStatusCount[]) {
|
||||||
|
return items.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countByStatus(items: IOverviewStatusCount[], status: string) {
|
||||||
|
return items.find((item) => item.status === status)?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDonutData(items: IOverviewStatusCount[]) {
|
||||||
|
return items.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewFleetTabPanelProps {
|
||||||
|
data: IOverviewOperationsTab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewFleetTabPanel({ data }: OverviewFleetTabPanelProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<OverviewKpiStrip
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Total trains",
|
||||||
|
value: sumCounts(data.trainStatusBreakdown),
|
||||||
|
icon: Train,
|
||||||
|
accent: "gold",
|
||||||
|
hint: "All time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active trains",
|
||||||
|
value: data.kpis.trainsActive,
|
||||||
|
icon: Train,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Total wagons",
|
||||||
|
value: sumCounts(data.wagonStatusBreakdown),
|
||||||
|
icon: Truck,
|
||||||
|
accent: "gold",
|
||||||
|
hint: "All time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wagons available",
|
||||||
|
value: data.kpis.wagonsAvailable,
|
||||||
|
icon: Truck,
|
||||||
|
accent: "emerald",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "In maintenance",
|
||||||
|
value: countByStatus(data.wagonStatusBreakdown, "MAINTENANCE"),
|
||||||
|
icon: Wrench,
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="Wagon fleet by type"
|
||||||
|
data={data.wagonsByType.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
valueLabel="Wagons"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="Wagons by yard"
|
||||||
|
data={data.wagonsByYard.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.count,
|
||||||
|
}))}
|
||||||
|
valueLabel="Wagons"
|
||||||
|
emptyMessage="No wagons assigned to yards"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Train status"
|
||||||
|
data={toDonutData(data.trainStatusBreakdown)}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewDonutChart
|
||||||
|
title="Wagon status"
|
||||||
|
data={toDonutData(data.wagonStatusBreakdown)}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Box, Container as ContainerIcon, Train, Truck } from "lucide-react";
|
import {
|
||||||
|
Box,
|
||||||
|
CalendarClock,
|
||||||
|
Container as ContainerIcon,
|
||||||
|
Send,
|
||||||
|
Train,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
import { Grid, Stack } from "@mantine/core";
|
import { Grid, Stack } from "@mantine/core";
|
||||||
|
|
||||||
import type { IOverviewOperationsTab } from "@/types/overview";
|
import type { IOverviewOperationsTab } from "@/types/overview";
|
||||||
import { OverviewDonutChart } from "../OverviewDonutChart";
|
import { OverviewDonutChart } from "../OverviewDonutChart";
|
||||||
|
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
|
||||||
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
import { OverviewKpiStrip } from "../OverviewKpiStrip";
|
||||||
|
import { OverviewStackedBarChart } from "../OverviewStackedBarChart";
|
||||||
|
|
||||||
interface OverviewOperationsTabPanelProps {
|
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
||||||
data: IOverviewOperationsTab;
|
const DIRECTION_SERIES = [
|
||||||
}
|
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
||||||
|
{ key: "importCount", label: "Import", color: "#0369a1" },
|
||||||
|
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
||||||
|
];
|
||||||
|
|
||||||
function formatStatusLabel(status: string) {
|
function formatStatusLabel(status: string) {
|
||||||
return status
|
return status
|
||||||
@@ -16,6 +28,22 @@ function formatStatusLabel(status: string) {
|
|||||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(date: string) {
|
||||||
|
const parsed = new Date(`${date}T00:00:00`);
|
||||||
|
return parsed.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDonutData(items: { status: string; count: number }[]) {
|
||||||
|
return items.map((item) => ({
|
||||||
|
name: formatStatusLabel(item.status),
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewOperationsTabPanelProps {
|
||||||
|
data: IOverviewOperationsTab;
|
||||||
|
}
|
||||||
|
|
||||||
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
|
export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelProps) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
@@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
|||||||
icon: Train,
|
icon: Train,
|
||||||
accent: "emerald",
|
accent: "emerald",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Upcoming departures",
|
||||||
|
value: data.kpis.schedulesUpcoming,
|
||||||
|
icon: CalendarClock,
|
||||||
|
accent: "sky",
|
||||||
|
hint: "Scheduled, not yet departed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dispatched today",
|
||||||
|
value: data.kpis.dispatchedToday,
|
||||||
|
icon: Send,
|
||||||
|
accent: "amber",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Wagons available",
|
label: "Wagons available",
|
||||||
value: data.kpis.wagonsAvailable,
|
value: data.kpis.wagonsAvailable,
|
||||||
@@ -46,40 +87,58 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid gap="md">
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
<OverviewStackedBarChart
|
||||||
|
title="Train departures by direction"
|
||||||
|
data={data.departureTrend}
|
||||||
|
series={DIRECTION_SERIES}
|
||||||
|
formatXLabel={formatDateLabel}
|
||||||
|
emptyMessage="No scheduled departures in this period"
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="Train status"
|
title="Schedule status"
|
||||||
data={data.trainStatusBreakdown.map((item) => ({
|
data={toDonutData(data.scheduleStatusBreakdown)}
|
||||||
name: formatStatusLabel(item.status),
|
emptyMessage="No train schedules yet"
|
||||||
value: item.count,
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<OverviewHorizontalBarChart
|
||||||
|
title="Cargo tonnage by type"
|
||||||
|
data={data.cargoTonnageByType.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
value: item.tons,
|
||||||
}))}
|
}))}
|
||||||
|
valueLabel="Tons"
|
||||||
|
emptyMessage="No cargo recorded"
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="Wagon status"
|
title="Containers by size"
|
||||||
data={data.wagonStatusBreakdown.map((item) => ({
|
data={data.containersBySize.map((item) => ({
|
||||||
name: formatStatusLabel(item.status),
|
name: item.label,
|
||||||
value: item.count,
|
value: item.count,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid gap="md">
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="Container status"
|
title="Container status"
|
||||||
data={data.containerStatusBreakdown.map((item) => ({
|
data={toDonutData(data.containerStatusBreakdown)}
|
||||||
name: formatStatusLabel(item.status),
|
|
||||||
value: item.count,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
<OverviewDonutChart
|
<OverviewDonutChart
|
||||||
title="Cargo status"
|
title="Cargo status"
|
||||||
data={data.cargoStatusBreakdown.map((item) => ({
|
data={toDonutData(data.cargoStatusBreakdown)}
|
||||||
name: formatStatusLabel(item.status),
|
|
||||||
value: item.count,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Info, Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
import DurationField from "@/components/trainScheduling/DurationField";
|
||||||
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
|
import type { CreateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
/** Fallbacks matching the API's global-rules defaults (used if the fetch fails). */
|
||||||
|
const DEFAULTS = {
|
||||||
|
windowOpenHour: 8,
|
||||||
|
windowCloseHour: 17,
|
||||||
|
windowDurationHours: 3,
|
||||||
|
docReviewMinutes: 30,
|
||||||
|
paymentWindowMinutes: 60,
|
||||||
|
importWindowLeadDays: 3,
|
||||||
|
exportBookingLeadHours: 24,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||||
|
function hourLabel(hour: number): string {
|
||||||
|
const period = hour < 12 ? "AM" : "PM";
|
||||||
|
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||||||
|
return `${h12}:00 ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||||||
|
value: String(h),
|
||||||
|
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface WindowFormState {
|
||||||
|
windowOpenHour: number;
|
||||||
|
windowCloseHour: number;
|
||||||
|
windowDurationHours: number | "";
|
||||||
|
docReviewMinutes: number | "";
|
||||||
|
paymentWindowMinutes: number | "";
|
||||||
|
importWindowLeadDays: number | "";
|
||||||
|
exportBookingLeadHours: number | "";
|
||||||
|
/** Blank = close exactly at departure. */
|
||||||
|
closeOffsetMinutes: number | "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the create payload from form state, or returns an error message when a
|
||||||
|
* required field was left blank. The close offset is direction-scoped: only the
|
||||||
|
* offset matching this schedule's direction is sent, since the other is never read.
|
||||||
|
*/
|
||||||
|
export function buildWindowRulePayload(
|
||||||
|
form: WindowFormState,
|
||||||
|
isExport: boolean,
|
||||||
|
): { payload: CreateScheduleWindowRulePayload } | { error: string } {
|
||||||
|
const duration = Number(form.windowDurationHours);
|
||||||
|
const doc = Number(form.docReviewMinutes);
|
||||||
|
const pay = Number(form.paymentWindowMinutes);
|
||||||
|
const lead = Number(form.importWindowLeadDays);
|
||||||
|
const exportLead = Number(form.exportBookingLeadHours);
|
||||||
|
|
||||||
|
const leadInvalid = isExport
|
||||||
|
? form.exportBookingLeadHours === "" || !Number.isFinite(exportLead) || exportLead < 1
|
||||||
|
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
|
||||||
|
if (
|
||||||
|
form.windowDurationHours === "" ||
|
||||||
|
form.docReviewMinutes === "" ||
|
||||||
|
form.paymentWindowMinutes === "" ||
|
||||||
|
!Number.isFinite(duration) ||
|
||||||
|
!Number.isFinite(doc) ||
|
||||||
|
!Number.isFinite(pay) ||
|
||||||
|
leadInvalid
|
||||||
|
) {
|
||||||
|
return { error: "Fill every booking-window field, or turn the toggle off" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank offset = close at departure. Sent as null (not omitted) so it wins
|
||||||
|
// over a non-null global offset.
|
||||||
|
const offset = form.closeOffsetMinutes === "" ? null : Number(form.closeOffsetMinutes);
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: {
|
||||||
|
windowOpenHour: form.windowOpenHour,
|
||||||
|
windowCloseHour: form.windowCloseHour,
|
||||||
|
windowDurationHours: duration,
|
||||||
|
docReviewMinutes: doc,
|
||||||
|
paymentWindowMinutes: pay,
|
||||||
|
...(isExport
|
||||||
|
? { exportBookingLeadHours: exportLead, exportCloseOffsetMinutes: offset }
|
||||||
|
: { importWindowLeadDays: lead, importCloseOffsetMinutes: offset }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScheduleWindowFieldsProps {
|
||||||
|
/** Direction of the selected route — picks lead/offset semantics. */
|
||||||
|
isExport: boolean;
|
||||||
|
form: WindowFormState | null;
|
||||||
|
onChange: (next: WindowFormState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-window settings for a schedule being created. Prefills from the live
|
||||||
|
* global rules (so the fields show what the schedule WOULD inherit), then lets
|
||||||
|
* staff tune them for this one train. Mirrors BookingWindowSettingsModal, plus
|
||||||
|
* the booking-close offset.
|
||||||
|
*/
|
||||||
|
export default function CreateScheduleWindowFields({
|
||||||
|
isExport,
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
}: CreateScheduleWindowFieldsProps) {
|
||||||
|
const rulesQuery = useQuery({
|
||||||
|
queryKey: ["train-scheduling", "global-rules"],
|
||||||
|
queryFn: () => trainSchedulingService.getGlobalRules(),
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed once from the global rules, so the toggle opens on the values this
|
||||||
|
// schedule would otherwise inherit rather than on hardcoded guesses.
|
||||||
|
const [seeded, setSeeded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (seeded || form != null) return;
|
||||||
|
const r = rulesQuery.data;
|
||||||
|
if (!r && rulesQuery.isLoading) return;
|
||||||
|
const num = (v: unknown, fallback: number) => {
|
||||||
|
const n = v == null || v === "" ? NaN : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
};
|
||||||
|
const offset = isExport
|
||||||
|
? (r as { exportCloseOffsetMinutes?: number | null } | undefined)
|
||||||
|
?.exportCloseOffsetMinutes
|
||||||
|
: (r as { importCloseOffsetMinutes?: number | null } | undefined)
|
||||||
|
?.importCloseOffsetMinutes;
|
||||||
|
onChange({
|
||||||
|
windowOpenHour: num(r?.windowOpenHour, DEFAULTS.windowOpenHour),
|
||||||
|
windowCloseHour: num(r?.windowCloseHour, DEFAULTS.windowCloseHour),
|
||||||
|
windowDurationHours: num(r?.windowDurationHours, DEFAULTS.windowDurationHours),
|
||||||
|
docReviewMinutes: num(r?.docReviewMinutes, DEFAULTS.docReviewMinutes),
|
||||||
|
paymentWindowMinutes: num(
|
||||||
|
isExport
|
||||||
|
? (r as { exportPaymentWindowMinutes?: number } | undefined)
|
||||||
|
?.exportPaymentWindowMinutes
|
||||||
|
: r?.paymentWindowMinutes,
|
||||||
|
DEFAULTS.paymentWindowMinutes,
|
||||||
|
),
|
||||||
|
importWindowLeadDays: num(r?.importWindowLeadDays, DEFAULTS.importWindowLeadDays),
|
||||||
|
exportBookingLeadHours: num(
|
||||||
|
r?.exportBookingLeadHours,
|
||||||
|
DEFAULTS.exportBookingLeadHours,
|
||||||
|
),
|
||||||
|
closeOffsetMinutes: offset == null || offset === 0 ? "" : Number(offset),
|
||||||
|
});
|
||||||
|
setSeeded(true);
|
||||||
|
}, [seeded, form, rulesQuery.data, rulesQuery.isLoading, isExport, onChange]);
|
||||||
|
|
||||||
|
const set = (patch: Partial<WindowFormState>) => {
|
||||||
|
if (form) onChange({ ...form, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
const is24h = form != null && form.windowOpenHour === form.windowCloseHour;
|
||||||
|
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning).
|
||||||
|
const isOvernight = form != null && form.windowCloseHour < form.windowOpenHour;
|
||||||
|
|
||||||
|
const reopenSummary = useMemo(() => {
|
||||||
|
if (!form) return "";
|
||||||
|
const total = (Number(form.docReviewMinutes) || 0) + (Number(form.paymentWindowMinutes) || 0);
|
||||||
|
const h = Math.floor(total / 60);
|
||||||
|
const m = total % 60;
|
||||||
|
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(" ") : "0m";
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
if (!form) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
{isExport ? (
|
||||||
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
|
Export schedules use a single first-come-first-served window: it opens the
|
||||||
|
export lead time before departure — shifted to the next desk opening if that
|
||||||
|
lands outside desk hours — and stays open until it closes. Cycle timing below
|
||||||
|
doesn't apply.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert variant="light" color="orange" icon={<Info size={16} />}>
|
||||||
|
These settings apply to this train only, and can be set only for the FIRST
|
||||||
|
train on a route and departure day. Later trains that day join its booking
|
||||||
|
group and share the same window.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Daily desk hours ─────────────────────────────────────────── */}
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" align="center" mb={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
Daily desk hours (EAT)
|
||||||
|
</Text>
|
||||||
|
{is24h ? (
|
||||||
|
<Badge variant="light" color="grape" leftSection={<Moon size={12} />}>
|
||||||
|
24-hour desk
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="light" color="edr-green" leftSection={<Sun size={12} />}>
|
||||||
|
{hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<Select
|
||||||
|
label="Opens"
|
||||||
|
data={HOUR_OPTIONS}
|
||||||
|
value={String(form.windowOpenHour)}
|
||||||
|
onChange={(v) => v != null && set({ windowOpenHour: Number(v) })}
|
||||||
|
allowDeselect={false}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Closes"
|
||||||
|
data={HOUR_OPTIONS}
|
||||||
|
value={String(form.windowCloseHour)}
|
||||||
|
onChange={(v) => v != null && set({ windowCloseHour: Number(v) })}
|
||||||
|
allowDeselect={false}
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{isOvernight && !is24h ? (
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight,
|
||||||
|
closing {form.windowCloseHour}:00 the next morning.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Switch
|
||||||
|
mt="sm"
|
||||||
|
size="sm"
|
||||||
|
color="grape"
|
||||||
|
label="Run 24 hours a day (never pause overnight)"
|
||||||
|
checked={is24h}
|
||||||
|
onChange={(e) =>
|
||||||
|
set({
|
||||||
|
// On → close == open (24h desk). Off → restore a normal ~9h day.
|
||||||
|
windowCloseHour: e.currentTarget.checked
|
||||||
|
? form.windowOpenHour
|
||||||
|
: Math.min(23, form.windowOpenHour + 9),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Cycle timing ─────────────────────────────────────────────── */}
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600} mb={6}>
|
||||||
|
Cycle timing
|
||||||
|
</Text>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<DurationField
|
||||||
|
label="Window duration"
|
||||||
|
description="How long each booking cycle stays open before it closes for review"
|
||||||
|
value={form.windowDurationHours}
|
||||||
|
nativeUnit="hours"
|
||||||
|
onChange={(v) => set({ windowDurationHours: v })}
|
||||||
|
min={0.0166}
|
||||||
|
disabled={isExport}
|
||||||
|
/>
|
||||||
|
<Group grow align="flex-start">
|
||||||
|
<DurationField
|
||||||
|
label="Document review"
|
||||||
|
description="Staff time to accept documents after the window closes"
|
||||||
|
value={form.docReviewMinutes}
|
||||||
|
nativeUnit="minutes"
|
||||||
|
onChange={(v) => set({ docReviewMinutes: v })}
|
||||||
|
min={0}
|
||||||
|
disabled={isExport}
|
||||||
|
/>
|
||||||
|
<DurationField
|
||||||
|
label="Payment window"
|
||||||
|
description="Time a selected customer has to pay"
|
||||||
|
value={form.paymentWindowMinutes}
|
||||||
|
nativeUnit="minutes"
|
||||||
|
onChange={(v) => set({ paymentWindowMinutes: v })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{!isExport ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Reopen gap after each cycle = document review + payment ={" "}
|
||||||
|
<b>{reopenSummary}</b>.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||||
|
{isExport ? (
|
||||||
|
<NumberInput
|
||||||
|
label="Export booking lead (hours)"
|
||||||
|
description="How many hours before departure the export booking window opens"
|
||||||
|
value={form.exportBookingLeadHours}
|
||||||
|
onChange={(v) => set({ exportBookingLeadHours: v === "" ? "" : Number(v) })}
|
||||||
|
min={1}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowNegative={false}
|
||||||
|
allowDecimal={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
label="Window lead (days)"
|
||||||
|
description="How many days before departure the booking window starts"
|
||||||
|
value={form.importWindowLeadDays}
|
||||||
|
onChange={(v) => set({ importWindowLeadDays: v === "" ? "" : Number(v) })}
|
||||||
|
min={0}
|
||||||
|
clampBehavior="none"
|
||||||
|
allowNegative={false}
|
||||||
|
allowDecimal={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Booking close offset ─────────────────────────────────────── */}
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" fw={600} mb={2}>
|
||||||
|
Booking close offset
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
|
How long before departure this schedule stops accepting bookings. e.g. a
|
||||||
|
3-hour import offset closes a 17:00 departure's window at 14:00; a 1-day
|
||||||
|
export offset closes a Jul-10 16:00 departure at Jul-9 16:00. Leave blank to
|
||||||
|
close exactly at departure.
|
||||||
|
</Text>
|
||||||
|
<DurationField
|
||||||
|
label={isExport ? "Export close offset" : "Import close offset"}
|
||||||
|
description={
|
||||||
|
isExport
|
||||||
|
? "This export booking window closes this long before departure (blank = at departure)"
|
||||||
|
: "This import booking window closes this long before departure (blank = at departure)"
|
||||||
|
}
|
||||||
|
value={form.closeOffsetMinutes}
|
||||||
|
nativeUnit="minutes"
|
||||||
|
onChange={(v) => set({ closeOffsetMinutes: v })}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -184,7 +184,8 @@ export const QUERY_KEYS = {
|
|||||||
["overview", "contracts", range ?? "30d"] as const,
|
["overview", "contracts", range ?? "30d"] as const,
|
||||||
billingTab: (range?: string) =>
|
billingTab: (range?: string) =>
|
||||||
["overview", "billing", range ?? "30d"] as const,
|
["overview", "billing", range ?? "30d"] as const,
|
||||||
operationsTab: () => ["overview", "operations"] as const,
|
operationsTab: (range?: string) =>
|
||||||
|
["overview", "operations", range ?? "30d"] as const,
|
||||||
customersTab: (range?: string) =>
|
customersTab: (range?: string) =>
|
||||||
["overview", "customers", range ?? "30d"] as const,
|
["overview", "customers", range ?? "30d"] as const,
|
||||||
staffTab: (range?: string) =>
|
staffTab: (range?: string) =>
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ export const URL_CONSTANTS = {
|
|||||||
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
BY_USER_ID: (id: string) => `/api/customers/user/${id}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
REPORTS: {
|
||||||
|
RUN: (key: string) => `/reports/${key}`,
|
||||||
|
},
|
||||||
|
|
||||||
OVERVIEW: {
|
OVERVIEW: {
|
||||||
BASE: "/overview",
|
BASE: "/overview",
|
||||||
BOOKINGS: "/overview/bookings",
|
BOOKINGS: "/overview/bookings",
|
||||||
@@ -343,6 +347,10 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/bookings/${bookingId}/expire`,
|
`/train-scheduling/bookings/${bookingId}/expire`,
|
||||||
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
MOVE_BOOKING_SCHEDULE: (bookingId: string) =>
|
||||||
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
`/train-scheduling/bookings/${bookingId}/move-schedule`,
|
||||||
|
ALLOCATION_CANDIDATES: (bookingId: string) =>
|
||||||
|
`/train-scheduling/bookings/${bookingId}/allocation-candidates`,
|
||||||
|
ALLOCATE_BOOKING: (bookingId: string) =>
|
||||||
|
`/train-scheduling/bookings/${bookingId}/allocate`,
|
||||||
GLOBAL_RULES: "/train-scheduling/global-rules",
|
GLOBAL_RULES: "/train-scheduling/global-rules",
|
||||||
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
|
BOOKING_WINDOWS: "/train-scheduling/booking-windows",
|
||||||
PREVIEW: "/train-scheduling/preview",
|
PREVIEW: "/train-scheduling/preview",
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOverviewOperationsTab(enabled: boolean) {
|
export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(),
|
queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range),
|
||||||
queryFn: () => overviewService.getOperationsTab(),
|
queryFn: () => overviewService.getOperationsTab(range),
|
||||||
enabled,
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
|
Modal,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -50,7 +52,10 @@ import {
|
|||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
|
import type { AllocationCandidate } from "@/types/trainScheduling";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -155,6 +160,15 @@ export default function BookingRequestsPage() {
|
|||||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||||
|
// Paid bookings with no train attached (staff removed them or a sweep
|
||||||
|
// detached them) — the queue the per-row Allocate action works through.
|
||||||
|
const [paidUnallocated, setPaidUnallocated] = useState(false);
|
||||||
|
const [allocatingId, setAllocatingId] = useState<string | null>(null);
|
||||||
|
const [otherDayModal, setOtherDayModal] = useState<{
|
||||||
|
booking: BookingListRow;
|
||||||
|
candidates: AllocationCandidate[];
|
||||||
|
} | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
const suppressRowClickRef = useRef(false);
|
const suppressRowClickRef = useRef(false);
|
||||||
const suppressRowClick = useCallback(() => {
|
const suppressRowClick = useCallback(() => {
|
||||||
suppressRowClickRef.current = true;
|
suppressRowClickRef.current = true;
|
||||||
@@ -187,6 +201,10 @@ export default function BookingRequestsPage() {
|
|||||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
||||||
|
// Wins over the payment-status select — the queue is by definition PAID.
|
||||||
|
...(paidUnallocated
|
||||||
|
? { paymentStatus: "PAID", assignedToSchedule: "false" as const }
|
||||||
|
: {}),
|
||||||
...(ownershipFilter
|
...(ownershipFilter
|
||||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -208,6 +226,7 @@ export default function BookingRequestsPage() {
|
|||||||
directionFilter,
|
directionFilter,
|
||||||
freightTypeFilter,
|
freightTypeFilter,
|
||||||
paymentStatusFilter,
|
paymentStatusFilter,
|
||||||
|
paidUnallocated,
|
||||||
ownershipFilter,
|
ownershipFilter,
|
||||||
originYardFilter,
|
originYardFilter,
|
||||||
destinationYardFilter,
|
destinationYardFilter,
|
||||||
@@ -251,6 +270,7 @@ export default function BookingRequestsPage() {
|
|||||||
(directionFilter ? 1 : 0) +
|
(directionFilter ? 1 : 0) +
|
||||||
(freightTypeFilter ? 1 : 0) +
|
(freightTypeFilter ? 1 : 0) +
|
||||||
(paymentStatusFilter ? 1 : 0) +
|
(paymentStatusFilter ? 1 : 0) +
|
||||||
|
(paidUnallocated ? 1 : 0) +
|
||||||
(ownershipFilter ? 1 : 0) +
|
(ownershipFilter ? 1 : 0) +
|
||||||
(originYardFilter ? 1 : 0) +
|
(originYardFilter ? 1 : 0) +
|
||||||
(destinationYardFilter ? 1 : 0) +
|
(destinationYardFilter ? 1 : 0) +
|
||||||
@@ -263,6 +283,7 @@ export default function BookingRequestsPage() {
|
|||||||
setDirectionFilter(null);
|
setDirectionFilter(null);
|
||||||
setFreightTypeFilter(null);
|
setFreightTypeFilter(null);
|
||||||
setPaymentStatusFilter(null);
|
setPaymentStatusFilter(null);
|
||||||
|
setPaidUnallocated(false);
|
||||||
setOwnershipFilter(null);
|
setOwnershipFilter(null);
|
||||||
setOriginYardFilter(null);
|
setOriginYardFilter(null);
|
||||||
setDestinationYardFilter(null);
|
setDestinationYardFilter(null);
|
||||||
@@ -301,6 +322,67 @@ export default function BookingRequestsPage() {
|
|||||||
[navigate],
|
[navigate],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One click: same-day fit → allocate straight away. No same-day fit but a
|
||||||
|
// train on another date fits → let staff pick it (customer is notified of
|
||||||
|
// the date change by the API). Nothing fits → say so.
|
||||||
|
const handleAllocatePaid = useCallback(
|
||||||
|
async (row: BookingListRow) => {
|
||||||
|
setAllocatingId(row.id);
|
||||||
|
try {
|
||||||
|
const candidates =
|
||||||
|
await trainSchedulingService.getAllocationCandidates(row.id);
|
||||||
|
if (candidates.sameDay.length > 0) {
|
||||||
|
const target = candidates.sameDay[0];
|
||||||
|
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
||||||
|
toast({
|
||||||
|
title: `Allocated ${row.reference}`,
|
||||||
|
description: `Placed on ${target.reference ?? "train"} departing ${formatDate(target.scheduledDepartureDate)}.`,
|
||||||
|
});
|
||||||
|
void refetch();
|
||||||
|
} else if (candidates.otherDays.length > 0) {
|
||||||
|
setOtherDayModal({ booking: row, candidates: candidates.otherDays });
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "No fitting train",
|
||||||
|
description:
|
||||||
|
"No open schedule covers this booking's route with enough capacity.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Allocation failed", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setAllocatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refetch, toast],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAllocateOtherDay = useCallback(
|
||||||
|
async (candidate: AllocationCandidate) => {
|
||||||
|
if (!otherDayModal) return;
|
||||||
|
const { booking } = otherDayModal;
|
||||||
|
setAllocatingId(booking.id);
|
||||||
|
try {
|
||||||
|
await trainSchedulingService.allocatePaidBooking(
|
||||||
|
booking.id,
|
||||||
|
candidate.id,
|
||||||
|
);
|
||||||
|
toast({
|
||||||
|
title: `Allocated ${booking.reference}`,
|
||||||
|
description: `Placed on ${candidate.reference ?? "train"} departing ${formatDate(candidate.scheduledDepartureDate)}. Customer notified of the date change.`,
|
||||||
|
});
|
||||||
|
setOtherDayModal(null);
|
||||||
|
void refetch();
|
||||||
|
} catch {
|
||||||
|
toast({ title: "Allocation failed", variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setAllocatingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[otherDayModal, refetch, toast],
|
||||||
|
);
|
||||||
|
|
||||||
const columns: ColumnDef<BookingListRow>[] = [
|
const columns: ColumnDef<BookingListRow>[] = [
|
||||||
{
|
{
|
||||||
id: "booking",
|
id: "booking",
|
||||||
@@ -435,13 +517,33 @@ export default function BookingRequestsPage() {
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
size: 140,
|
size: 140,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<BookingActionsMenu
|
const b = row.original;
|
||||||
row={row.original}
|
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||||
variant="table"
|
return (
|
||||||
onSuppressRowClick={suppressRowClick}
|
<Group gap="xs" wrap="nowrap">
|
||||||
/>
|
{needsAllocation ? (
|
||||||
),
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
color="edr-green"
|
||||||
|
loading={allocatingId === b.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
suppressRowClick();
|
||||||
|
void handleAllocatePaid(b);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Allocate
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<BookingActionsMenu
|
||||||
|
row={b}
|
||||||
|
variant="table"
|
||||||
|
onSuppressRowClick={suppressRowClick}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -640,6 +742,16 @@ export default function BookingRequestsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
style={{ minWidth: 180 }}
|
style={{ minWidth: 180 }}
|
||||||
/>
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Paid, not allocated"
|
||||||
|
checked={paidUnallocated}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPaidUnallocated(e.currentTarget.checked);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
radius="sm"
|
||||||
|
style={{ alignSelf: "center" }}
|
||||||
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Gov / Private"
|
placeholder="Gov / Private"
|
||||||
data={OWNERSHIP_OPTIONS}
|
data={OWNERSHIP_OPTIONS}
|
||||||
@@ -751,6 +863,42 @@ export default function BookingRequestsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={otherDayModal !== null}
|
||||||
|
onClose={() => setOtherDayModal(null)}
|
||||||
|
title="Allocate to another date"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
|
||||||
|
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||||
|
other dates do — the customer will be notified of the date change.
|
||||||
|
</Text>
|
||||||
|
{otherDayModal?.candidates.map((c) => (
|
||||||
|
<Group key={c.id} justify="space-between" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{c.reference ?? "Train"}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Departs {formatDate(c.scheduledDepartureDate)}
|
||||||
|
{c.direction ? ` · ${c.direction}` : ""}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
loading={allocatingId === otherDayModal.booking.id}
|
||||||
|
onClick={() => void handleAllocateOtherDay(c)}
|
||||||
|
>
|
||||||
|
Allocate
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{allocateBooking ? (
|
{allocateBooking ? (
|
||||||
<AllocateBookingWizard
|
<AllocateBookingWizard
|
||||||
booking={allocateBooking}
|
booking={allocateBooking}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Train,
|
Train,
|
||||||
|
TrainFront,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Container,
|
Container,
|
||||||
Paper,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -22,7 +22,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||||
import { OverviewQuickLinks } from "@/components/overview/OverviewQuickLinks";
|
|
||||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||||
import "@/components/overview/overview.css";
|
import "@/components/overview/overview.css";
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
@@ -82,6 +81,18 @@ const TAB_ITEMS: Array<{
|
|||||||
FREIGHT_PERMS.lastMile.view,
|
FREIGHT_PERMS.lastMile.view,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "fleet",
|
||||||
|
label: "Fleet",
|
||||||
|
icon: TrainFront,
|
||||||
|
kpiKey: "operations",
|
||||||
|
metricKey: "wagonsAvailable",
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.fleet.view,
|
||||||
|
FREIGHT_PERMS.wagons.view,
|
||||||
|
FREIGHT_PERMS.trainScheduling.view,
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "customers",
|
value: "customers",
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
@@ -174,6 +185,12 @@ const OverviewPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{visibleTabs.length === 0 && !isLoading && !isError && (
|
||||||
|
<Alert color="gray" variant="light" title="No dashboard sections available">
|
||||||
|
Your role has no access to any overview section.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{visibleTabs.length > 0 && (
|
{visibleTabs.length > 0 && (
|
||||||
<Tabs
|
<Tabs
|
||||||
value={currentTab}
|
value={currentTab}
|
||||||
@@ -220,10 +237,6 @@ const OverviewPage = () => {
|
|||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper p="lg" radius="lg" withBorder>
|
|
||||||
<OverviewQuickLinks />
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|||||||
445
apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx
Normal file
445
apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
MultiSelect,
|
||||||
|
Select,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DateInput } from "@mantine/dates";
|
||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import { Download, FileSpreadsheet, Printer, RotateCcw } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useParams, useSearchParams, Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { ALL_TRADE_DIRECTIONS, TRADE_DIRECTION_LABELS } from "@edr/types";
|
||||||
|
|
||||||
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { ReportQueryInput, ReportRow } from "@/types/reports";
|
||||||
|
import {
|
||||||
|
REPORT_CONFIG_BY_KEY,
|
||||||
|
type ReportColumn,
|
||||||
|
type ReportConfig,
|
||||||
|
} from "./reportConfigs";
|
||||||
|
|
||||||
|
const compact = new Intl.NumberFormat("en", { notation: "compact" });
|
||||||
|
|
||||||
|
const UNIT_SUFFIX = { ETB: " ETB", t: " t", "%": "%", min: " min" } as const;
|
||||||
|
|
||||||
|
function formatCell(value: unknown, col: ReportColumn): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
|
if (col.unit || col.numeric) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
return `${n.toLocaleString()}${col.unit ? UNIT_SUFFIX[col.unit] : ""}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toDate = (s: string | null): Date | null => (s ? new Date(s) : null);
|
||||||
|
// Mantine DateInput onChange emits a date string (or null).
|
||||||
|
const toParam = (d: Date | string | null): string | null => {
|
||||||
|
if (!d) return null;
|
||||||
|
return typeof d === "string" ? d.slice(0, 10) : d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
function downloadBlob(content: BlobPart, type: string, filename: string) {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv(config: ReportConfig, rows: ReportRow[]) {
|
||||||
|
const esc = (v: unknown) => `"${String(v ?? "").replace(/"/g, '""')}"`;
|
||||||
|
const lines = [
|
||||||
|
config.columns.map((c) => esc(c.label)).join(","),
|
||||||
|
...rows.map((r) => config.columns.map((c) => esc(r[c.key])).join(",")),
|
||||||
|
];
|
||||||
|
downloadBlob(lines.join("\n"), "text/csv;charset=utf-8", `${config.key}.csv`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportXlsx(config: ReportConfig, rows: ReportRow[]) {
|
||||||
|
const sheetRows = rows.map((r) =>
|
||||||
|
Object.fromEntries(config.columns.map((c) => [c.label, r[c.key] ?? ""])),
|
||||||
|
);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(
|
||||||
|
wb,
|
||||||
|
XLSX.utils.json_to_sheet(sheetRows),
|
||||||
|
config.title.slice(0, 31),
|
||||||
|
);
|
||||||
|
XLSX.writeFile(wb, `${config.key}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportChartView({
|
||||||
|
config,
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
config: ReportConfig;
|
||||||
|
rows: ReportRow[];
|
||||||
|
}) {
|
||||||
|
const chart = config.chart;
|
||||||
|
const data = useMemo(() => {
|
||||||
|
if (!chart) return [];
|
||||||
|
const sliced = chart.topN ? rows.slice(0, chart.topN) : rows;
|
||||||
|
// xKey "a+b" concatenates columns (e.g. origin+destination → "A → B").
|
||||||
|
const keys = chart.xKey.split("+");
|
||||||
|
return sliced.map((r) => ({
|
||||||
|
...r,
|
||||||
|
__x:
|
||||||
|
keys.length > 1
|
||||||
|
? keys.map((k) => String(r[k] ?? "")).join(" → ")
|
||||||
|
: String(r[chart.xKey] ?? ""),
|
||||||
|
}));
|
||||||
|
}, [chart, rows]);
|
||||||
|
|
||||||
|
if (!chart) return null;
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No data for the selected filters
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartComponent =
|
||||||
|
chart.type === "bar" ? BarChart : chart.type === "line" ? LineChart : AreaChart;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
|
<ChartComponent data={data} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
|
<XAxis dataKey="__x" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(v: number) => compact.format(v)}
|
||||||
|
width={56}
|
||||||
|
/>
|
||||||
|
<Tooltip formatter={(value) => Number(value ?? 0).toLocaleString()} />
|
||||||
|
{chart.series.length > 1 ? <Legend /> : null}
|
||||||
|
{chart.series.map((s, i) => {
|
||||||
|
const color =
|
||||||
|
overviewChartColors.pipeline[i % overviewChartColors.pipeline.length];
|
||||||
|
if (chart.type === "bar") {
|
||||||
|
return (
|
||||||
|
<Bar key={s.key} dataKey={s.key} name={s.label} fill={color} radius={[4, 4, 0, 0]} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (chart.type === "line") {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={s.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={s.key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={s.key}
|
||||||
|
name={s.label}
|
||||||
|
stroke={color}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={0.15}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ChartComponent>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportPage() {
|
||||||
|
const { reportKey = "" } = useParams<{ reportKey: string }>();
|
||||||
|
const config = REPORT_CONFIG_BY_KEY.get(reportKey);
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 20 });
|
||||||
|
|
||||||
|
const setParam = (name: string, value: string | null) => {
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
if (value) prev.set(name, value);
|
||||||
|
else prev.delete(name);
|
||||||
|
return prev;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const input: ReportQueryInput = {
|
||||||
|
key: reportKey,
|
||||||
|
dateFrom: params.get("dateFrom") ?? undefined,
|
||||||
|
dateTo: params.get("dateTo") ?? undefined,
|
||||||
|
granularity:
|
||||||
|
(params.get("granularity") as ReportQueryInput["granularity"]) ?? undefined,
|
||||||
|
yardIds: params.get("yardIds") ?? undefined,
|
||||||
|
statuses: params.get("statuses") ?? undefined,
|
||||||
|
direction: params.get("direction") ?? undefined,
|
||||||
|
freightType: params.get("freightType") ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportQuery = useQuery(
|
||||||
|
api.reports.run.queryOptions({
|
||||||
|
input,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: Boolean(config),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const yardsQuery = useQuery(
|
||||||
|
api.routes.yards.queryOptions({
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
enabled: Boolean(config?.filters.includes("yards")),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader title="Unknown report" backTo="/dashboard/reports" />
|
||||||
|
<Text>
|
||||||
|
This report does not exist. <Link to="/dashboard/reports">Back to reports</Link>
|
||||||
|
</Text>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = reportQuery.data?.rows ?? [];
|
||||||
|
const kpis = reportQuery.data?.kpis ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil(rows.length / pagination.pageSize));
|
||||||
|
|
||||||
|
const columns: ColumnDef<ReportRow, unknown>[] = config.columns.map((col) => ({
|
||||||
|
accessorKey: col.key,
|
||||||
|
header: col.label,
|
||||||
|
cell: (info) => formatCell(info.getValue(), col),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tableStatus = reportQuery.isLoading
|
||||||
|
? "loading"
|
||||||
|
: reportQuery.isError
|
||||||
|
? "error"
|
||||||
|
: "success";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={config.title}
|
||||||
|
subtitle={config.description}
|
||||||
|
backTo="/dashboard/reports"
|
||||||
|
action={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
onClick={() => exportCsv(config, rows)}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
>
|
||||||
|
CSV
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<FileSpreadsheet size={14} />}
|
||||||
|
onClick={() => exportXlsx(config, rows)}
|
||||||
|
disabled={rows.length === 0}
|
||||||
|
>
|
||||||
|
Excel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Printer size={14} />}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||||
|
<DateInput
|
||||||
|
label="From"
|
||||||
|
size="xs"
|
||||||
|
clearable
|
||||||
|
value={toDate(params.get("dateFrom"))}
|
||||||
|
maxDate={toDate(params.get("dateTo")) ?? undefined}
|
||||||
|
onChange={(d) => setParam("dateFrom", toParam(d))}
|
||||||
|
placeholder="All time"
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="To"
|
||||||
|
size="xs"
|
||||||
|
clearable
|
||||||
|
value={toDate(params.get("dateTo"))}
|
||||||
|
minDate={toDate(params.get("dateFrom")) ?? undefined}
|
||||||
|
onChange={(d) => setParam("dateTo", toParam(d))}
|
||||||
|
placeholder="All time"
|
||||||
|
/>
|
||||||
|
{config.filters.includes("granularity") ? (
|
||||||
|
<Select
|
||||||
|
label="Group by"
|
||||||
|
size="xs"
|
||||||
|
data={[
|
||||||
|
{ value: "day", label: "Day" },
|
||||||
|
{ value: "week", label: "Week" },
|
||||||
|
{ value: "month", label: "Month" },
|
||||||
|
]}
|
||||||
|
value={params.get("granularity") ?? "day"}
|
||||||
|
onChange={(v) => setParam("granularity", v)}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{config.filters.includes("yards") ? (
|
||||||
|
<MultiSelect
|
||||||
|
label="Yards"
|
||||||
|
size="xs"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={220}
|
||||||
|
data={(yardsQuery.data ?? []).map((y) => ({
|
||||||
|
value: y.id,
|
||||||
|
label: y.label,
|
||||||
|
}))}
|
||||||
|
value={params.get("yardIds")?.split(",").filter(Boolean) ?? []}
|
||||||
|
onChange={(v) => setParam("yardIds", v.length ? v.join(",") : null)}
|
||||||
|
placeholder="All yards"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{config.filters.includes("direction") ? (
|
||||||
|
<Select
|
||||||
|
label="Direction"
|
||||||
|
size="xs"
|
||||||
|
clearable
|
||||||
|
data={ALL_TRADE_DIRECTIONS.map((d) => ({
|
||||||
|
value: d,
|
||||||
|
label: TRADE_DIRECTION_LABELS[d],
|
||||||
|
}))}
|
||||||
|
value={params.get("direction")}
|
||||||
|
onChange={(v) => setParam("direction", v)}
|
||||||
|
placeholder="All"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{config.filters.includes("freightType") ? (
|
||||||
|
<Select
|
||||||
|
label="Freight type"
|
||||||
|
size="xs"
|
||||||
|
clearable
|
||||||
|
data={["CONTAINER", "BULK"]}
|
||||||
|
value={params.get("freightType")}
|
||||||
|
onChange={(v) => setParam("freightType", v)}
|
||||||
|
placeholder="All"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{config.filters.includes("statuses") && config.statusOptions ? (
|
||||||
|
<MultiSelect
|
||||||
|
label="Status"
|
||||||
|
size="xs"
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
w={220}
|
||||||
|
data={config.statusOptions}
|
||||||
|
value={params.get("statuses")?.split(",").filter(Boolean) ?? []}
|
||||||
|
onChange={(v) => setParam("statuses", v.length ? v.join(",") : null)}
|
||||||
|
placeholder="Default (active)"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<RotateCcw size={14} />}
|
||||||
|
onClick={() => setParams({}, { replace: true })}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<KpiStrip
|
||||||
|
loading={reportQuery.isLoading}
|
||||||
|
items={kpis.map((k) => ({
|
||||||
|
label: k.label,
|
||||||
|
value: k.value.toLocaleString(),
|
||||||
|
hint: k.unit,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReportChartView config={config} rows={rows} />
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={tableStatus}
|
||||||
|
emptyMessage="No data for the selected filters"
|
||||||
|
error={
|
||||||
|
reportQuery.isError
|
||||||
|
? {
|
||||||
|
message: "Failed to load report",
|
||||||
|
onRetry: () => void reportQuery.refetch(),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: rows.length,
|
||||||
|
}}
|
||||||
|
tableOptions={{
|
||||||
|
manualPagination: false,
|
||||||
|
state: { pagination },
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
autoResetPageIndex: false,
|
||||||
|
}}
|
||||||
|
footer={({ table, pagination: p }) => (
|
||||||
|
<DataTableFooter
|
||||||
|
table={table}
|
||||||
|
pagination={p}
|
||||||
|
options={{ labels: { items: "rows" } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Search, Star } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import {
|
||||||
|
REPORT_CONFIGS,
|
||||||
|
REPORT_DOMAINS,
|
||||||
|
type ReportConfig,
|
||||||
|
} from "./reportConfigs";
|
||||||
|
|
||||||
|
const FAVORITES_KEY = "reports.favorites";
|
||||||
|
|
||||||
|
const loadFavorites = (): string[] => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(FAVORITES_KEY) ?? "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReportCard({
|
||||||
|
config,
|
||||||
|
favorite,
|
||||||
|
onToggleFavorite,
|
||||||
|
}: {
|
||||||
|
config: ReportConfig;
|
||||||
|
favorite: boolean;
|
||||||
|
onToggleFavorite: () => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
className="cursor-pointer transition-colors hover:bg-gray-50"
|
||||||
|
onClick={() => navigate(`/dashboard/reports/${config.key}`)}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} truncate>
|
||||||
|
{config.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" lineClamp={2}>
|
||||||
|
{config.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color={favorite ? "yellow" : "gray"}
|
||||||
|
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Star size={16} fill={favorite ? "currentColor" : "none"} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Badge mt="sm" size="sm" variant="light">
|
||||||
|
{config.domain}
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportsHubPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [favorites, setFavorites] = useState<string[]>(loadFavorites);
|
||||||
|
|
||||||
|
const toggleFavorite = (key: string) => {
|
||||||
|
setFavorites((prev) => {
|
||||||
|
const next = prev.includes(key)
|
||||||
|
? prev.filter((k) => k !== key)
|
||||||
|
: [...prev, key];
|
||||||
|
localStorage.setItem(FAVORITES_KEY, JSON.stringify(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return REPORT_CONFIGS;
|
||||||
|
return REPORT_CONFIGS.filter(
|
||||||
|
(c) =>
|
||||||
|
c.title.toLowerCase().includes(q) ||
|
||||||
|
c.description.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const pinned = visible.filter((c) => favorites.includes(c.key));
|
||||||
|
|
||||||
|
const renderGrid = (configs: ReportConfig[]) => (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||||
|
{configs.map((c) => (
|
||||||
|
<ReportCard
|
||||||
|
key={c.key}
|
||||||
|
config={c}
|
||||||
|
favorite={favorites.includes(c.key)}
|
||||||
|
onToggleFavorite={() => toggleFavorite(c.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Reports"
|
||||||
|
subtitle="Operational, commercial and financial reporting"
|
||||||
|
action={
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
w={240}
|
||||||
|
leftSection={<Search size={14} />}
|
||||||
|
placeholder="Search reports…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pinned.length ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Title order={4}>Favorites</Title>
|
||||||
|
{renderGrid(pinned)}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{REPORT_DOMAINS.map((domain) => {
|
||||||
|
const configs = visible.filter((c) => c.domain === domain);
|
||||||
|
if (!configs.length) return null;
|
||||||
|
return (
|
||||||
|
<Stack key={domain} gap="sm">
|
||||||
|
<Title order={4}>{domain}</Title>
|
||||||
|
{renderGrid(configs)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">
|
||||||
|
No reports match “{search}”
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { BookingStatus } from "@edr/types";
|
||||||
|
|
||||||
|
export type ReportDomain = "Commercial" | "Operations" | "Finance" | "Data";
|
||||||
|
|
||||||
|
export type ReportColumnUnit = "ETB" | "t" | "%" | "min";
|
||||||
|
|
||||||
|
export interface ReportColumn {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** Numeric unit — formats the cell (thousands separators, suffix). */
|
||||||
|
unit?: ReportColumnUnit;
|
||||||
|
numeric?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportChart {
|
||||||
|
type: "area" | "line" | "bar";
|
||||||
|
xKey: string;
|
||||||
|
series: { key: string; label: string }[];
|
||||||
|
/** Chart only the first N rows (rows arrive sorted by the backend). */
|
||||||
|
topN?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportFilterKey =
|
||||||
|
| "granularity"
|
||||||
|
| "yards"
|
||||||
|
| "direction"
|
||||||
|
| "freightType"
|
||||||
|
| "statuses";
|
||||||
|
|
||||||
|
export interface ReportConfig {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
domain: ReportDomain;
|
||||||
|
filters: ReportFilterKey[];
|
||||||
|
/** Options for the `statuses` filter, when enabled. */
|
||||||
|
statusOptions?: string[];
|
||||||
|
chart?: ReportChart;
|
||||||
|
columns: ReportColumn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full enum from @edr/types; Set dedupes the deprecated AwaitingPayment alias.
|
||||||
|
const BOOKING_STATUSES = [...new Set(Object.values(BookingStatus))];
|
||||||
|
|
||||||
|
// Full list mirroring CONTRACT_STATUSES in contract.entity.ts (no shared enum
|
||||||
|
// in @edr/types yet).
|
||||||
|
const CONTRACT_STATUSES = [
|
||||||
|
"DRAFT",
|
||||||
|
"SUBMITTED",
|
||||||
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"APPROVED",
|
||||||
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
|
"CONTRACT_READY",
|
||||||
|
"SIGNED_CUSTOMER",
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
"CONTRACT_ACTIVE",
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
|
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||||
|
"SUSPENDED",
|
||||||
|
"CONTRACT_CLOSED",
|
||||||
|
"EXPIRED",
|
||||||
|
"REJECTED",
|
||||||
|
"CANCELLED",
|
||||||
|
"RENEWAL_DRAFT",
|
||||||
|
"RENEWAL_SUBMITTED",
|
||||||
|
"RENEWAL_PENDING_APPROVAL",
|
||||||
|
"AMENDMENTS_PROPOSED",
|
||||||
|
"ARCHIVED",
|
||||||
|
];
|
||||||
|
|
||||||
|
const INVOICE_STATUSES = [
|
||||||
|
"ISSUED",
|
||||||
|
"PENDING",
|
||||||
|
"PARTIALLY_PAID",
|
||||||
|
"PAID",
|
||||||
|
"OVERDUE",
|
||||||
|
"REFUNDED",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REPORT_CONFIGS: ReportConfig[] = [
|
||||||
|
{
|
||||||
|
key: "bookings-trend",
|
||||||
|
title: "Bookings Trend",
|
||||||
|
description: "Booking volume, tonnage and revenue over time",
|
||||||
|
domain: "Commercial",
|
||||||
|
filters: ["granularity", "yards", "direction", "freightType", "statuses"],
|
||||||
|
statusOptions: BOOKING_STATUSES,
|
||||||
|
chart: {
|
||||||
|
type: "area",
|
||||||
|
xKey: "period",
|
||||||
|
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "period", label: "Period" },
|
||||||
|
{ key: "bookings", label: "Bookings", numeric: true },
|
||||||
|
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||||
|
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "revenue-by-customer",
|
||||||
|
title: "Revenue by Customer",
|
||||||
|
description: "Ranked customers by booking revenue",
|
||||||
|
domain: "Commercial",
|
||||||
|
filters: ["yards", "direction", "freightType", "statuses"],
|
||||||
|
statusOptions: BOOKING_STATUSES,
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "customer",
|
||||||
|
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||||
|
topN: 10,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "bookings", label: "Bookings", numeric: true },
|
||||||
|
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||||
|
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "revenue-by-lane",
|
||||||
|
title: "Revenue by Lane",
|
||||||
|
description: "Origin → destination lanes by tonnage and revenue",
|
||||||
|
domain: "Commercial",
|
||||||
|
filters: ["direction", "freightType", "statuses"],
|
||||||
|
statusOptions: BOOKING_STATUSES,
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "origin+destination",
|
||||||
|
series: [{ key: "revenue", label: "Revenue (ETB)" }],
|
||||||
|
topN: 10,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "bookings", label: "Bookings", numeric: true },
|
||||||
|
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||||
|
{ key: "revenue", label: "Revenue", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contract-utilization",
|
||||||
|
title: "Contract Utilization",
|
||||||
|
description: "Committed scope caps vs booked tonnage per contract",
|
||||||
|
domain: "Commercial",
|
||||||
|
filters: ["direction", "statuses"],
|
||||||
|
statusOptions: CONTRACT_STATUSES,
|
||||||
|
columns: [
|
||||||
|
{ key: "reference", label: "Contract" },
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "valid_from", label: "Valid from" },
|
||||||
|
{ key: "valid_until", label: "Valid until" },
|
||||||
|
{ key: "committed", label: "Committed", unit: "t" },
|
||||||
|
{ key: "booked_tons", label: "Booked", unit: "t" },
|
||||||
|
{ key: "bookings", label: "Bookings", numeric: true },
|
||||||
|
{ key: "utilization_pct", label: "Utilization", unit: "%" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "train-on-time",
|
||||||
|
title: "Train On-Time Performance",
|
||||||
|
description: "Departure punctuality and delays by lane (60-min grace)",
|
||||||
|
domain: "Operations",
|
||||||
|
filters: ["yards", "direction"],
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "origin+destination",
|
||||||
|
series: [{ key: "on_time_pct", label: "On-time %" }],
|
||||||
|
topN: 15,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "trips", label: "Trips", numeric: true },
|
||||||
|
{ key: "departed", label: "Departed", numeric: true },
|
||||||
|
{ key: "avg_dep_delay_min", label: "Avg dep. delay", unit: "min" },
|
||||||
|
{ key: "avg_arr_delay_min", label: "Avg arr. delay", unit: "min" },
|
||||||
|
{ key: "on_time_pct", label: "On-time", unit: "%" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schedule-fill-rate",
|
||||||
|
title: "Schedule Fill Rate",
|
||||||
|
description: "Booked tonnage vs wagon capacity per train schedule",
|
||||||
|
domain: "Operations",
|
||||||
|
filters: ["yards", "direction"],
|
||||||
|
chart: {
|
||||||
|
type: "line",
|
||||||
|
xKey: "departure",
|
||||||
|
series: [{ key: "fill_pct", label: "Fill %" }],
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "train_number", label: "Train" },
|
||||||
|
{ key: "departure", label: "Departure" },
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "direction", label: "Direction" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "wagon_count", label: "Wagons", numeric: true },
|
||||||
|
{ key: "capacity_tons", label: "Capacity", unit: "t" },
|
||||||
|
{ key: "booked_tons", label: "Booked", unit: "t" },
|
||||||
|
{ key: "fill_pct", label: "Fill", unit: "%" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "trips-per-route",
|
||||||
|
title: "Trips per Route",
|
||||||
|
description: "Completed trips and tonnage hauled per lane",
|
||||||
|
domain: "Operations",
|
||||||
|
filters: ["yards", "direction"],
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "origin+destination",
|
||||||
|
series: [{ key: "trips", label: "Trips" }],
|
||||||
|
topN: 15,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "direction", label: "Direction" },
|
||||||
|
{ key: "trips", label: "Trips", numeric: true },
|
||||||
|
{ key: "tons_hauled", label: "Tonnage hauled", unit: "t" },
|
||||||
|
{ key: "avg_tons_per_trip", label: "Avg per trip", unit: "t" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invoiced-vs-collected",
|
||||||
|
title: "Invoiced vs Collected",
|
||||||
|
description: "Billing issued vs payments received over time",
|
||||||
|
domain: "Finance",
|
||||||
|
filters: ["granularity", "direction"],
|
||||||
|
chart: {
|
||||||
|
type: "line",
|
||||||
|
xKey: "period",
|
||||||
|
series: [
|
||||||
|
{ key: "invoiced", label: "Invoiced (ETB)" },
|
||||||
|
{ key: "collected", label: "Collected (ETB)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "period", label: "Period" },
|
||||||
|
{ key: "invoices", label: "Invoices", numeric: true },
|
||||||
|
{ key: "invoiced", label: "Invoiced", unit: "ETB" },
|
||||||
|
{ key: "collected", label: "Collected", unit: "ETB" },
|
||||||
|
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "aging-receivables",
|
||||||
|
title: "Aging Receivables",
|
||||||
|
description: "Outstanding invoice balances by age bucket per customer",
|
||||||
|
domain: "Finance",
|
||||||
|
filters: ["direction", "statuses"],
|
||||||
|
statusOptions: INVOICE_STATUSES,
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "customer",
|
||||||
|
series: [{ key: "outstanding", label: "Outstanding (ETB)" }],
|
||||||
|
topN: 10,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "invoices", label: "Invoices", numeric: true },
|
||||||
|
{ key: "outstanding", label: "Outstanding", unit: "ETB" },
|
||||||
|
{ key: "current", label: "Current", unit: "ETB" },
|
||||||
|
{ key: "overdue_0_30", label: "0–30d", unit: "ETB" },
|
||||||
|
{ key: "overdue_31_60", label: "31–60d", unit: "ETB" },
|
||||||
|
{ key: "overdue_61_90", label: "61–90d", unit: "ETB" },
|
||||||
|
{ key: "overdue_90_plus", label: "90d+", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "revenue-by-payment-method",
|
||||||
|
title: "Revenue by Payment Method",
|
||||||
|
description: "Successful payments broken down by method",
|
||||||
|
domain: "Finance",
|
||||||
|
filters: ["direction"],
|
||||||
|
chart: {
|
||||||
|
type: "bar",
|
||||||
|
xKey: "method",
|
||||||
|
series: [{ key: "amount", label: "Amount (ETB)" }],
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ key: "method", label: "Method" },
|
||||||
|
{ key: "payments", label: "Payments", numeric: true },
|
||||||
|
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// --- Record-level list exports (Data domain) — filtered or full dumps ---
|
||||||
|
{
|
||||||
|
key: "bookings-list",
|
||||||
|
title: "Bookings Export",
|
||||||
|
description: "Booking records with customer, lane, cargo, amounts",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["yards", "direction", "freightType", "statuses"],
|
||||||
|
statusOptions: BOOKING_STATUSES,
|
||||||
|
columns: [
|
||||||
|
{ key: "reference", label: "Reference" },
|
||||||
|
{ key: "created", label: "Created" },
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "freight_type", label: "Freight" },
|
||||||
|
{ key: "direction", label: "Direction" },
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "cargo", label: "Cargo" },
|
||||||
|
{ key: "tons", label: "Tonnage", unit: "t" },
|
||||||
|
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||||
|
{ key: "payment_status", label: "Payment" },
|
||||||
|
{ key: "scheduling_status", label: "Scheduling" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "contracts-list",
|
||||||
|
title: "Contracts Export",
|
||||||
|
description: "Contract records with validity, status, customer",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["direction", "statuses"],
|
||||||
|
statusOptions: CONTRACT_STATUSES,
|
||||||
|
columns: [
|
||||||
|
{ key: "reference", label: "Reference" },
|
||||||
|
{ key: "customer", label: "Customer" },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "direction", label: "Direction" },
|
||||||
|
{ key: "freight_type", label: "Freight" },
|
||||||
|
{ key: "valid_from", label: "Valid from" },
|
||||||
|
{ key: "valid_until", label: "Valid until" },
|
||||||
|
{ key: "created", label: "Created" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "schedules-list",
|
||||||
|
title: "Train Schedules Export",
|
||||||
|
description: "Schedule records with planned vs actual times",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["yards", "direction", "statuses"],
|
||||||
|
statusOptions: ["DRAFT", "SCHEDULED", "DISPATCHED", "ARRIVED", "CANCELLED"],
|
||||||
|
columns: [
|
||||||
|
{ key: "train_number", label: "Train" },
|
||||||
|
{ key: "reference", label: "Reference" },
|
||||||
|
{ key: "direction", label: "Direction" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "origin", label: "Origin" },
|
||||||
|
{ key: "destination", label: "Destination" },
|
||||||
|
{ key: "scheduled_departure", label: "Sched. departure" },
|
||||||
|
{ key: "actual_departure", label: "Actual departure" },
|
||||||
|
{ key: "scheduled_arrival", label: "Sched. arrival" },
|
||||||
|
{ key: "actual_arrival", label: "Actual arrival" },
|
||||||
|
{ key: "max_wagons", label: "Max wagons", numeric: true },
|
||||||
|
{ key: "wagon_count", label: "Wagons", numeric: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fleet-wagons",
|
||||||
|
title: "Wagons Export",
|
||||||
|
description: "Wagon fleet with type, capacity, status, location",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["yards", "statuses"],
|
||||||
|
statusOptions: ["AVAILABLE", "ASSIGNED", "MAINTENANCE"],
|
||||||
|
columns: [
|
||||||
|
{ key: "wagon_number", label: "Wagon" },
|
||||||
|
{ key: "type", label: "Type" },
|
||||||
|
{ key: "capacity_tons", label: "Capacity", unit: "t" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "current_yard", label: "Current yard" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "fleet-locomotives",
|
||||||
|
title: "Locomotives Export",
|
||||||
|
description: "Locomotive fleet with type, pull capacity, status",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["yards", "statuses"],
|
||||||
|
statusOptions: ["AVAILABLE", "OUT_OF_SERVICE"],
|
||||||
|
columns: [
|
||||||
|
{ key: "code", label: "Code" },
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "locomotive_type", label: "Type" },
|
||||||
|
{ key: "max_pull_tons", label: "Max pull", unit: "t" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "current_yard", label: "Current yard" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "customers-list",
|
||||||
|
title: "Customers Export",
|
||||||
|
description: "Company records with type, status, TIN",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["statuses"],
|
||||||
|
statusOptions: ["pending", "active"],
|
||||||
|
columns: [
|
||||||
|
{ key: "name", label: "Name" },
|
||||||
|
{ key: "type", label: "Type" },
|
||||||
|
{ key: "kind", label: "Kind" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "tin", label: "TIN" },
|
||||||
|
{ key: "approved", label: "Approved" },
|
||||||
|
{ key: "created", label: "Created" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "payments-list",
|
||||||
|
title: "Payments Export",
|
||||||
|
description: "Payment transactions with method, status, references",
|
||||||
|
domain: "Data",
|
||||||
|
filters: ["direction", "statuses"],
|
||||||
|
statusOptions: [
|
||||||
|
"action-required",
|
||||||
|
"processing",
|
||||||
|
"success",
|
||||||
|
"failed",
|
||||||
|
"canceled",
|
||||||
|
"refunded",
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: "created", label: "Created" },
|
||||||
|
{ key: "method", label: "Method" },
|
||||||
|
{ key: "status", label: "Status" },
|
||||||
|
{ key: "currency", label: "Currency" },
|
||||||
|
{ key: "amount", label: "Amount", unit: "ETB" },
|
||||||
|
{ key: "transaction_id", label: "Transaction" },
|
||||||
|
{ key: "merchant_order_id", label: "Merchant order" },
|
||||||
|
{ key: "paid", label: "Paid" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const REPORT_CONFIG_BY_KEY = new Map(
|
||||||
|
REPORT_CONFIGS.map((c) => [c.key, c]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const REPORT_DOMAINS: ReportDomain[] = [
|
||||||
|
"Commercial",
|
||||||
|
"Operations",
|
||||||
|
"Finance",
|
||||||
|
"Data",
|
||||||
|
];
|
||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
@@ -46,6 +48,10 @@ import {
|
|||||||
directionRowStyle,
|
directionRowStyle,
|
||||||
} from "@/components/trainBuilder/trainStatus";
|
} from "@/components/trainBuilder/trainStatus";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
|
import CreateScheduleWindowFields, {
|
||||||
|
buildWindowRulePayload,
|
||||||
|
type WindowFormState,
|
||||||
|
} from "@/components/trainScheduling/CreateScheduleWindowFields";
|
||||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
|
import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions";
|
||||||
import type {
|
import type {
|
||||||
|
CreateScheduleWindowRulePayload,
|
||||||
FreightType,
|
FreightType,
|
||||||
TrainScheduleListFilters,
|
TrainScheduleListFilters,
|
||||||
TrainScheduleListItem,
|
TrainScheduleListItem,
|
||||||
@@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const [scheduleDate, setScheduleDate] = useState("");
|
const [scheduleDate, setScheduleDate] = useState("");
|
||||||
const [trainId, setTrainId] = useState("");
|
const [trainId, setTrainId] = useState("");
|
||||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||||
|
// Booking window for the schedule being created: off = inherit the live global
|
||||||
|
// rules (the default), on = the values in `windowForm` are frozen onto it.
|
||||||
|
const [configureWindow, setConfigureWindow] = useState(false);
|
||||||
|
const [windowForm, setWindowForm] = useState<WindowFormState | null>(null);
|
||||||
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
// Recomputed each time the create modal opens so a long-lived tab can't keep
|
||||||
// offering a stale "now" as the earliest selectable departure.
|
// offering a stale "now" as the earliest selectable departure.
|
||||||
const minScheduleDate = useMemo(
|
const minScheduleDate = useMemo(
|
||||||
@@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only build the window override when the toggle is on — off means "inherit
|
||||||
|
// the global rules", which the API expresses as an absent windowRule.
|
||||||
|
let windowRule: CreateScheduleWindowRulePayload | undefined;
|
||||||
|
if (configureWindow) {
|
||||||
|
if (!windowForm) {
|
||||||
|
toast({ title: "Booking window settings are still loading", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const built = buildWindowRulePayload(
|
||||||
|
windowForm,
|
||||||
|
selectedRoute?.direction === "EXPORT",
|
||||||
|
);
|
||||||
|
if ("error" in built) {
|
||||||
|
toast({ title: built.error, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
windowRule = built.payload;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await create.mutateAsync({
|
const created = await create.mutateAsync({
|
||||||
payload: {
|
payload: {
|
||||||
@@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||||
trainId,
|
trainId,
|
||||||
reverseWagonOrder,
|
reverseWagonOrder,
|
||||||
|
...(windowRule ? { windowRule } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast({ title: "Train schedule created" });
|
toast({ title: "Train schedule created" });
|
||||||
showScheduleWarnings(created.warnings);
|
showScheduleWarnings(created.warnings);
|
||||||
setReverseWagonOrder(false);
|
setReverseWagonOrder(false);
|
||||||
|
setConfigureWindow(false);
|
||||||
|
setWindowForm(null);
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
checked={reverseWagonOrder}
|
checked={reverseWagonOrder}
|
||||||
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
|
onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
label="Configure booking window for this schedule"
|
||||||
|
description="Off, this train follows the global booking rules. On, the settings below are frozen onto it and a later global-rules change won't move them."
|
||||||
|
checked={configureWindow}
|
||||||
|
onChange={(e) => setConfigureWindow(e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
{configureWindow ? (
|
||||||
|
<CreateScheduleWindowFields
|
||||||
|
isExport={selectedRoute?.direction === "EXPORT"}
|
||||||
|
form={windowForm}
|
||||||
|
onChange={setWindowForm}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
<Button variant="default" onClick={() => setCreateOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ import {
|
|||||||
type SaveLocomotivePayload,
|
type SaveLocomotivePayload,
|
||||||
} from "./locomotives.service";
|
} from "./locomotives.service";
|
||||||
import { overviewService } from "./overview.service";
|
import { overviewService } from "./overview.service";
|
||||||
|
import { reportsService } from "./reports.service";
|
||||||
|
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||||
import {
|
import {
|
||||||
paymentsService,
|
paymentsService,
|
||||||
type PaginatedPayments,
|
type PaginatedPayments,
|
||||||
@@ -2880,4 +2882,13 @@ export const api = {
|
|||||||
({ range }) => overviewService.getDashboard(range),
|
({ range }) => overviewService.getDashboard(range),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reports: {
|
||||||
|
run: endpoint<ReportQueryInput, ReportResult>(
|
||||||
|
"reports",
|
||||||
|
"run",
|
||||||
|
(input) => reportsService.run(input),
|
||||||
|
(input) => ["reports", input.key, input],
|
||||||
|
),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ export const overviewService = {
|
|||||||
return unwrap(response);
|
return unwrap(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
getOperationsTab: async (): Promise<IOverviewOperationsTab> => {
|
getOperationsTab: async (
|
||||||
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS);
|
range?: OverviewRange,
|
||||||
|
): Promise<IOverviewOperationsTab> => {
|
||||||
|
const response = await client.get<IOverviewOperationsTab>(O.OPERATIONS, {
|
||||||
|
params: range ? { range } : undefined,
|
||||||
|
});
|
||||||
return unwrap(response);
|
return unwrap(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ReportQueryInput, ReportResult } from "@/types/reports";
|
||||||
|
|
||||||
|
export const reportsService = {
|
||||||
|
run: async ({ key, ...params }: ReportQueryInput): Promise<ReportResult> => {
|
||||||
|
const response = await client.get<ReportResult>(
|
||||||
|
URL_CONSTANTS.REPORTS.RUN(key),
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { api as client } from "../auth/http";
|
|||||||
import { unwrap } from "@/utils/endpoint";
|
import { unwrap } from "@/utils/endpoint";
|
||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type {
|
import type {
|
||||||
|
AllocationCandidates,
|
||||||
BatchBoardFilters,
|
BatchBoardFilters,
|
||||||
BatchBoardListResponse,
|
BatchBoardListResponse,
|
||||||
BatchBoardScheduleDetail,
|
BatchBoardScheduleDetail,
|
||||||
@@ -319,6 +320,25 @@ export const trainSchedulingService = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAllocationCandidates: async (
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<AllocationCandidates> => {
|
||||||
|
const response = await client.get<AllocationCandidates>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATION_CANDIDATES(bookingId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
allocatePaidBooking: async (
|
||||||
|
bookingId: string,
|
||||||
|
trainScheduleId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.post(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.ALLOCATE_BOOKING(bookingId),
|
||||||
|
{ trainScheduleId },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
getScheduleById: async (
|
getScheduleById: async (
|
||||||
id: string,
|
id: string,
|
||||||
freightType?: FreightType,
|
freightType?: FreightType,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type {
|
|||||||
IOverviewBillingKpis,
|
IOverviewBillingKpis,
|
||||||
IOverviewStaffKpis,
|
IOverviewStaffKpis,
|
||||||
IOverviewTrendPoint,
|
IOverviewTrendPoint,
|
||||||
|
IOverviewDirectionTrendPoint,
|
||||||
|
IOverviewTonnagePoint,
|
||||||
IOverviewStatusCount,
|
IOverviewStatusCount,
|
||||||
IOverviewPipelineCount,
|
IOverviewPipelineCount,
|
||||||
IOverviewPaymentTrendPoint,
|
IOverviewPaymentTrendPoint,
|
||||||
|
|||||||
27
apps/edr-freight-web/backoffice/src/types/reports.ts
Normal file
27
apps/edr-freight-web/backoffice/src/types/reports.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export interface ReportKpi {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface ReportResult {
|
||||||
|
kpis: ReportKpi[];
|
||||||
|
rows: ReportRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query params for GET /reports/:key. List filters are comma-separated. */
|
||||||
|
export interface ReportQueryInput {
|
||||||
|
key: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
granularity?: "day" | "week" | "month";
|
||||||
|
companyIds?: string;
|
||||||
|
routeIds?: string;
|
||||||
|
yardIds?: string;
|
||||||
|
cargoTypeIds?: string;
|
||||||
|
statuses?: string;
|
||||||
|
direction?: string;
|
||||||
|
freightType?: string;
|
||||||
|
}
|
||||||
@@ -103,6 +103,21 @@ export interface BookingWagonShortage {
|
|||||||
wagonsShort: number;
|
wagonsShort: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||||||
|
export interface AllocationCandidate {
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
scheduledDepartureDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllocationCandidates {
|
||||||
|
/** Trains departing on the booking's own scheduled day. */
|
||||||
|
sameDay: AllocationCandidate[];
|
||||||
|
/** Fitting trains on other days — allocating to one notifies the customer. */
|
||||||
|
otherDays: AllocationCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeferredBookingRow {
|
export interface DeferredBookingRow {
|
||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -891,6 +906,23 @@ export interface CreateTrainSchedulePayload {
|
|||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
/** Reverse the wagon order on this train: physically-last wagon becomes position 1. */
|
||||||
reverseWagonOrder?: boolean;
|
reverseWagonOrder?: boolean;
|
||||||
|
/**
|
||||||
|
* Configure the booking window for THIS schedule instead of inheriting the
|
||||||
|
* live global rules. Omit to follow the global rules (the default).
|
||||||
|
*/
|
||||||
|
windowRule?: CreateScheduleWindowRulePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-window rule chosen at creation. Mirrors the per-schedule override plus
|
||||||
|
* the booking-close offset; omitted fields fall back to the global rule.
|
||||||
|
*/
|
||||||
|
export interface CreateScheduleWindowRulePayload
|
||||||
|
extends UpdateScheduleWindowRulePayload {
|
||||||
|
/** Minutes before departure an IMPORT/DOMESTIC window closes; null = at departure. */
|
||||||
|
importCloseOffsetMinutes?: number | null;
|
||||||
|
/** Minutes before departure an EXPORT window closes; null = at departure. */
|
||||||
|
exportCloseOffsetMinutes?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignBookingsPayload {
|
export interface AssignBookingsPayload {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export type OverviewRange = '7d' | '30d' | '90d';
|
export type OverviewRange = '7d' | '30d' | '90d';
|
||||||
|
|
||||||
export interface IOverviewBookingKpis {
|
export interface IOverviewBookingKpis {
|
||||||
|
/** All bookings ever recorded (excluding deleted / GENERAL umbrella rows). */
|
||||||
|
total: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
urgent: number;
|
urgent: number;
|
||||||
@@ -13,6 +15,8 @@ export interface IOverviewOperationsKpis {
|
|||||||
wagonsAvailable: number;
|
wagonsAvailable: number;
|
||||||
containersInTransit: number;
|
containersInTransit: number;
|
||||||
cargoesLoaded: number;
|
cargoesLoaded: number;
|
||||||
|
schedulesUpcoming: number;
|
||||||
|
dispatchedToday: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOverviewCustomerKpis {
|
export interface IOverviewCustomerKpis {
|
||||||
@@ -33,6 +37,8 @@ export interface IOverviewStaffKpis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IOverviewContractKpis {
|
export interface IOverviewContractKpis {
|
||||||
|
/** All contracts ever recorded (excluding deleted). */
|
||||||
|
total: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
needsAction: number;
|
needsAction: number;
|
||||||
inApproval: number;
|
inApproval: number;
|
||||||
@@ -151,8 +157,27 @@ export interface IOverviewBillingTab {
|
|||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scheduled train departures per day, split by trade direction. */
|
||||||
|
export interface IOverviewDirectionTrendPoint {
|
||||||
|
date: string;
|
||||||
|
importCount: number;
|
||||||
|
exportCount: number;
|
||||||
|
domesticCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IOverviewTonnagePoint {
|
||||||
|
label: string;
|
||||||
|
tons: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IOverviewOperationsTab {
|
export interface IOverviewOperationsTab {
|
||||||
kpis: IOverviewOperationsKpis;
|
kpis: IOverviewOperationsKpis;
|
||||||
|
departureTrend: IOverviewDirectionTrendPoint[];
|
||||||
|
scheduleStatusBreakdown: IOverviewStatusCount[];
|
||||||
|
wagonsByType: IOverviewLabelCount[];
|
||||||
|
wagonsByYard: IOverviewLabelCount[];
|
||||||
|
containersBySize: IOverviewLabelCount[];
|
||||||
|
cargoTonnageByType: IOverviewTonnagePoint[];
|
||||||
trainStatusBreakdown: IOverviewStatusCount[];
|
trainStatusBreakdown: IOverviewStatusCount[];
|
||||||
wagonStatusBreakdown: IOverviewStatusCount[];
|
wagonStatusBreakdown: IOverviewStatusCount[];
|
||||||
containerStatusBreakdown: IOverviewStatusCount[];
|
containerStatusBreakdown: IOverviewStatusCount[];
|
||||||
@@ -181,5 +206,6 @@ export type OverviewTabKey =
|
|||||||
| 'contracts'
|
| 'contracts'
|
||||||
| 'billing'
|
| 'billing'
|
||||||
| 'operations'
|
| 'operations'
|
||||||
|
| 'fleet'
|
||||||
| 'customers'
|
| 'customers'
|
||||||
| 'staff';
|
| 'staff';
|
||||||
|
|||||||
Reference in New Issue
Block a user