diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 041ef8ef2..0e7949d7e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -89,6 +89,7 @@ import { CargoesModule } from "./modules/cargoes/cargoes.module"; import { RoutesModule } from "./modules/routes/routes.module"; import { WarehousesModule } from "./modules/warehouses/warehouses.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 { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; @@ -207,6 +208,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar RoutesModule, WarehousesModule, OverviewModule, + ReportsModule, UserTradeAccessModule, VehiclesModule, DriversModule, diff --git a/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts new file mode 100644 index 000000000..821c67135 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts @@ -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 { + 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 { + await queryRunner.query( + `ALTER TABLE "freight"."train_schedules" DROP COLUMN IF EXISTS "window_rule_custom"`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index ab914deff..005dad08a 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; export class OverviewBookingKpisDto { + @ApiProperty() total!: number; @ApiProperty() totalActive!: number; @ApiProperty() needsAction!: number; @ApiProperty() urgent!: number; @@ -9,6 +10,7 @@ export class OverviewBookingKpisDto { } export class OverviewContractKpisDto { + @ApiProperty() total!: number; @ApiProperty() totalActive!: number; @ApiProperty() needsAction!: number; @ApiProperty() inApproval!: number; @@ -21,6 +23,8 @@ export class OverviewOperationsKpisDto { @ApiProperty() wagonsAvailable!: number; @ApiProperty() containersInTransit!: number; @ApiProperty() cargoesLoaded!: number; + @ApiProperty() schedulesUpcoming!: number; + @ApiProperty() dispatchedToday!: number; } export class OverviewCustomerKpisDto { diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index d4e5c2782..7c60a91a6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -104,10 +104,40 @@ export class OverviewBillingTabDto { 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 { @ApiProperty({ type: 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] }) trainStatusBreakdown!: OverviewStatusCountDto[]; diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index 4e1ecc6d6..661350f49 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -99,8 +99,10 @@ export class OverviewController { @BookingView() @ApiOperation({ summary: 'Operations tab metrics and charts' }) @ApiOkResponse({ type: OverviewOperationsTabDto }) - getOperationsTab(): Promise { - return this.overviewService.getOperationsTab(); + getOperationsTab( + @Query() query: OverviewQueryDto, + ): Promise { + return this.overviewService.getOperationsTab(query.range ?? '30d'); } @Get('customers') diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index ff19bb801..a20f75ed2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -9,6 +9,7 @@ import { Container } from "../container-management/entities/container.entity"; import { Company } from "../companies/entities/company.entity"; import { Contract } from "../contracts/entities/contract.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 { Wagon } from "../wagons/entities/wagon.entity"; import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module"; @@ -23,6 +24,7 @@ import { OverviewService } from "./overview.service"; PaymentEntity, Company, Train, + TrainSchedule, Wagon, Container, Cargo, diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index a8957d760..c61320d8c 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -11,7 +11,12 @@ import { Cargo } from "../cargoes/entities/cargoes.entity"; import { Container } from "../container-management/entities/container.entity"; import { Contract } from "../contracts/entities/contract.entity"; import { PaymentEntity } from "../payment/entities/payment.entity"; +import { CargoType } from "../rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../rule-engine/entities/container-type.entity"; +import { Yard } from "../rule-engine/entities/yard.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { Train } from "../trains/entities/train.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, @@ -34,6 +39,7 @@ const EXCLUDE_GENERAL_CONTRACT_BOOKINGS = "(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')"; export type OverviewBookingKpisRow = { + total: number; totalActive: number; needsAction: number; urgent: number; @@ -53,6 +59,7 @@ export type OverviewRecentBookingRow = { }; export type OverviewContractKpisRow = { + total: number; totalActive: number; needsAction: number; inApproval: number; @@ -83,6 +90,8 @@ export class OverviewRepository { private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, + @InjectRepository(TrainSchedule) + private readonly trainScheduleRepository: Repository, @InjectRepository(Wagon) private readonly wagonRepository: Repository, @InjectRepository(Container) @@ -101,7 +110,8 @@ export class OverviewRepository { const scope = directionScopeSql("booking.trade_direction", dirs); const row = await this.bookingRepository .createQueryBuilder("booking") - .select( + .select("COUNT(*)::int", "total") + .addSelect( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, "totalActive", ) @@ -133,6 +143,7 @@ export class OverviewRepository { .getRawOne>(); return { + total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), urgent: Number(row?.urgent ?? 0), @@ -146,9 +157,17 @@ export class OverviewRepository { wagonsAvailable: number; containersInTransit: number; cargoesLoaded: number; + schedulesUpcoming: number; + dispatchedToday: number; }> { - const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = - await Promise.all([ + const [ + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + schedulesUpcoming, + dispatchedToday, + ] = await Promise.all([ this.trainRepository .createQueryBuilder("train") .where("train.deleted_at IS NULL") @@ -178,6 +197,22 @@ export class OverviewRepository { statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Scheduled, + }) + .andWhere("schedule.scheduled_departure_date >= CURRENT_DATE") + .getCount(), + this.trainScheduleRepository + .createQueryBuilder("schedule") + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status = :status", { + status: Freight.TrainScheduleStatus.Dispatched, + }) + .andWhere("schedule.scheduled_departure_date::date = CURRENT_DATE") + .getCount(), ]); return { @@ -185,6 +220,8 @@ export class OverviewRepository { wagonsAvailable, containersInTransit, cargoesLoaded, + schedulesUpcoming, + dispatchedToday, }; } @@ -533,6 +570,141 @@ export class OverviewRepository { return this.statusBreakdown(this.cargoRepository, "cargo"); } + async getScheduleStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainScheduleRepository, "schedule"); + } + + /** Scheduled departures per day over the range, split by trade direction. */ + async getDepartureTrend(days: number): Promise< + { + date: string; + importCount: number; + exportCount: number; + domesticCount: number; + }[] + > { + const rows = await this.trainScheduleRepository + .createQueryBuilder("schedule") + .select( + `to_char(schedule.scheduled_departure_date::date, 'YYYY-MM-DD')`, + "date", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'IMPORT')::int`, + "importCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction = 'EXPORT')::int`, + "exportCount", + ) + .addSelect( + `COUNT(*) FILTER (WHERE schedule.direction NOT IN ('IMPORT', 'EXPORT') OR schedule.direction IS NULL)::int`, + "domesticCount", + ) + .where("schedule.deleted_at IS NULL") + .andWhere("schedule.status != :draft", { + draft: Freight.TrainScheduleStatus.Draft, + }) + .andWhere( + `schedule.scheduled_departure_date >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere( + `schedule.scheduled_departure_date < CURRENT_DATE + :ahead::int`, + { ahead: 8 }, + ) + .groupBy("schedule.scheduled_departure_date::date") + .orderBy("schedule.scheduled_departure_date::date", "ASC") + .getRawMany<{ + date: string; + importCount: string; + exportCount: string; + domesticCount: string; + }>(); + + return rows.map((row) => ({ + date: row.date, + importCount: Number(row.importCount), + exportCount: Number(row.exportCount), + domesticCount: Number(row.domesticCount), + })); + } + + async getWagonsByType(): Promise<{ label: string; count: number }[]> { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .leftJoin(WagonType, "wagon_type", "wagon_type.id = wagon.wagon_type_id") + .select(`COALESCE(wagon_type.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("wagon_type.name") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getWagonsByYard(limit: number): Promise< + { label: string; count: number }[] + > { + const rows = await this.wagonRepository + .createQueryBuilder("wagon") + .innerJoin(Yard, "yard", "yard.id = wagon.current_yard_id") + .select("yard.label", "label") + .addSelect("COUNT(*)::int", "count") + .where("wagon.deleted_at IS NULL") + .groupBy("yard.label") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + async getContainersBySize(): Promise<{ label: string; count: number }[]> { + const rows = await this.containerRepository + .createQueryBuilder("container") + .leftJoin( + ContainerType, + "container_type", + "container_type.id = container.container_type_id", + ) + .select( + `COALESCE(container_type.size_ft::text || ' ft', container_type.code, 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("container.deleted_at IS NULL") + .groupBy("container_type.size_ft") + .addGroupBy("container_type.code") + .orderBy("count", "DESC") + .getRawMany<{ label: string; count: string }>(); + + return rows.map((row) => ({ label: row.label, count: Number(row.count) })); + } + + /** Total cargo weight (tons) grouped by cargo type, heaviest first. */ + async getCargoTonnageByType(limit: number): Promise< + { label: string; tons: number }[] + > { + const rows = await this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(CargoType, "cargo_type", "cargo_type.id = cargo.cargo_type_id") + .select(`COALESCE(cargo_type.cargo_type_name, 'Other')`, "label") + .addSelect(`ROUND(COALESCE(SUM(cargo.weight), 0) / 1000, 1)`, "tons") + .where("cargo.deleted_at IS NULL") + .groupBy("cargo_type.cargo_type_name") + .orderBy("tons", "DESC") + .limit(limit) + .getRawMany<{ label: string; tons: string }>(); + + return rows + .map((row) => ({ label: row.label, tons: Number(row.tons) })) + .filter((row) => row.tons > 0); + } + private async statusBreakdown( repository: Repository, alias: string, @@ -677,7 +849,8 @@ export class OverviewRepository { const scope = directionScopeSql("contract.trade_direction", dirs); const row = await this.contractRepository .createQueryBuilder("contract") - .select( + .select("COUNT(*)::int", "total") + .addSelect( `COUNT(*) FILTER (WHERE contract.status NOT IN (:...closedStatuses) AND contract.status != 'DRAFT')::int`, "totalActive", ) @@ -708,6 +881,7 @@ export class OverviewRepository { .getRawOne>(); return { + total: Number(row?.total ?? 0), totalActive: Number(row?.totalActive ?? 0), needsAction: Number(row?.needsAction ?? 0), inApproval: Number(row?.inApproval ?? 0), diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 4b1f5d6bf..399bf4f72 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -202,15 +202,31 @@ export class OverviewService { }; } - async getOperationsTab(): Promise { + async getOperationsTab( + range: OverviewRangeQuery = '30d', + ): Promise { + const days = OVERVIEW_RANGE_DAYS[range]; + const [ kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, cargoStatusBreakdown, ] = await Promise.all([ 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.getWagonStatusBreakdown(), this.overviewRepository.getContainerStatusBreakdown(), @@ -219,6 +235,12 @@ export class OverviewService { return { kpis, + departureTrend, + scheduleStatusBreakdown, + wagonsByType, + wagonsByYard, + containersBySize, + cargoTonnageByType, trainStatusBreakdown, wagonStatusBreakdown, containerStatusBreakdown, diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts new file mode 100644 index 000000000..82b1a76ef --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-query.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts new file mode 100644 index 000000000..cc1c215af --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/dto/report-result.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts new file mode 100644 index 000000000..9e4a6f617 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -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[]; +} + +type ReportQuery = (ds: DataSource, f: ReportFilters) => Promise; + +// 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[], 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) => 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) => + 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) => 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) => + ['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) => 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) => 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) => 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) => 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) => 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 = { + '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, +}; diff --git a/apps/edr-freight-api/src/modules/reports/reports.controller.ts b/apps/edr-freight-api/src/modules/reports/reports.controller.ts new file mode 100644 index 000000000..bc2671680 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.controller.ts @@ -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 { + const allowed = await this.userTradeAccessService.resolveAllowedDirections(user); + return this.reportsService.run(key, query, allowed); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.module.ts b/apps/edr-freight-api/src/modules/reports/reports.module.ts new file mode 100644 index 000000000..a7fe792a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/reports/reports.repository.ts b/apps/edr-freight-api/src/modules/reports/reports.repository.ts new file mode 100644 index 000000000..65f154b22 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.repository.ts @@ -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 { + return REPORT_QUERIES[key](this.dataSource, filters); + } +} diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts new file mode 100644 index 000000000..04e6e9a60 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 0581f6000..5730120e9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -161,6 +161,15 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) 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 }) ruleImportWindowLeadDays?: number | null; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 44f7c93b5..3e1af07b5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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. */ interface RouteDayGroup { originYardId: string; @@ -3040,6 +3048,104 @@ export class BookingBatchService implements OnModuleInit { 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 { + 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 * calls this every pass; `payment_reminder_sent_at` dedups). Skips paid @@ -3526,6 +3632,16 @@ export class BookingBatchService implements OnModuleInit { } 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): // no webhook arrived, so ask the gateway DIRECTLY whether the money // 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. const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge; if (!overlaps) continue; + const victimPaid = + victim.paymentStatus === "PAID" || victim.status === "PAID"; await this.dataSource.transaction(async (manager) => { await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( scheduleId, victim.id, 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, { status: "EXPIRED", schedulingStatus: "ELIGIBLE", diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index b806de5ca..84d029e4f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -275,6 +275,19 @@ export class BookingNotifierService { 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 * pre-empt). It returns to eligible — the customer must rebook or reschedule. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 9dfea0781..e8716eb99 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -9,9 +9,107 @@ import { IsNumber, IsOptional, IsUUID, + Max, Min, + ValidateNested, } 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 { @ApiProperty({ format: 'uuid' }) @IsUUID() @@ -73,4 +171,19 @@ export class CreateContainerTrainScheduleDto { @IsOptional() @IsBoolean() 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; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index ed1b52318..742bd2837 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -856,6 +856,32 @@ export class TrainSchedulingController { 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") @TrainSchedulingView() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index d2e1268ea..455dc3d9c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -805,6 +805,51 @@ describe('TrainSchedulingService', () => { ).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', () => { const scheduleId = 'sched-unassigned-1'; const trainSetId = 'train-set-unassigned'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index fba09a6bb..44c50e185 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -181,6 +181,13 @@ import { const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; +/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ +function pickDefined(source: T): Partial { + return Object.fromEntries( + Object.entries(source).filter(([, v]) => v !== undefined), + ) as Partial; +} + /** * The booking-window rule fields frozen onto a train schedule at creation (and * refreshed by restampPendingWindows for not-yet-open schedules). The board draws @@ -906,6 +913,9 @@ export class TrainSchedulingService { windowClosesAt: cap(times.windowClosesAt, t.departure), ...ruleFields, 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( @@ -1197,6 +1207,9 @@ export class TrainSchedulingService { let restamped = 0; for (const s of schedules) { 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 = s.direction === 'EXPORT' ? computeExportWindowTimes(s.scheduledDepartureDate, cfg) @@ -1460,29 +1473,8 @@ export class TrainSchedulingService { // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens // 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 // on this origin + destination + EAT departure day, this new train JOINS // its group and adopts the group's shared window timeline (open/close + @@ -1506,6 +1498,77 @@ export class TrainSchedulingService { route.destinationYardId, 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 = direction === 'EXPORT' ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) } @@ -1514,12 +1577,30 @@ export class TrainSchedulingService { ...ruleSnapshot, ...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 = { bookingWindowStatus: 'CLOSED', windowPhase: 'PRE_WINDOW', ...(groupAnchor ? this.groupWindowFieldsFrom(groupAnchor, departure) : 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 // its wagons are allocated. Trains built without wagons yet fall back to diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ceb2a7d06..d4892d198 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -2,6 +2,7 @@ import { ArrowLeftRight, Boxes, Building2, + BarChart3, Container, FileSignature, FileText, @@ -71,6 +72,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; 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 PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; @@ -154,6 +157,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.overview.view, }, + { + label: "Reports", + href: "/dashboard/reports", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, { label: "Customers", href: "/dashboard/customers", @@ -823,6 +832,8 @@ const App = () => { /> }> } /> + } /> + } /> {/* Dev/testing page for the mock AI booking assistant. */} - link.permission.some((key) => hasPermission(user, key)), - ); - if (!visible.length) return null; - - return ( - - Quick links - - {visible.map((link) => { - const Icon = link.icon; - return ( - navigate(link.href)} - > - - - - - - - - {link.title} - - - {link.description} - - - - - - - ); - })} - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx new file mode 100644 index 000000000..be913f5f0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx @@ -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 { + 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({ + title, + data, + series, + xKey = "date", + emptyMessage = "No data available", + formatXLabel, +}: OverviewStackedBarChartProps) { + const hasData = data.some((row) => + series.some((s) => Number((row as Record)[s.key]) > 0), + ); + + return ( + + + {title} + {!hasData ? ( + + {emptyMessage} + + ) : ( + + + + + + formatXLabel(String(v)))} /> + + {series.map((s, index) => ( + + ))} + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx index 13a357cce..cf71198a6 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -14,6 +14,7 @@ import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; +import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel"; import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; @@ -36,7 +37,12 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { const bookings = useOverviewBookingsTab(range, tab === "bookings"); const contracts = useOverviewContractsTab(range, tab === "contracts"); 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 staff = useOverviewStaffTab(range, tab === "staff"); @@ -47,7 +53,7 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { ? contracts : tab === "billing" ? billing - : tab === "operations" + : tab === "operations" || tab === "fleet" ? operations : tab === "customers" ? customers @@ -99,6 +105,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { {tab === "operations" && operations.data && ( )} + {tab === "fleet" && operations.data && ( + + )} {tab === "customers" && customers.data && ( )} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index c294a5199..cacba63e1 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -24,6 +24,13 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - + ({ @@ -81,10 +88,20 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps emptyMessage="No bookings yet" /> - - + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Bookings" + /> + + + ({ name: item.label, value: item.count, }))} @@ -92,14 +109,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - ({ - label: item.label, - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index 9786b11e2..e185f40d2 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -41,6 +41,13 @@ export function OverviewContractsTabPanel({ - + ({ @@ -104,7 +111,7 @@ export function OverviewContractsTabPanel({ emptyMessage="No contracts yet" /> - + ({ @@ -113,16 +120,17 @@ export function OverviewContractsTabPanel({ }))} /> + + ({ + name: item.label === "CONTAINER" ? "Container" : "Bulk", + value: item.count, + }))} + /> + - ({ - label: item.label === "CONTAINER" ? "Container" : "Bulk", - value: item.count, - }))} - /> - ); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewFleetTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewFleetTabPanel.tsx new file mode 100644 index 000000000..5949b93e7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewFleetTabPanel.tsx @@ -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 ( + + + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + /> + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + emptyMessage="No wagons assigned to yards" + /> + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 355fb6b57..675a81b32 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -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 type { IOverviewOperationsTab } from "@/types/overview"; import { OverviewDonutChart } from "../OverviewDonutChart"; +import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart"; import { OverviewKpiStrip } from "../OverviewKpiStrip"; +import { OverviewStackedBarChart } from "../OverviewStackedBarChart"; -interface OverviewOperationsTabPanelProps { - data: IOverviewOperationsTab; -} +/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */ +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) { return status @@ -16,6 +28,22 @@ function formatStatusLabel(status: string) { .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) { return ( @@ -27,6 +55,19 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP icon: Train, 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", value: data.kpis.wagonsAvailable, @@ -46,40 +87,58 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP /> - + + + + ({ - name: formatStatusLabel(item.status), - value: item.count, + title="Schedule status" + data={toDonutData(data.scheduleStatusBreakdown)} + emptyMessage="No train schedules yet" + /> + + + + + + ({ + label: item.label, + value: item.tons, }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" /> ({ - name: formatStatusLabel(item.status), + title="Containers by size" + data={data.containersBySize.map((item) => ({ + name: item.label, value: item.count, }))} /> + + + ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.containerStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.cargoStatusBreakdown)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx new file mode 100644 index 000000000..b0de39baa --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx @@ -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) => { + 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 ( + + + + ); + } + + return ( + + {isExport ? ( + }> + 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. + + ) : ( + }> + 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. + + )} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + }> + 24-hour desk + + ) : ( + }> + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + v != null && set({ windowCloseHour: Number(v) })} + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + /> + + {isOvernight && !is24h ? ( + + Overnight desk — opens {form.windowOpenHour}:00 and runs past midnight, + closing {form.windowCloseHour}:00 the next morning. + + ) : null} + + set({ + // On → close == open (24h desk). Off → restore a normal ~9h day. + windowCloseHour: e.currentTarget.checked + ? form.windowOpenHour + : Math.min(23, form.windowOpenHour + 9), + }) + } + /> + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + set({ windowDurationHours: v })} + min={0.0166} + disabled={isExport} + /> + + set({ docReviewMinutes: v })} + min={0} + disabled={isExport} + /> + set({ paymentWindowMinutes: v })} + min={1} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + {isExport ? ( + set({ exportBookingLeadHours: v === "" ? "" : Number(v) })} + min={1} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + ) : ( + set({ importWindowLeadDays: v === "" ? "" : Number(v) })} + min={0} + clampBehavior="none" + allowNegative={false} + allowDecimal={false} + /> + )} + + + + {/* ── Booking close offset ─────────────────────────────────────── */} + + + Booking close offset + + + 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. + + set({ closeOffsetMinutes: v })} + min={0} + /> + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 6e95a27fc..f6df662d5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -184,7 +184,8 @@ export const QUERY_KEYS = { ["overview", "contracts", range ?? "30d"] as const, billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const, - operationsTab: () => ["overview", "operations"] as const, + operationsTab: (range?: string) => + ["overview", "operations", range ?? "30d"] as const, customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 94cdcf07c..0e3facead 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -109,6 +109,10 @@ export const URL_CONSTANTS = { BY_USER_ID: (id: string) => `/api/customers/user/${id}`, }, + REPORTS: { + RUN: (key: string) => `/reports/${key}`, + }, + OVERVIEW: { BASE: "/overview", BOOKINGS: "/overview/bookings", @@ -343,6 +347,10 @@ export const URL_CONSTANTS = { `/train-scheduling/bookings/${bookingId}/expire`, MOVE_BOOKING_SCHEDULE: (bookingId: string) => `/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", BOOKING_WINDOWS: "/train-scheduling/booking-windows", PREVIEW: "/train-scheduling/preview", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index 023d3d9f1..f46fe865f 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -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({ - queryKey: QUERY_KEYS.OVERVIEW.operationsTab(), - queryFn: () => overviewService.getOperationsTab(), + queryKey: QUERY_KEYS.OVERVIEW.operationsTab(range), + queryFn: () => overviewService.getOperationsTab(range), enabled, }); } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 0f8cc87ea..91eb52033 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -4,7 +4,9 @@ import { Box, Button, Card, + Checkbox, Group, + Modal, MultiSelect, Select, Stack, @@ -50,7 +52,10 @@ import { import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; 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 { useToast } from "@/hooks/use-toast"; import { Badge, DataTable, @@ -155,6 +160,15 @@ export default function BookingRequestsPage() { const [scheduledTo, setScheduledTo] = useState(null); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); + // 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(null); + const [otherDayModal, setOtherDayModal] = useState<{ + booking: BookingListRow; + candidates: AllocationCandidate[]; + } | null>(null); + const { toast } = useToast(); const suppressRowClickRef = useRef(false); const suppressRowClick = useCallback(() => { suppressRowClickRef.current = true; @@ -187,6 +201,10 @@ export default function BookingRequestsPage() { ...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}), + // Wins over the payment-status select — the queue is by definition PAID. + ...(paidUnallocated + ? { paymentStatus: "PAID", assignedToSchedule: "false" as const } + : {}), ...(ownershipFilter ? { isGovernment: ownershipFilter as "true" | "false" } : {}), @@ -208,6 +226,7 @@ export default function BookingRequestsPage() { directionFilter, freightTypeFilter, paymentStatusFilter, + paidUnallocated, ownershipFilter, originYardFilter, destinationYardFilter, @@ -251,6 +270,7 @@ export default function BookingRequestsPage() { (directionFilter ? 1 : 0) + (freightTypeFilter ? 1 : 0) + (paymentStatusFilter ? 1 : 0) + + (paidUnallocated ? 1 : 0) + (ownershipFilter ? 1 : 0) + (originYardFilter ? 1 : 0) + (destinationYardFilter ? 1 : 0) + @@ -263,6 +283,7 @@ export default function BookingRequestsPage() { setDirectionFilter(null); setFreightTypeFilter(null); setPaymentStatusFilter(null); + setPaidUnallocated(false); setOwnershipFilter(null); setOriginYardFilter(null); setDestinationYardFilter(null); @@ -301,6 +322,67 @@ export default function BookingRequestsPage() { [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[] = [ { id: "booking", @@ -435,13 +517,33 @@ export default function BookingRequestsPage() { { id: "actions", size: 140, - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + const b = row.original; + const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId; + return ( + + {needsAllocation ? ( + + ) : null} + + + ); + }, }, ]; @@ -640,6 +742,16 @@ export default function BookingRequestsPage() { radius="lg" style={{ minWidth: 180 }} /> + { + setPaidUnallocated(e.currentTarget.checked); + resetPage(); + }} + radius="sm" + style={{ alignSelf: "center" }} + /> setParam("granularity", v)} + allowDeselect={false} + /> + ) : null} + {config.filters.includes("yards") ? ( + ({ + 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") ? ( + setParam("freightType", v)} + placeholder="All" + /> + ) : null} + {config.filters.includes("statuses") && config.statusOptions ? ( + setParam("statuses", v.length ? v.join(",") : null)} + placeholder="Default (active)" + /> + ) : null} + + + + + ({ + label: k.label, + value: k.value.toLocaleString(), + hint: k.unit, + }))} + /> + + + + 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 }) => ( + + )} + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx new file mode 100644 index 000000000..f8f6f161d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportsHubPage.tsx @@ -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 ( + navigate(`/dashboard/reports/${config.key}`)} + > + +
+ + {config.title} + + + {config.description} + +
+ { + e.stopPropagation(); + onToggleFavorite(); + }} + > + + +
+ + {config.domain} + +
+ ); +} + +export default function ReportsHubPage() { + const [search, setSearch] = useState(""); + const [favorites, setFavorites] = useState(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[]) => ( + + {configs.map((c) => ( + toggleFavorite(c.key)} + /> + ))} + + ); + + return ( + + } + placeholder="Search reports…" + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + } + /> + + {pinned.length ? ( + + Favorites + {renderGrid(pinned)} + + ) : null} + + {REPORT_DOMAINS.map((domain) => { + const configs = visible.filter((c) => c.domain === domain); + if (!configs.length) return null; + return ( + + {domain} + {renderGrid(configs)} + + ); + })} + + {visible.length === 0 ? ( + + No reports match “{search}” + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts new file mode 100644 index 000000000..9aec4fec0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -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", +]; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 946359647..3c2244f68 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -6,12 +6,14 @@ import { Button, Card, Checkbox, + Divider, Group, Menu, Modal, Select, SimpleGrid, Stack, + Switch, Text, TextInput, ThemeIcon, @@ -46,6 +48,10 @@ import { directionRowStyle, } from "@/components/trainBuilder/trainStatus"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; +import CreateScheduleWindowFields, { + buildWindowRulePayload, + type WindowFormState, +} from "@/components/trainScheduling/CreateScheduleWindowFields"; import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal"; import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions"; import { @@ -59,6 +65,7 @@ import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/auth/useAuth"; import { canCreateSchedule, canUpdateSchedule } from "@/lib/permissions"; import type { + CreateScheduleWindowRulePayload, FreightType, TrainScheduleListFilters, TrainScheduleListItem, @@ -133,6 +140,10 @@ export default function TrainScheduleV2ListPage() { const [scheduleDate, setScheduleDate] = useState(""); const [trainId, setTrainId] = useState(""); 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(null); // Recomputed each time the create modal opens so a long-lived tab can't keep // offering a stale "now" as the earliest selectable departure. const minScheduleDate = useMemo( @@ -535,6 +546,25 @@ export default function TrainScheduleV2ListPage() { }); 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 { const created = await create.mutateAsync({ payload: { @@ -542,11 +572,14 @@ export default function TrainScheduleV2ListPage() { scheduleDate: new Date(scheduleDate).toISOString(), trainId, reverseWagonOrder, + ...(windowRule ? { windowRule } : {}), }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); setReverseWagonOrder(false); + setConfigureWindow(false); + setWindowForm(null); setCreateOpen(false); navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`); } catch (err) { @@ -843,6 +876,22 @@ export default function TrainScheduleV2ListPage() { checked={reverseWagonOrder} onChange={(e) => setReverseWagonOrder(e.currentTarget.checked)} /> + + + + setConfigureWindow(e.currentTarget.checked)} + /> + {configureWindow ? ( + + ) : null}