From b73bf2154eab9f6da79a9118f243658bdf0b094e Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 13 Jun 2026 18:28:39 +0000 Subject: [PATCH] add detail batch and allocation monitoring page --- ...000005-CreateTrainCompositionRemovalLog.ts | 81 +++ .../train-composition-removal-log.entity.ts | 22 + ...rain-composition-removal-log.repository.ts | 18 + .../train-schedules/train-schedules.module.ts | 5 + .../dto/update-container-item.dto.ts | 7 + .../train-scheduling.controller.ts | 42 +- .../train-scheduling.service.spec.ts | 1 + .../train-scheduling.service.ts | 118 +++- .../src/components/fleet/useFleetViewMode.ts | 2 +- .../AssignedBookingsPanel.tsx | 169 ++++++ .../compositionEditor/BatchBookingList.tsx | 132 +++++ .../compositionEditor/BookingDetailModal.tsx | 218 ++++++++ .../CompositionBookingTabs.tsx | 263 +++++++++ .../ContainerNumberInput.tsx | 89 +++ .../InteractiveTrainConsist.tsx | 527 ++++++++++++++++++ .../compositionEditor/RemovalLogPanel.tsx | 73 +++ .../RemoveBookingConfirmModal.tsx | 148 +++++ .../compositionEditor/RemoveBookingModal.tsx | 74 +++ .../compositionEditor/TrainConsistView.tsx | 220 ++++++++ .../compositionEditor/TrainStatsBar.tsx | 134 +++++ .../UnassignedBookingsPanel.tsx | 218 ++++++++ .../compositionEditor/WagonCard.tsx | 188 +++++++ .../compositionEditor/index.ts | 13 + .../backoffice/src/constants/QUERY_KEYS.ts | 4 + .../backoffice/src/constants/URLS.ts | 8 + .../trainScheduling/useTrainScheduling.ts | 49 ++ .../pages/trainScheduling/BatchBoardPage.tsx | 446 +++++++++++++-- .../BatchScheduleDetailPage.tsx | 80 ++- .../src/services/trainScheduling.service.ts | 39 ++ .../backoffice/src/types/trainScheduling.ts | 20 + 30 files changed, 3336 insertions(+), 72 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts diff --git a/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts new file mode 100644 index 000000000..51dcbb514 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1781000000005-CreateTrainCompositionRemovalLog.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'train_composition_removal_logs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'schedule_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'booking_reference', + type: 'varchar', + length: '64', + isNullable: true, + }, + { + name: 'removed_by_user_id', + type: 'uuid', + isNullable: true, + }, + { + name: 'removed_at', + type: 'timestamptz', + default: () => 'NOW()', + isNullable: false, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: () => 'NOW()', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamptz', + default: () => 'NOW()', + isNullable: false, + }, + { + name: 'deleted_at', + type: 'timestamptz', + isNullable: true, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.train_composition_removal_logs', + new TableIndex({ + columnNames: ['schedule_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.train_composition_removal_logs', true); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts new file mode 100644 index 000000000..b32b42148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-composition-removal-log.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +@Entity({ schema: 'freight', name: 'train_composition_removal_logs' }) +@Index(['scheduleId']) +export class TrainCompositionRemovalLog extends BaseEntity { + @Column({ name: 'schedule_id', type: 'uuid' }) scheduleId!: string; + + @Column({ name: 'booking_id', type: 'uuid' }) bookingId!: string; + + @Column({ name: 'booking_reference', type: 'varchar', length: 64, nullable: true }) + bookingReference?: string | null; + + @Column({ name: 'removed_by_user_id', type: 'uuid', nullable: true }) + removedByUserId?: string | null; + + @Column({ name: 'removed_at', type: 'timestamptz', default: () => 'NOW()' }) + removedAt!: Date; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts new file mode 100644 index 000000000..c8e0d6053 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-composition-removal-log.repository.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; + +@Injectable() +export class TrainCompositionRemovalLogRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(dataSource.getRepository(TrainCompositionRemovalLog)); + } + + async findByScheduleId(scheduleId: string): Promise { + return this.findAll({ + where: { scheduleId }, + order: { removedAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts index f9ce84f7d..bb7b41d90 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; import { TrainSchedule } from './entities/train-schedule.entity'; +import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity'; import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; import { TrainSchedulesRepository } from './train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository'; import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; @@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r TypeOrmModule.forFeature([ TrainSchedule, TrainScheduleBooking, + TrainCompositionRemovalLog, WagonBookingAllocation, WagonAllocationContainerItem, WagonAllocationBulkLoad, @@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r providers: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, WagonBookingAllocationsRepository, WagonAllocationContainerItemsRepository, WagonAllocationBulkLoadsRepository, @@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r exports: [ TrainSchedulesRepository, TrainScheduleBookingsRepository, + TrainCompositionRemovalLogRepository, WagonBookingAllocationsRepository, WagonAllocationContainerItemsRepository, WagonAllocationBulkLoadsRepository, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts new file mode 100644 index 000000000..37710e003 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-container-item.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateContainerItemDto { + @IsString() + @IsOptional() + containerNumber?: string | null; +} 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 8056f4cdc..57f4a80e8 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 @@ -9,7 +9,10 @@ import { Post, Query, } from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { AuthUserPayload } from '../../common/resolve-auth-user-id'; +import { resolveAuthUserId } from '../../common/resolve-auth-user-id'; import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; @@ -18,6 +21,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -176,8 +180,44 @@ export class TrainSchedulingController { unassignBooking( @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, ) { - return this.trainSchedulingService.unassignBooking(id, bookingId); + return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user)); + } + + @Delete('schedules/:id/wagons/:trainSetWagonId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Remove an empty wagon slot from a train' }) + removeWagonSlot( + @Param('id', ParseUUIDPipe) id: string, + @Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string, + ) { + return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId); + } + + @Patch('schedules/:id/container-items/:itemId') + @TrainSchedulingManage() + @ApiOperation({ summary: 'Update a container number on a wagon slot' }) + updateContainerItem( + @Param('id', ParseUUIDPipe) id: string, + @Param('itemId', ParseUUIDPipe) itemId: string, + @Body() dto: UpdateContainerItemDto, + ) { + return this.trainSchedulingService.updateContainerItem(id, itemId, dto); + } + + @Get('schedules/:id/unassigned-bookings') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get unassigned bookings for a schedule' }) + getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getUnassignedBookings(id); + } + + @Get('schedules/:id/composition-removals') + @TrainSchedulingView() + @ApiOperation({ summary: 'Get removal log for a schedule' }) + getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getCompositionRemovals(id); } @Post('schedules/:id/pin-wagons') 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 9127cbc61..b4a3341ef 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 @@ -144,6 +144,7 @@ describe('TrainSchedulingService', () => { wagonAllocationContainerItemsRepository as never, wagonAllocationBulkLoadsRepository as never, trainCheckpointEventsRepository as never, + {} as never, // trainCompositionRemovalLogRepository ); const defaultFleetWagons = [ 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 6b4004cfb..b1bc9c671 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 @@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository'; import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; @@ -41,6 +43,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -143,6 +146,7 @@ export class TrainSchedulingService { private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository, private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository, private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository, + private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository, private readonly configService?: ConfigService, ) {} @@ -467,7 +471,7 @@ export class TrainSchedulingService { return { ...detail, warnings, deferredBookings }; } - async unassignBooking(scheduleId: string, bookingId: string) { + async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -481,6 +485,9 @@ export class TrainSchedulingService { throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`); } + const booking = await this.bookingsRepository.findById(bookingId); + const bookingReference = booking?.reference ?? null; + await this.dataSource.transaction(async (manager) => { const allocationIds = (schedule.trainSet?.wagons ?? []) .flatMap((w) => w.allocations ?? []) @@ -529,6 +536,18 @@ export class TrainSchedulingService { } }); + await this.trainCompositionRemovalLogRepository.create({ + scheduleId, + bookingId, + bookingReference, + removedByUserId: userId ?? null, + removedAt: new Date(), + }); + + console.log( + `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`, + ); + return this.getTrainScheduleById(scheduleId); } @@ -2236,6 +2255,103 @@ export class TrainSchedulingService { return result; } + async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule'); + } + + const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId); + if (!wagon) { + throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`); + } + + if ((wagon.allocations ?? []).length > 0) { + throw new BadRequestException( + 'Cannot remove a wagon slot that has active allocations; remove the booking first', + ); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSetWagon).delete(trainSetWagonId); + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + wagonCount: Math.max(0, (schedule.trainSet?.wagonCount ?? 0) - 1), + totalLengthMeters: Math.max(0, (schedule.trainSet?.totalLengthMeters ?? 0) - (wagon.lengthMeters ?? 0)), + }); + }); + + return this.getTrainScheduleById(scheduleId); + } + + async updateContainerItem( + scheduleId: string, + itemId: string, + dto: UpdateContainerItemDto, + ): Promise<{ id: string; containerNumber: string | null }> { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status === 'DISPATCHED') { + throw new BadRequestException('Cannot edit a dispatched schedule'); + } + + const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({ + where: { id: itemId }, + relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'], + }); + + if (!item) { + throw new NotFoundException(`Container item ${itemId} not found`); + } + + const wagonId = item.wagonBookingAllocationId; + const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({ + where: { id: wagonId }, + relations: ['trainSetWagon'], + }); + + if (!wagonAllocation?.trainSetWagon) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + const trainSetWagonId = wagonAllocation.trainSetWagon.id; + const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id); + if (!wagonIds.includes(trainSetWagonId)) { + throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`); + } + + await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, { + containerNumber: dto.containerNumber ?? null, + }); + + return { id: itemId, containerNumber: dto.containerNumber ?? null }; + } + + async getUnassignedBookings(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const allBookings = await this.bookingsRepository.findAll({ + where: { trainScheduleId: scheduleId }, + select: ['id', 'reference', 'freightType', 'priorityScore', 'cargoTotalWeightVgm', 'status', 'schedulingStatus'], + }); + + const allocatedBookingIds = await this.getWagonAssignedBookingIds(scheduleId); + + const unassigned = allBookings.filter((b: any) => !allocatedBookingIds.has(b.id)); + return unassigned.sort((a: any, b: any) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0)); + } + + async getCompositionRemovals(scheduleId: string): Promise { + return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId); + } + private async getWagonAssignedBookingIds(scheduleId: string): Promise> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id); diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts b/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts index 244e03841..9dd571271 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts +++ b/apps/edr-freight-web/backoffice/src/components/fleet/useFleetViewMode.ts @@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards"; const STORAGE_PREFIX = "edr-freight-fleet-view:"; -type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2"; +type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board"; const readStored = (slug: ViewModeSlug): FleetViewMode => { try { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx new file mode 100644 index 000000000..126701b0b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/AssignedBookingsPanel.tsx @@ -0,0 +1,169 @@ +import { useState } from "react"; +import { ActionIcon, Badge, Box, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { Building2, Package, TrainFront, Weight, X } from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import type { BookingDetailData } from "./BookingDetailModal"; +import { RemoveBookingConfirmModal, type RemovalTarget } from "./RemoveBookingConfirmModal"; +import { useScheduleMutations } from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; +import { freightBrand } from "@/theme/freight-brand"; + +interface AssignedBookingsPanelProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; +} + +export const AssignedBookingsPanel = ({ + scheduleDetail, + scheduleId, + selectedBookingId, + onSelect, +}: AssignedBookingsPanelProps) => { + const { toast } = useToast(); + const unassign = useScheduleMutations(scheduleId).unassign; + const isDispatched = scheduleDetail.status === "DISPATCHED"; + const [removalTarget, setRemovalTarget] = useState(null); + + const wagons = scheduleDetail.trainSet?.wagons ?? []; + const wagonCountByBooking = new Map(); + for (const w of wagons) { + for (const a of w.allocations ?? []) { + wagonCountByBooking.set(a.bookingId, (wagonCountByBooking.get(a.bookingId) ?? 0) + 1); + } + } + + const assignedBookings = (scheduleDetail.bookings ?? []).filter((b) => + wagonCountByBooking.has(b.id), + ); + + const handleConfirmRemove = async () => { + if (!removalTarget) return; + try { + await unassign.mutateAsync({ id: scheduleId, bookingId: removalTarget.bookingId }); + toast({ title: "Booking removed from train" }); + setRemovalTarget(null); + } catch { + toast({ title: "Could not remove booking", variant: "destructive" }); + } + }; + + if (assignedBookings.length === 0) { + return ( + + + + + + No assigned bookings + + + Assign a paid booking from the Unassigned tab to load it onto a wagon. + + + ); + } + + return ( + <> + + {assignedBookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: booking.customer, + freightType: scheduleDetail.freightType ?? null, + weightTons: booking.weightTons ?? null, + status: booking.status, + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? freightBrand.primary : undefined, + boxShadow: isActive ? `0 0 0 2px ${freightBrand.ring}` : undefined, + background: isActive ? freightBrand.mutedBg : undefined, + transition: "box-shadow 120ms ease", + }} + > + + + + + + + + {booking.reference} + + + } + > + {wagonCountByBooking.get(booking.id)} + + {!isDispatched ? ( + + { + e.stopPropagation(); + setRemovalTarget({ + bookingId: booking.id, + reference: booking.reference, + company: booking.customer, + weightTons: booking.weightTons ?? null, + wagonCount: wagonCountByBooking.get(booking.id) ?? 0, + }); + }} + > + + + + ) : null} + + + {booking.customer ? ( + + + + {booking.customer} + + + ) : null} + + + + {(booking.weightTons ?? 0).toFixed(1)} T + + + + + + ); + })} + + + setRemovalTarget(null)} + onConfirm={handleConfirmRemove} + isLoading={unassign.isPending} + target={removalTarget} + /> + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx new file mode 100644 index 000000000..ab2b13a20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BatchBookingList.tsx @@ -0,0 +1,132 @@ +import { Badge, Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Building2, CreditCard, Landmark, Weight, XCircle } from "lucide-react"; +import type { BatchBoardBookingDetail } from "@/types/trainScheduling"; +import type { BookingDetailData } from "./BookingDetailModal"; + +interface BatchBookingListProps { + bookings: BatchBoardBookingDetail[]; + variant: "payment" | "expired"; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; + emptyTitle: string; + emptyHint: string; +} + +const fmtDateTime = (iso: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(new Date(iso)) + : null; + +export const BatchBookingList = ({ + bookings, + variant, + selectedBookingId, + onSelect, + emptyTitle, + emptyHint, +}: BatchBookingListProps) => { + const accent = variant === "payment" ? "orange" : "red"; + const Icon = variant === "payment" ? CreditCard : XCircle; + + if (bookings.length === 0) { + return ( + + + + + + {emptyTitle} + + + {emptyHint} + + + ); + } + + return ( + + {bookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + const deadline = fmtDateTime(booking.paymentDeadline); + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: booking.company, + freightType: null, + weightTons: booking.weightTons ?? null, + status: variant === "payment" ? "Awaiting payment" : "Expired", + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? `var(--mantine-color-${accent}-5)` : undefined, + }} + > + + + + + + + + {booking.reference} + + {booking.isGovernment ? ( + } + > + Gov + + ) : null} + + {booking.company ? ( + + + + {booking.company} + + + ) : null} + + + + + {(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w + + + {variant === "payment" && deadline ? ( + + Pay by {deadline} + + ) : variant === "expired" ? ( + + Expired + + ) : null} + + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx new file mode 100644 index 000000000..e1fd4977d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/BookingDetailModal.tsx @@ -0,0 +1,218 @@ +import { Badge, Box, Divider, Group, Modal, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + MapPin, + Package, + TrainFront, + Weight, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +export interface BookingDetailData { + bookingId: string; + reference: string | null; + company: string | null; + freightType: string | null; + weightTons: number | null; + status: string | null; + priorityScore?: number | null; +} + +interface BookingDetailModalProps { + opened: boolean; + onClose: () => void; + booking: BookingDetailData | null; + /** All wagons in the consist — used to show where this booking sits. */ + wagons: Wagon[]; +} + +function InfoRow({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: React.ReactNode; +}) { + return ( + + + + {icon} + + + {label} + + + {value} + + ); +} + +export const BookingDetailModal = ({ + opened, + onClose, + booking, + wagons, +}: BookingDetailModalProps) => { + if (!booking) return null; + + const bookingWagons = wagons.filter((w) => + (w.allocations ?? []).some((a) => a.bookingId === booking.bookingId), + ); + const allocations = bookingWagons.flatMap((w) => + (w.allocations ?? []) + .filter((a) => a.bookingId === booking.bookingId) + .map((a) => ({ wagon: w, allocation: a })), + ); + const containers = allocations.flatMap(({ allocation }) => allocation.containerItems ?? []); + const isBulk = allocations.some(({ allocation }) => + (allocation.loadType ?? "").toUpperCase().includes("BULK"), + ); + + return ( + + + + +
+ {booking.reference ?? "Booking"} + + Booking details + +
+ + } + > + + + {booking.company ? ( + } + label="Company" + value={ + + {booking.company} + + } + /> + ) : null} + : } + label="Freight type" + value={ + + {booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")} + + } + /> + } + label="Weight" + value={ + + {booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"} + + } + /> + } + label="Wagons" + value={ + bookingWagons.length ? ( + + {bookingWagons.map((w) => ( + + #{w.sequenceNo} + + ))} + + ) : ( + + Not assigned to a wagon + + ) + } + /> + {booking.status ? ( + } + label="Status" + value={ + + {booking.status} + + } + /> + ) : null} + + + {containers.length ? ( + <> + + + + Containers ({containers.length}) + + + } + /> + + {containers.map((c, i) => ( + + + + + {c.containerNumber?.trim() || `Container ${i + 1}`} + + + {c.grossWeightTons != null ? ( + + {Number(c.grossWeightTons).toFixed(1)} T + + ) : null} + + ))} + + + ) : null} + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx new file mode 100644 index 000000000..b040c338a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/CompositionBookingTabs.tsx @@ -0,0 +1,263 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Group, Paper, ScrollArea, Tabs, Text, Tooltip } from "@mantine/core"; +import { CreditCard, History, Layers, PackageCheck, PackagePlus, XCircle } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { BatchBoardBookingDetail, TrainScheduleDetail } from "@/types/trainScheduling"; +import { AssignedBookingsPanel } from "./AssignedBookingsPanel"; +import { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; +import { RemovalLogPanel } from "./RemovalLogPanel"; +import { BatchBookingList } from "./BatchBookingList"; +import { BookingDetailModal, type BookingDetailData } from "./BookingDetailModal"; +import { + useCompositionRemovals, + useUnassignedBookings, +} from "@/hooks/trainScheduling/useTrainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +interface CompositionBookingTabsProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + /** Bookings selected for batch with a payment notification sent (awaiting payment). */ + awaitingPayment?: BatchBoardBookingDetail[]; + /** Bookings whose payment window expired. */ + expired?: BatchBoardBookingDetail[]; + /** Booking id highlighted in the train consist (lifted to the page). */ + selectedBookingId?: string | null; + onSelectBooking?: (bookingId: string | null) => void; +} + +type TabKey = "assigned" | "unassigned" | "payment" | "expired" | "removed"; + +const TAB_META: Record = { + assigned: { label: "Assigned to train", icon: PackageCheck, color: "green" }, + unassigned: { label: "Unassigned (ready to load)", icon: PackagePlus, color: "orange" }, + payment: { label: "Awaiting payment", icon: CreditCard, color: "orange" }, + expired: { label: "Expired bookings", icon: XCircle, color: "red" }, + removed: { label: "Removed from train", icon: History, color: "gray" }, +}; + +export const CompositionBookingTabs = ({ + scheduleDetail, + scheduleId, + awaitingPayment = [], + expired = [], + selectedBookingId, + onSelectBooking, +}: CompositionBookingTabsProps) => { + const [detailBooking, setDetailBooking] = useState(null); + const [tab, setTab] = useState("assigned"); + + const unassignedQuery = useUnassignedBookings(scheduleId); + const removalsQuery = useCompositionRemovals(scheduleId); + + const { assignedCount, freeWagons, freeWeightTons } = useMemo(() => { + const wagons = scheduleDetail.trainSet?.wagons ?? []; + const ids = new Set(); + let usedWeight = 0; + let empty = 0; + for (const w of wagons) { + const allocs = w.allocations ?? []; + if (allocs.length === 0) empty += 1; + for (const a of allocs) { + ids.add(a.bookingId); + usedWeight += a.allocatedWeightTons ?? 0; + } + } + const maxWeight = scheduleDetail.trainSet?.locomotive?.maxPullWeightTons ?? null; + return { + assignedCount: ids.size, + freeWagons: empty, + freeWeightTons: maxWeight != null ? Math.max(0, maxWeight - usedWeight) : null, + }; + }, [scheduleDetail.trainSet?.wagons, scheduleDetail.trainSet?.locomotive?.maxPullWeightTons]); + + const counts: Record = { + assigned: assignedCount, + unassigned: unassignedQuery.data?.length ?? 0, + payment: awaitingPayment.length, + expired: expired.length, + removed: removalsQuery.data?.length ?? 0, + }; + + const handleSelect = (booking: BookingDetailData) => { + setDetailBooking(booking); + onSelectBooking?.(booking.bookingId); + }; + + const TabButton = ({ value }: { value: TabKey }) => { + const meta = TAB_META[value]; + const Icon = meta.icon; + const active = tab === value; + const count = counts[value]; + return ( + + + + + + {count} + + + + + ); + }; + + return ( + <> + + {/* Header — reflects the active tab */} + + + + + +
+ + {TAB_META[tab].label} + + + {counts[tab]} booking{counts[tab] === 1 ? "" : "s"} + +
+
+
+ + v && setTab(v as TabKey)} + variant="default" + color="green" + style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Footer summary */} + + + + + {assignedCount} on train + + + + + + {counts.payment} to pay + + + + + + {counts.expired} expired + + + +
+ + setDetailBooking(null)} + booking={detailBooking} + wagons={scheduleDetail.trainSet?.wagons ?? []} + /> + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx new file mode 100644 index 000000000..5629ba698 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/ContainerNumberInput.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Group, TextInput, Text } from "@mantine/core"; +import { useUpdateContainerItem } from "@/hooks/trainScheduling/useTrainScheduling"; + +interface ContainerNumberInputProps { + value: string | null; + itemId: string; + scheduleId: string; + disabled: boolean; +} + +export const ContainerNumberInput = ({ + value, + itemId, + scheduleId, + disabled, +}: ContainerNumberInputProps) => { + const [isEditing, setIsEditing] = useState(false); + const [inputValue, setInputValue] = useState(value ?? ""); + const [error, setError] = useState(null); + + const updateMutation = useUpdateContainerItem(scheduleId); + const isLoading = updateMutation.isPending; + + const handleSave = async () => { + try { + setError(null); + await updateMutation.mutateAsync({ + itemId, + containerNumber: inputValue || null, + }); + setIsEditing(false); + } catch (err) { + setError("Failed to save"); + setInputValue(value ?? ""); + } + }; + + const handleBlur = () => { + if (inputValue !== value) { + handleSave(); + } else { + setIsEditing(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + setInputValue(value ?? ""); + setIsEditing(false); + } + }; + + if (disabled) { + return {value || "TBD"}; + } + + if (isEditing) { + return ( + + setInputValue(e.currentTarget.value)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + autoFocus + disabled={isLoading} + placeholder="Container #" + style={{ flex: 1 }} + /> + {error && {error}} + + ); + } + + return ( + setIsEditing(true)} + style={{ cursor: "pointer", textDecoration: "underline" }} + title="Click to edit" + > + {value || "TBD"} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx new file mode 100644 index 000000000..297ecc48b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -0,0 +1,527 @@ +import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + Gauge, + Package, + TrainFront, + Weight, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; +type Locomotive = NonNullable["locomotive"]; + +interface InteractiveTrainConsistProps { + wagons: Wagon[]; + locomotive: Locomotive | null | undefined; + /** Resolve the customer/company name for a booking id (joined from schedule bookings). */ + getCompany: (bookingId: string | undefined) => string | null; + selectedWagonId: string | null; + onSelectWagon: (wagon: Wagon) => void; + /** Booking id to highlight across the train (e.g. selected in the side panel). */ + highlightBookingId?: string | null; +} + +const CONTAINER_GRADIENTS = [ + "linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))", + "linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))", +]; +const CONTAINER_BORDERS = ["var(--mantine-color-cyan-8)", "var(--mantine-color-blue-8)"]; + +function Wheels({ count = 2, dark = false }: { count?: number; dark?: boolean }) { + return ( + 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}> + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} + +function Coupler() { + return ( + + + + ); +} + +function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) { + const code = locomotive?.code ?? "LOCO"; + return ( + + + {/* cab windows */} + + + + {/* headlight */} + + {/* hazard stripe */} + + + + + {code} + + + {locomotive?.maxPullWeightTons ? ( + + + + {locomotive.maxPullWeightTons}T pull + + + ) : null} + + + + HEAD + + + ); +} + +function WagonCar({ + wagon, + company, + selected, + highlighted, + onSelect, +}: { + wagon: Wagon; + company: string | null; + selected: boolean; + highlighted: boolean; + onSelect: () => void; +}) { + const allocation = wagon.allocations?.[0]; + const isEmpty = !allocation; + const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); + const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0; + const capacity = wagon.capacityTons ?? 0; + const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0; + const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan"; + const accentVar = `var(--mantine-color-${accent}-6)`; + + const containerNumbers = (allocation?.containerItems ?? []).map( + (c) => c.containerNumber?.trim() || "—", + ); + const blocks = containerNumbers.slice(0, 2); + + const ringColor = selected + ? freightBrand.primary + : highlighted + ? "var(--mantine-color-yellow-5)" + : "transparent"; + + return ( + + + + + {/* top accent strip */} + + {/* header */} + + + #{wagon.sequenceNo} + + {isEmpty ? ( + + EMPTY + + ) : ( + + {isBulk ? ( + + ) : ( + + )} + + {isBulk ? "BULK" : "CONT"} + + + )} + + + {/* body */} + + {isEmpty ? ( + + Available + + ) : isBulk ? ( + + + + + + ) : ( + + {(blocks.length ? blocks : ["—"]).map((cn, i) => ( + + + {cn} + + + ))} + + )} + + + {/* footer */} + + + + {wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"} + + {!isEmpty ? ( + + {assigned}T + + ) : null} + + + + + + + + + + + + + + +
+ + Wagon #{wagon.sequenceNo} + + + {wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"} + +
+
+ {!isEmpty ? ( + + {isBulk ? "Bulk" : "Container"} + + ) : null} +
+ + {isEmpty ? ( + + Empty slot — available for allocation. + + ) : ( + + {company ? ( + + + + {company} + + + ) : null} + + + + {allocation?.bookingReference ?? "Unknown booking"} + + + + {containerNumbers.length ? ( +
+ + Containers + + + {containerNumbers.map((cn, i) => ( + + {cn} + + ))} + +
+ ) : null} + + {isBulk && allocation?.bulkLoad?.cargoDescription ? ( + + {allocation.bulkLoad.cargoDescription} + + ) : null} + + + + + {assigned}T / {capacity}T ({utilization}%) + + + + = 100 + ? "var(--mantine-color-red-5)" + : `var(--mantine-color-${accent}-5)`, + }} + /> + + + Click the wagon to edit or remove + +
+ )} +
+
+
+ ); +} + +export const InteractiveTrainConsist = ({ + wagons, + locomotive, + getCompany, + selectedWagonId, + onSelectWagon, + highlightBookingId, +}: InteractiveTrainConsistProps) => { + return ( + + + {locomotive ? : null} + {wagons.length === 0 ? ( + + No wagons assigned + + ) : ( + wagons.map((wagon, i) => { + const bookingId = wagon.allocations?.[0]?.bookingId; + return ( + + {i > 0 || locomotive ? : null} + onSelectWagon(wagon)} + /> + + ); + }) + )} + + + {/* track bed under the whole consist */} + + + + + + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx new file mode 100644 index 000000000..05f2a8328 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemovalLogPanel.tsx @@ -0,0 +1,73 @@ +import { Box, Card, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { History, PackageX } from "lucide-react"; +import { useCompositionRemovals } from "@/hooks/trainScheduling/useTrainScheduling"; + +interface RemovalLogPanelProps { + scheduleId: string; +} + +export const RemovalLogPanel = ({ scheduleId }: RemovalLogPanelProps) => { + const removalQuery = useCompositionRemovals(scheduleId); + + if (removalQuery.isLoading) { + return ( + + Loading... + + ); + } + + const removals = removalQuery.data ?? []; + + if (removals.length === 0) { + return ( + + + + + + No removals yet + + + Bookings removed from this train will appear here for audit. + + + ); + } + + return ( + + {removals.map((removal) => ( + + + + + + + + {removal.bookingReference || "Unknown booking"} + + + Removed{" "} + {new Date(removal.removedAt).toLocaleString("en-GB", { + timeZone: "Africa/Addis_Ababa", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + })}{" "} + EAT + + {removal.notes ? ( + + {removal.notes} + + ) : null} + + + + ))} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx new file mode 100644 index 000000000..03301154b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingConfirmModal.tsx @@ -0,0 +1,148 @@ +import { Badge, Box, Button, Group, List, Modal, Stack, Text, ThemeIcon } from "@mantine/core"; +import { AlertTriangle, Bell, Building2, FileClock, PackageX, TrainFront, Undo2, Weight } from "lucide-react"; + +export interface RemovalTarget { + bookingId: string; + reference: string | null; + company: string | null; + weightTons: number | null; + wagonCount: number; +} + +interface RemoveBookingConfirmModalProps { + opened: boolean; + onClose: () => void; + onConfirm: () => void; + isLoading: boolean; + target: RemovalTarget | null; +} + +export const RemoveBookingConfirmModal = ({ + opened, + onClose, + onConfirm, + isLoading, + target, +}: RemoveBookingConfirmModalProps) => { + return ( + + + + +
+ Remove booking from train? + + This change is logged and the customer is notified + +
+ + } + > + + {/* Booking summary */} + + + + {target?.reference ?? "Booking"} + + }> + {target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"} + + + + {target?.company ? ( + + + + {target.company} + + + ) : null} + + + + {(target?.weightTons ?? 0).toFixed(1)} T + + + + + + {/* What happens */} + + + + + Removing this booking will: + + + + + + + } + > + Return it to the unassigned pool + + + + + } + > + Create a removal log entry for audit + + + + + } + > + Notify the customer to reschedule or cancel + + + + + + + + + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx new file mode 100644 index 000000000..151a796d8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/RemoveBookingModal.tsx @@ -0,0 +1,74 @@ +import { Button, Group, Modal, Stack, Text, Badge } from "@mantine/core"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; + +type WagonWithAllocation = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface RemoveBookingModalProps { + opened: boolean; + onClose: () => void; + wagon: WagonWithAllocation | null; + onConfirm: () => void; + isLoading: boolean; +} + +export const RemoveBookingModal = ({ + opened, + onClose, + wagon, + onConfirm, + isLoading, +}: RemoveBookingModalProps) => { + if (!wagon || !wagon.allocations?.[0]) return null; + + const allocation = wagon.allocations[0]; + const booking = allocation.booking; + + return ( + + +
+ + Booking Details + + + + Reference: {booking?.reference || "N/A"} + + + Freight Type:{" "} + + {booking?.freightType || "N/A"} + + + + Weight: {allocation.allocatedWeightTons?.toFixed(2) || 0} T + + + Wagon Slot: #{wagon.sequenceNo} + + +
+ +
+ + ⚠️ Warning: Removing this booking will: + +
    +
  • Move the booking back to the unassigned pool
  • +
  • Create a removal log for audit
  • +
  • Notify the customer to reschedule or cancel
  • +
+
+ + + + + +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx new file mode 100644 index 000000000..49898cbcd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainConsistView.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; +import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core"; +import { MousePointerClick, TrainFront } from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { TrainStatsBar } from "./TrainStatsBar"; +import { WagonCard } from "./WagonCard"; +import { InteractiveTrainConsist } from "./InteractiveTrainConsist"; +import { RemoveBookingModal } from "./RemoveBookingModal"; +import { useScheduleMutations, useRemoveWagonSlot } from "@/hooks/trainScheduling/useTrainScheduling"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface TrainConsistViewProps { + scheduleDetail: TrainScheduleDetail; + scheduleId: string; + maxWagons: number; + /** Booking id selected in the side panel — highlights its wagons in the consist. */ + highlightBookingId?: string | null; +} + +function LegendDot({ color, label, dashed }: { color: string; label: string; dashed?: boolean }) { + return ( + + + + {label} + + + ); +} + +export const TrainConsistView = ({ + scheduleDetail, + scheduleId, + maxWagons, + highlightBookingId, +}: TrainConsistViewProps) => { + const [selectedWagonId, setSelectedWagonId] = useState(null); + const [removeModalOpen, setRemoveModalOpen] = useState(false); + + const unassignMutation = useScheduleMutations(scheduleId).unassign; + const removeWagonMutation = useRemoveWagonSlot(scheduleId); + + const trainSet = scheduleDetail.trainSet; + const wagons = trainSet?.wagons ?? []; + + // Join company/customer name from schedule bookings by booking id. + const companyByBooking = useMemo(() => { + const map = new Map(); + for (const b of scheduleDetail.bookings ?? []) { + if (b.id && b.customer) map.set(b.id, b.customer); + } + return map; + }, [scheduleDetail.bookings]); + + const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null; + const loadedCount = wagons.filter((w) => (w.allocations?.length ?? 0) > 0).length; + + const handleRemoveBooking = (wagon: Wagon) => { + setSelectedWagonId(wagon.id); + setRemoveModalOpen(true); + }; + + const handleConfirmRemoveBooking = async () => { + if (selectedWagon?.allocations?.[0]?.bookingId) { + await unassignMutation.mutateAsync({ + id: scheduleId, + bookingId: selectedWagon.allocations[0].bookingId, + }); + setRemoveModalOpen(false); + setSelectedWagonId(null); + } + }; + + const handleRemoveWagon = async (wagonId: string) => { + if (confirm("Are you sure you want to remove this wagon slot?")) { + await removeWagonMutation.mutateAsync(wagonId); + setSelectedWagonId(null); + } + }; + + const weightUsed = wagons.reduce( + (sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0), + 0, + ); + const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0); + + return ( + + + + {/* Consist panel */} + + + + + + +
+ + Train consist + + + {wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty + +
+
+ + + + + +
+ + + (bookingId ? companyByBooking.get(bookingId) ?? null : null)} + selectedWagonId={selectedWagonId} + onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))} + highlightBookingId={highlightBookingId} + /> + +
+ + {/* Selected wagon — editable detail card */} + {selectedWagon ? ( + + + + Editing wagon #{selectedWagon.sequenceNo} + + + Update container numbers or remove the booking + + + + + ) : wagons.length ? ( + + + + + + + Click a wagon in the train to edit container numbers or remove its booking. + + + + ) : null} + + { + setRemoveModalOpen(false); + }} + wagon={selectedWagon} + onConfirm={handleConfirmRemoveBooking} + isLoading={unassignMutation.isPending} + /> +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx new file mode 100644 index 000000000..8ec1c7f4e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/TrainStatsBar.tsx @@ -0,0 +1,134 @@ +import { Box, Group, Paper, RingProgress, SimpleGrid, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Ruler, Train, Weight } from "lucide-react"; +import { freightBrand } from "@/theme/freight-brand"; + +interface TrainStatsBarProps { + weightUsed: number; + weightMax: number | null; + lengthUsed: number; + lengthMax: number | null; + wagonCount: number; + wagonMax: number; +} + +function pctColor(pct: number) { + if (pct >= 100) return "#fa5252"; + if (pct >= 85) return "#FB8C2E"; + return freightBrand.primary; +} + +function StatTile({ + icon, + label, + pct, + current, + max, + unit, +}: { + icon: React.ReactNode; + label: string; + pct: number | null; + current: string; + max: string; + unit: string; +}) { + const color = pct != null ? pctColor(pct) : freightBrand.primary; + const clamped = pct != null ? Math.min(100, Math.max(0, pct)) : 0; + return ( + + + + {icon} + + + } + /> + + + {label} + + + + {current} + + + / {max} {unit} + + + {pct != null ? ( + + {Math.round(pct)}% utilized + + ) : ( + + no limit set + + )} + + + ); +} + +export const TrainStatsBar = ({ + weightUsed, + weightMax, + lengthUsed, + lengthMax, + wagonCount, + wagonMax, +}: TrainStatsBarProps) => { + const weightPct = weightMax ? (weightUsed / weightMax) * 100 : null; + const lengthPct = lengthMax ? (lengthUsed / lengthMax) * 100 : null; + const wagonPct = wagonMax ? (wagonCount / wagonMax) * 100 : null; + + return ( + + + } + label="Weight" + pct={weightPct} + current={weightUsed.toFixed(1)} + max={weightMax?.toFixed(1) ?? "∞"} + unit="T" + /> + + } + label="Length" + pct={lengthPct} + current={lengthUsed.toFixed(1)} + max={lengthMax?.toFixed(1) ?? "∞"} + unit="m" + /> + + } + label="Wagons" + pct={wagonPct} + current={String(wagonCount)} + max={String(wagonMax)} + unit="" + /> + + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx new file mode 100644 index 000000000..69585d075 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/UnassignedBookingsPanel.tsx @@ -0,0 +1,218 @@ +import { Badge, Box, Button, Card, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { AlertTriangle, Container as ContainerIcon, Plus, TrainFront, Weight } from "lucide-react"; +import { + useUnassignedBookings, + useScheduleMutations, +} from "@/hooks/trainScheduling/useTrainScheduling"; +import { useToast } from "@/hooks/use-toast"; +import type { BookingDetailData } from "./BookingDetailModal"; + +interface UnassignedBookingsPanelProps { + scheduleId: string; + selectedBookingId?: string | null; + onSelect: (booking: BookingDetailData) => void; + /** Empty wagon slots currently available on the train. */ + freeWagons: number; + /** Remaining pull-weight headroom in tons, or null when no locomotive limit. */ + freeWeightTons: number | null; +} + +const parseError = (error: unknown): string | null => { + if (error && typeof error === "object" && "response" in error) { + const resp = (error as { response?: { data?: { message?: unknown } } }).response; + const msg = resp?.data?.message; + if (Array.isArray(msg)) return msg.join(", "); + if (typeof msg === "string") return msg; + } + return null; +}; + +export const UnassignedBookingsPanel = ({ + scheduleId, + selectedBookingId, + onSelect, + freeWagons, + freeWeightTons, +}: UnassignedBookingsPanelProps) => { + const { toast } = useToast(); + const unassignedQuery = useUnassignedBookings(scheduleId); + const assignMutation = useScheduleMutations(scheduleId).assign; + + const handleAssign = async (bookingId: string, reference: string | null) => { + try { + await assignMutation.mutateAsync({ + id: scheduleId, + payload: { bookingIds: [bookingId] }, + }); + toast({ title: `Assigned ${reference ?? "booking"} to the train` }); + } catch (err) { + toast({ + title: "Could not assign booking", + description: + parseError(err) ?? "No free wagon or not enough space for this booking.", + variant: "destructive", + }); + } + }; + + if (unassignedQuery.isLoading) { + return ( + + Loading... + + ); + } + + const bookings = unassignedQuery.data ?? []; + + if (bookings.length === 0) { + return ( + + + + + + No unassigned bookings + + + Paid bookings waiting for a wagon will appear here. + + + ); + } + + const noFreeWagon = freeWagons <= 0; + + return ( + + {/* Capacity availability banner */} + + + + + {freeWagons} free wagon{freeWagons === 1 ? "" : "s"} + + + {freeWeightTons != null ? ( + + + + {freeWeightTons.toFixed(1)} T headroom + + + ) : null} + + + {bookings.map((booking) => { + const isActive = selectedBookingId === booking.id; + const weight = booking.cargoTotalWeightVgm ?? 0; + const overWeight = freeWeightTons != null && weight > freeWeightTons; + const fits = !noFreeWagon && !overWeight; + const blockReason = noFreeWagon + ? "No free wagon on this train" + : overWeight + ? "Exceeds remaining weight headroom" + : null; + + return ( + + onSelect({ + bookingId: booking.id, + reference: booking.reference, + company: null, + freightType: booking.freightType, + weightTons: booking.cargoTotalWeightVgm ?? null, + status: booking.status, + priorityScore: booking.priorityScore, + }) + } + style={{ + cursor: "pointer", + borderColor: isActive ? "var(--mantine-color-green-5)" : undefined, + }} + > + + + + + + + + + {booking.reference} + + {booking.priorityScore ? ( + + P{booking.priorityScore} + + ) : null} + + + + {booking.freightType} + + + + + {weight.toFixed(1)} T + + + + + + + {blockReason ? ( + + + + {blockReason} + + + ) : null} + + + + + + + ); + })} + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx new file mode 100644 index 000000000..bda0b1356 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/WagonCard.tsx @@ -0,0 +1,188 @@ +import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core"; +import { + Building2, + Container as ContainerIcon, + Fuel, + Package, + TrainFront, + Trash2, + X, +} from "lucide-react"; +import type { TrainScheduleDetail } from "@/types/trainScheduling"; +import { ContainerNumberInput } from "./ContainerNumberInput"; +import { freightBrand } from "@/theme/freight-brand"; + +type Wagon = TrainScheduleDetail["trainSet"]["wagons"][number]; + +interface WagonCardProps { + wagon: Wagon; + company?: string | null; + scheduleId: string; + scheduleStatus?: string; + onRemoveBooking: (wagon: Wagon) => void; + onRemoveWagon: (wagonId: string) => void; +} + +export const WagonCard = ({ + wagon, + company, + scheduleId, + scheduleStatus, + onRemoveBooking, + onRemoveWagon, +}: WagonCardProps) => { + const isDispatched = scheduleStatus === "DISPATCHED"; + const allocation = wagon.allocations?.[0]; + const hasAllocations = Boolean(allocation); + const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK"); + + const weightUsed = allocation?.allocatedWeightTons ?? 0; + const weightMax = wagon.capacityTons ?? 0; + const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0; + + const wagonType = wagon.wagonType?.code || "UNKNOWN"; + + return ( + + + + + + + +
+ + + Wagon #{wagon.sequenceNo} + + + {wagonType} + + + {wagon.physicalWagonNumber || wagon.physicalWagonId ? ( + + {wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)} + + ) : null} +
+
+ {hasAllocations ? ( + : } + > + {isBulk ? "Bulk" : "Container"} + + ) : null} +
+
+ + + {hasAllocations && allocation ? ( + <> + {company ? ( + + + + {company} + + + ) : null} + + + + + {allocation.bookingReference || "Unknown booking"} + + + + {allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? ( + + + Containers + + + {allocation.containerItems.map((item, idx) => ( + + + + #{idx + 1} + + + + ))} + + + ) : null} + + {isBulk ? ( + + + + {allocation.bulkLoad?.cargoDescription || "Bulk load"} + + + ) : null} + + + + + Weight + + + {weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T + + + 90 ? "red" : weightPercent > 75 ? "orange" : "green"} + size="sm" + radius="xl" + /> + + + {!isDispatched ? ( + + ) : null} + + ) : ( + + + + + + Empty slot + + {!isDispatched ? ( + + ) : null} + + )} + +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts new file mode 100644 index 000000000..c0cf7bc96 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/index.ts @@ -0,0 +1,13 @@ +export { TrainStatsBar } from "./TrainStatsBar"; +export { ContainerNumberInput } from "./ContainerNumberInput"; +export { RemoveBookingModal } from "./RemoveBookingModal"; +export { WagonCard } from "./WagonCard"; +export { TrainConsistView } from "./TrainConsistView"; +export { InteractiveTrainConsist } from "./InteractiveTrainConsist"; +export { BookingDetailModal } from "./BookingDetailModal"; +export { BatchBookingList } from "./BatchBookingList"; +export { RemoveBookingConfirmModal } from "./RemoveBookingConfirmModal"; +export { AssignedBookingsPanel } from "./AssignedBookingsPanel"; +export { UnassignedBookingsPanel } from "./UnassignedBookingsPanel"; +export { RemovalLogPanel } from "./RemovalLogPanel"; +export { CompositionBookingTabs } from "./CompositionBookingTabs"; 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 35f566519..e3e1921de 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -52,6 +52,10 @@ export const QUERY_KEYS = { batchBoard: () => ["train-scheduling", "batch-board"] as const, batchBoardDetail: (scheduleId: string) => ["train-scheduling", "batch-board", scheduleId] as const, + unassignedBookings: (id: string) => + ["train-scheduling", "unassigned", id] as const, + compositionRemovals: (id: string) => + ["train-scheduling", "removals", id] as const, }, FLEET: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 5dd820edb..73060c123 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -190,6 +190,14 @@ export const URL_CONSTANTS = { SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`, CANCEL_SCHEDULE: (id: string) => `/train-scheduling/container/schedules/${id}/cancel`, + REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) => + `/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`, + UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) => + `/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`, + UNASSIGNED_BOOKINGS: (scheduleId: string) => + `/train-scheduling/schedules/${scheduleId}/unassigned-bookings`, + COMPOSITION_REMOVALS: (scheduleId: string) => + `/train-scheduling/schedules/${scheduleId}/composition-removals`, }, RULE_ENGINE: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts index 8ea385316..6d2bdc292 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/trainScheduling/useTrainScheduling.ts @@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId), }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId), + }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId), + }); } void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT }); }; @@ -244,3 +250,46 @@ export const useScheduleMutations = (scheduleId?: string) => { invalidate, }; }; + +export const useUnassignedBookings = (scheduleId: string | undefined) => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""), + queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!), + enabled: Boolean(scheduleId), + }); + +export const useCompositionRemovals = (scheduleId: string | undefined) => + useQuery({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""), + queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!), + enabled: Boolean(scheduleId), + }); + +export const useRemoveWagonSlot = (scheduleId: string) => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (wagonId: string) => + trainSchedulingService.removeWagonSlot(scheduleId, wagonId), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), + }); + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId), + }); + }, + }); +}; + +export const useUpdateContainerItem = (scheduleId: string) => { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) => + trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId), + }); + }, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx index 0dec57af5..4ee2a7d96 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchBoardPage.tsx @@ -1,13 +1,15 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Alert, Box, Button, + Card, Container, Group, Paper, RingProgress, + Select, SimpleGrid, Skeleton, Stack, @@ -17,17 +19,21 @@ import { import { AlertTriangle, ArrowRight, + CalendarClock, CalendarDays, Inbox, Package, - RefreshCw, Ruler, Train, TrainFront, Weight, } from "lucide-react"; +import type { ColumnDef } from "@edr/ui-common"; +import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import FleetToolbar from "@/components/fleet/FleetToolbar"; +import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { BookingPipeline, HeroChip, @@ -35,6 +41,7 @@ import { WindowStatusPill, } from "@/components/trainScheduling/batchVisuals"; import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals"; +import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand"; import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling"; import type { BatchBoardSchedule } from "@/types/trainScheduling"; @@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) => }).format(new Date(iso)) + " EAT" : "No date"; +const splitDate = (iso: string | null) => { + if (!iso) return { day: "—", time: "" }; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return { day: "—", time: "" }; + return { + day: new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "Africa/Addis_Ababa", + }).format(date), + time: + new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "Africa/Addis_Ababa", + }).format(date) + " EAT", + }; +}; + /** Capacity ring color: gold normally, red once over capacity. */ function ringColor(pct: number) { if (pct >= 100) return "#fa5252"; @@ -109,18 +137,56 @@ function CapacityRing({ ); } +/** Small percent chip used in the table's capacity column. */ +function CapacityChip({ + icon: Icon, + pct, + text, +}: { + icon: typeof Weight; + pct: number | null; + text: string; +}) { + const over = pct != null && pct >= 100; + return ( + + + + {pct != null ? `${Math.round(pct)}%` : "—"} + + + {text} + + + ); +} + +function weightPctOf(s: BatchBoardSchedule) { + return s.capacity.maxWeightTons && s.capacity.maxWeightTons > 0 + ? (s.capacity.usedWeightTons / s.capacity.maxWeightTons) * 100 + : null; +} +function lengthPctOf(s: BatchBoardSchedule) { + return s.capacity.maxLengthMeters && s.capacity.maxLengthMeters > 0 + ? (s.capacity.allocatedLengthMeters / s.capacity.maxLengthMeters) * 100 + : null; +} + function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) { const navigate = useNavigate(); const { capacity, counts, locomotive } = schedule; - const lengthPct = - capacity.maxLengthMeters && capacity.maxLengthMeters > 0 - ? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100 - : null; - const weightPct = - capacity.maxWeightTons && capacity.maxWeightTons > 0 - ? (capacity.usedWeightTons / capacity.maxWeightTons) * 100 - : null; + const lengthPct = lengthPctOf(schedule); + const weightPct = weightPctOf(schedule); const totalBookings = totalBookingCount(counts); @@ -298,7 +364,13 @@ function CardSkeleton() { } export default function BatchBoardPage() { + const navigate = useNavigate(); const { data, isLoading, isFetching, refetch } = useBatchBoard(); + const { viewMode, setViewMode } = useFleetViewMode("batch-board"); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [search, setSearch] = useState(""); + const [windowFilter, setWindowFilter] = useState("ALL"); + const schedules = data ?? []; const summary = useMemo(() => { @@ -308,22 +380,199 @@ export default function BatchBoardPage() { return { openWindows, totalBookings, totalWagons }; }, [schedules]); + const filtered = useMemo(() => { + const query = search.trim().toLowerCase(); + return schedules.filter((s) => { + if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false; + if (!query) return true; + const haystack = [ + s.trainNumber, + s.routeName, + s.origin, + s.destination, + s.locomotive?.code, + s.status, + s.bookingWindowStatus, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); + }, [schedules, search, windowFilter]); + + const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize)); + const paged = useMemo(() => { + const start = pagination.pageIndex * pagination.pageSize; + return filtered.slice(start, start + pagination.pageSize); + }, [filtered, pagination]); + + const columns = useMemo((): ColumnDef[] => { + const headerClassName = ruleEngineTable.headerCell; + const cellClassName = ruleEngineTable.bodyCell; + return [ + { + id: "train", + header: "Train / Route", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + + + + + + {row.original.trainNumber ?? row.original.routeName ?? "Schedule"} + + + + + + + ), + }, + { + id: "date", + header: "Departure", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const { day, time } = splitDate(row.original.scheduleDate); + return ( + + + + + + + {day} + + + {time || "—"} + + + + ); + }, + }, + { + id: "window", + header: "Window", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => , + }, + { + id: "loco", + header: "Locomotive", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => + row.original.locomotive ? ( + + + + + {row.original.locomotive.code} + + + {fmtTons(row.original.locomotive.maxPullWeightTons)} pull + + + + ) : ( + + No loco + + ), + }, + { + id: "capacity", + header: "Capacity", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => ( + + + + + + + {row.original.capacity.allocatedWagons} + + + wgn + + + + ), + }, + { + id: "bookings", + header: "Bookings", + meta: { headerClassName, cellClassName }, + cell: ({ row }) => { + const total = totalBookingCount(row.original.counts); + return ( + + + {total} booking{total === 1 ? "" : "s"} + + + + ); + }, + }, + { + id: "actions", + header: "", + meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, + cell: ({ row }) => ( + + + + ), + }, + ]; + }, [navigate]); + + const tableStatus = isLoading ? "loading" : "success"; + return ( - - - - - {isLoading ? ( - - - - - - ) : schedules.length === 0 ? ( - - - + + + v && setWindowFilter(v)} + data={[ + { value: "ALL", label: "All windows" }, + { value: "OPEN", label: "Open" }, + { value: "FULL", label: "Full" }, + { value: "CLOSED", label: "Closed" }, + ]} + w={150} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> + } + /> + + + {viewMode === "table" ? ( + - - - - No active schedules - - - Schedules with an open booking window appear here as cards. Create or activate - a schedule to get started. - - - - ) : ( - - {schedules.map((s) => ( - - ))} - - )} + tableOptions={{ + manualPagination: true, + pageCount, + state: { pagination }, + onPaginationChange: setPagination, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={({ table, pagination: footerPagination }) => ( + + )} + /> + ) : isLoading ? ( + + + + + + ) : filtered.length === 0 ? ( + + + + + + + No active schedules + + + Schedules with an open booking window appear here. Create or activate a + schedule to get started. + + + + ) : ( + + {filtered.map((s) => ( + + ))} + + )} + + + + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index d3f753057..311f76124 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -15,6 +15,7 @@ import { SimpleGrid, Stack, Table, + Tabs, Text, ThemeIcon, Title, @@ -44,6 +45,7 @@ import type { LucideIcon } from "lucide-react"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram"; +import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor"; import { BookingPipeline, HeroChip, @@ -491,10 +493,20 @@ export default function BatchScheduleDetailPage() { [data], ); - const scheduleDetailQuery = useScheduleDetail( - hasAssignedWagons ? scheduleId : undefined, - "CONTAINER", - ); + const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER"); + + // Batch bookings by state for the composition side panel (payment / expired lists). + const batchBookings = useMemo(() => { + if (!data) return { awaitingPayment: [], expired: [] }; + const all = [ + ...data.windows.flatMap((w) => w.bookings), + ...data.pendingContract.bookings, + ]; + return { + awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"), + expired: all.filter((b) => b.state === "EXPIRED"), + }; + }, [data]); // Group the flat window list into per-day sections (one per EAT calendar date). const dayGroups = useMemo(() => { @@ -568,6 +580,8 @@ export default function BatchScheduleDetailPage() { // Date-stepper: which day is currently shown. Default to today, else the first // day with bookings, else the first day. Keep the selection if still valid. const [selectedDate, setSelectedDate] = useState(null); + const [activeTab, setActiveTab] = useState("overview"); + const [selectedBookingId, setSelectedBookingId] = useState(null); useEffect(() => { if (!dayGroups.length) return; if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return; @@ -636,8 +650,17 @@ export default function BatchScheduleDetailPage() { ]} /> - - + + + Overview + + Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`} + + + + + +