From e68bdb7a1a9c9268473bddfc28b150d7018168de Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:06:59 +0000 Subject: [PATCH 1/4] Enhance overview and train scheduling features - Updated OverviewContractsTabPanel to include a new donut chart for freight type distribution. - Modified OverviewOperationsTabPanel to improve data visualization with additional charts and refactored data handling. - Introduced CreateScheduleWindowFields component for configuring booking windows in train scheduling. - Added new API endpoints for allocation candidates and booking allocation in trainScheduling.service. - Enhanced BookingRequestsPage to support allocation of paid bookings with a modal for selecting alternative dates. - Updated QUERY_KEYS and URLS constants to accommodate new operations and features. - Improved type definitions for overview and train scheduling to support new functionalities. --- ...00000000000-AddScheduleWindowRuleCustom.ts | 28 ++ .../overview/dto/overview-response.dto.ts | 2 + .../overview/dto/overview-tab-response.dto.ts | 30 ++ .../modules/overview/overview.controller.ts | 6 +- .../src/modules/overview/overview.module.ts | 2 + .../modules/overview/overview.repository.ts | 172 +++++++- .../src/modules/overview/overview.service.ts | 24 +- .../entities/train-schedule.entity.ts | 9 + .../train-scheduling/booking-batch.service.ts | 130 +++++++ .../booking-notifier.service.ts | 13 + .../create-container-train-schedule.dto.ts | 113 ++++++ .../train-scheduling.controller.ts | 26 ++ .../train-scheduling.service.spec.ts | 45 +++ .../train-scheduling.service.ts | 125 ++++-- .../overview/OverviewStackedBarChart.tsx | 82 ++++ .../overview/OverviewTabContent.tsx | 2 +- .../tabs/OverviewBookingsTabPanel.tsx | 24 +- .../tabs/OverviewContractsTabPanel.tsx | 21 +- .../tabs/OverviewOperationsTabPanel.tsx | 135 ++++++- .../CreateScheduleWindowFields.tsx | 366 ++++++++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 3 +- .../backoffice/src/constants/URLS.ts | 4 + .../backoffice/src/hooks/useOverview.ts | 6 +- .../pages/bookings/BookingRequestsPage.tsx | 162 +++++++- .../TrainScheduleV2ListPage.tsx | 49 +++ .../src/services/overview.service.ts | 8 +- .../src/services/trainScheduling.service.ts | 20 + .../backoffice/src/types/overview.ts | 2 + .../backoffice/src/types/trainScheduling.ts | 32 ++ packages/types/src/freight/overview.ts | 21 + 30 files changed, 1580 insertions(+), 82 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3200000000000-AddScheduleWindowRuleCustom.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/OverviewStackedBarChart.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/CreateScheduleWindowFields.tsx 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..981ff35dd 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 @@ -21,6 +21,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..c75510c0e 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, @@ -83,6 +88,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) @@ -146,9 +153,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 +193,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 +216,8 @@ export class OverviewRepository { wagonsAvailable, containersInTransit, cargoesLoaded, + schedulesUpcoming, + dispatchedToday, }; } @@ -533,6 +566,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, 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/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 485a3ceea..95c6aa6f2 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 @@ -842,6 +842,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 406f51988..6a47f8ac8 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 db8236927..f8dfcd6d0 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/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..7452e36cf 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx @@ -36,7 +36,7 @@ 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"); + const operations = useOverviewOperationsTab(range, tab === "operations"); const customers = useOverviewCustomersTab(range, tab === "customers"); const staff = useOverviewStaffTab(range, tab === "staff"); 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..9dce6cc9e 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 @@ -71,7 +71,7 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps - + ({ @@ -81,10 +81,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 +102,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..e35600df9 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 @@ -94,7 +94,7 @@ export function OverviewContractsTabPanel({ - + ({ @@ -104,7 +104,7 @@ export function OverviewContractsTabPanel({ emptyMessage="No contracts yet" /> - + ({ @@ -113,16 +113,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/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 355fb6b57..0fcdef9e6 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, @@ -45,41 +86,95 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP ]} /> + + + + + + + + + + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + /> + + + ({ + label: item.label, + value: item.count, + }))} + valueLabel="Wagons" + emptyMessage="No wagons assigned to yards" + /> + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + ({ + name: item.label, + value: item.count, + }))} + /> + + + ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.trainStatusBreakdown)} /> ({ - name: formatStatusLabel(item.status), - value: item.count, - }))} + data={toDonutData(data.wagonStatusBreakdown)} /> ({ - 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 f7f1f9cbc..9e343017e 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -343,6 +343,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..3fac82442 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/reports/reportConfigs.ts @@ -0,0 +1,295 @@ +export type ReportDomain = "Commercial" | "Operations" | "Finance"; + +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[]; +} + +const BOOKING_STATUSES = [ + "SUBMITTED", + "PENDING_APPROVAL", + "APPROVED", + "INVOICED", + "PAID", + "IN_TRANSIT", + "ARRIVED", + "COMPLETED", + "CANCELLED", + "REJECTED", +]; + +const CONTRACT_STATUSES = [ + "SUBMITTED", + "PENDING_APPROVAL", + "APPROVED", + "CONTRACT_ACTIVE", + "ACTIVE_SHIPMENT_IN_PROGRESS", + "SUSPENDED", + "CONTRACT_CLOSED", + "EXPIRED", + "CANCELLED", +]; + +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" }, + ], + }, +]; + +export const REPORT_CONFIG_BY_KEY = new Map( + REPORT_CONFIGS.map((c) => [c.key, c]), +); + +export const REPORT_DOMAINS: ReportDomain[] = [ + "Commercial", + "Operations", + "Finance", +]; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 9933fbccf..b21ec07a0 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -163,6 +163,8 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; +import { reportsService } from "./reports.service"; +import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { paymentsService, type PaginatedPayments, @@ -2880,4 +2882,13 @@ export const api = { ({ range }) => overviewService.getDashboard(range), ), }, + + reports: { + run: endpoint( + "reports", + "run", + (input) => reportsService.run(input), + (input) => ["reports", input.key, input], + ), + }, }; diff --git a/apps/edr-freight-web/backoffice/src/services/reports.service.ts b/apps/edr-freight-web/backoffice/src/services/reports.service.ts new file mode 100644 index 000000000..6f6995614 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/reports.service.ts @@ -0,0 +1,14 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import { URL_CONSTANTS } from "@/constants/URLS"; +import type { ReportQueryInput, ReportResult } from "@/types/reports"; + +export const reportsService = { + run: async ({ key, ...params }: ReportQueryInput): Promise => { + const response = await client.get( + URL_CONSTANTS.REPORTS.RUN(key), + { params }, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/reports.ts b/apps/edr-freight-web/backoffice/src/types/reports.ts new file mode 100644 index 000000000..72326788c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/reports.ts @@ -0,0 +1,27 @@ +export interface ReportKpi { + label: string; + value: number; + unit?: string; +} + +export type ReportRow = Record; + +export interface ReportResult { + kpis: ReportKpi[]; + rows: ReportRow[]; +} + +/** Query params for GET /reports/:key. List filters are comma-separated. */ +export interface ReportQueryInput { + key: string; + dateFrom?: string; + dateTo?: string; + granularity?: "day" | "week" | "month"; + companyIds?: string; + routeIds?: string; + yardIds?: string; + cargoTypeIds?: string; + statuses?: string; + direction?: string; + freightType?: string; +} diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index 91675b1fc..e3d98bc00 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -1,6 +1,8 @@ export type OverviewRange = '7d' | '30d' | '90d'; export interface IOverviewBookingKpis { + /** All bookings ever recorded (excluding deleted / GENERAL umbrella rows). */ + total: number; totalActive: number; needsAction: number; urgent: number; @@ -35,6 +37,8 @@ export interface IOverviewStaffKpis { } export interface IOverviewContractKpis { + /** All contracts ever recorded (excluding deleted). */ + total: number; totalActive: number; needsAction: number; inApproval: number; @@ -202,5 +206,6 @@ export type OverviewTabKey = | 'contracts' | 'billing' | 'operations' + | 'fleet' | 'customers' | 'staff'; From 5e8eec95393731c2b3614188c431c132bad4d0b1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 3 Aug 2026 21:58:35 +0000 Subject: [PATCH 3/4] add reports module with controller, service, and repository --- .../src/modules/reports/report-queries.ts | 271 ++++++++++++++++-- .../src/modules/reports/reports.service.ts | 9 +- .../src/pages/reports/ReportPage.tsx | 4 +- .../src/pages/reports/reportConfigs.ts | 141 ++++++++- 4 files changed, 392 insertions(+), 33 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/report-queries.ts b/apps/edr-freight-api/src/modules/reports/report-queries.ts index e329c8df6..9e4a6f617 100644 --- a/apps/edr-freight-api/src/modules/reports/report-queries.ts +++ b/apps/edr-freight-api/src/modules/reports/report-queries.ts @@ -1,10 +1,10 @@ import { DataSource } from 'typeorm'; export interface ReportFilters { - /** ISO timestamp, inclusive lower bound. */ - dateFrom: string; - /** ISO timestamp, exclusive upper bound. */ - dateTo: string; + /** 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; @@ -52,7 +52,8 @@ function bookingWhere(f: ReportFilters): { where: string; params: unknown[] } { where: ` b.deleted_at IS NULL AND ${NOT_UMBRELLA} - AND b.created_at >= $1::timestamptz AND b.created_at < $2::timestamptz + 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)) @@ -182,8 +183,9 @@ const contractUtilization: ReportQuery = async (ds, f) => { 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 < $2::timestamptz - AND (ct.contract_valid_until IS NULL OR ct.contract_valid_until >= $1::timestamptz) + 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)) @@ -227,8 +229,8 @@ const trainOnTime: ReportQuery = async (ds, f) => { JOIN freight.yards d ON d.id = ts.destination_station_id WHERE ts.deleted_at IS NULL AND ts.status IN ('DISPATCHED', 'ARRIVED') - AND ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + 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)) @@ -280,8 +282,8 @@ const scheduleFillRate: ReportQuery = async (ds, f) => { 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 ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + 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)) @@ -319,8 +321,8 @@ const tripsPerRoute: ReportQuery = async (ds, f) => { 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 ts.scheduled_departure_date >= $1::timestamptz - AND ts.scheduled_departure_date < $2::timestamptz + 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)) @@ -347,8 +349,8 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { FROM freight.invoices i WHERE i.deleted_at IS NULL AND i.status NOT IN ('DRAFT', 'CANCELLED') - AND COALESCE(i.issued_at, i.created_at) >= $1::timestamptz - AND COALESCE(i.issued_at, i.created_at) < $2::timestamptz + 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`, @@ -371,26 +373,27 @@ const invoicedVsCollected: ReportQuery = async (ds, f) => { }; }; -// Aging is an as-of snapshot: dateTo is the as-of moment, dateFrom is ignored. +// 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 >= $1::timestamptz), 0))::float8 AS current, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - AND i.due_at >= $1::timestamptz - interval '30 days'), 0))::float8 AS overdue_0_30, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '30 days' - AND i.due_at >= $1::timestamptz - interval '60 days'), 0))::float8 AS overdue_31_60, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '60 days' - AND i.due_at >= $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_61_90, - ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < $1::timestamptz - interval '90 days'), 0))::float8 AS overdue_90_plus + 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 i.created_at < $1::timestamptz + 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`, @@ -416,7 +419,8 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { ROUND(SUM(p.amount))::float8 AS amount FROM freight.payments p WHERE p.status = 'success' - AND p.created_at >= $1::timestamptz AND p.created_at < $2::timestamptz + 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], @@ -436,7 +440,222 @@ const revenueByPaymentMethod: ReportQuery = async (ds, f) => { }; }; +// --------------------------------------------------------------------------- +// 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, diff --git a/apps/edr-freight-api/src/modules/reports/reports.service.ts b/apps/edr-freight-api/src/modules/reports/reports.service.ts index 0e5c7d928..04e6e9a60 100644 --- a/apps/edr-freight-api/src/modules/reports/reports.service.ts +++ b/apps/edr-freight-api/src/modules/reports/reports.service.ts @@ -25,12 +25,13 @@ export class ReportsService { if (!(key in REPORT_QUERIES)) { throw new NotFoundException(`Unknown report: ${key}`); } - const to = dto.dateTo ? new Date(dto.dateTo) : new Date(); - const from = dto.dateFrom ? new Date(dto.dateFrom) : new Date(to.getTime() - 30 * DAY_MS); + // 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.toISOString(), + dateFrom: from ? from.toISOString() : null, // dateTo is inclusive in the API; queries treat the bound as exclusive. - dateTo: new Date(to.getTime() + DAY_MS).toISOString(), + dateTo: to ? new Date(to.getTime() + DAY_MS).toISOString() : null, granularity: dto.granularity ?? 'day', companyIds: list(dto.companyIds), routeIds: list(dto.routeIds), diff --git a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx index 0ac68325d..939a6423a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/reports/ReportPage.tsx @@ -305,7 +305,7 @@ export default function ReportPage() { value={toDate(params.get("dateFrom"))} maxDate={toDate(params.get("dateTo")) ?? undefined} onChange={(d) => setParam("dateFrom", toParam(d))} - placeholder="30 days ago" + placeholder="All time" /> setParam("dateTo", toParam(d))} - placeholder="Today" + placeholder="All time" /> {config.filters.includes("granularity") ? (