mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
add detail batch and allocation monitoring page
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateTrainCompositionRemovalLog1781000000005 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await queryRunner.dropTable('freight.train_composition_removal_logs', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<TrainCompositionRemovalLog> {
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
super(dataSource.getRepository(TrainCompositionRemovalLog));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByScheduleId(scheduleId: string): Promise<TrainCompositionRemovalLog[]> {
|
||||||
|
return this.findAll({
|
||||||
|
where: { scheduleId },
|
||||||
|
order: { removedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from './entities/train-schedule.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 { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
|
||||||
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||||
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from './train-schedules.repository';
|
import { TrainSchedulesRepository } from './train-schedules.repository';
|
||||||
|
import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
|
||||||
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||||
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
|||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
TrainSchedule,
|
TrainSchedule,
|
||||||
TrainScheduleBooking,
|
TrainScheduleBooking,
|
||||||
|
TrainCompositionRemovalLog,
|
||||||
WagonBookingAllocation,
|
WagonBookingAllocation,
|
||||||
WagonAllocationContainerItem,
|
WagonAllocationContainerItem,
|
||||||
WagonAllocationBulkLoad,
|
WagonAllocationBulkLoad,
|
||||||
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
|||||||
providers: [
|
providers: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
|
TrainCompositionRemovalLogRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
WagonAllocationContainerItemsRepository,
|
WagonAllocationContainerItemsRepository,
|
||||||
WagonAllocationBulkLoadsRepository,
|
WagonAllocationBulkLoadsRepository,
|
||||||
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
|||||||
exports: [
|
exports: [
|
||||||
TrainSchedulesRepository,
|
TrainSchedulesRepository,
|
||||||
TrainScheduleBookingsRepository,
|
TrainScheduleBookingsRepository,
|
||||||
|
TrainCompositionRemovalLogRepository,
|
||||||
WagonBookingAllocationsRepository,
|
WagonBookingAllocationsRepository,
|
||||||
WagonAllocationContainerItemsRepository,
|
WagonAllocationContainerItemsRepository,
|
||||||
WagonAllocationBulkLoadsRepository,
|
WagonAllocationBulkLoadsRepository,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateContainerItemDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
containerNumber?: string | null;
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
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 { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
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 { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.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 { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
@@ -176,8 +180,44 @@ export class TrainSchedulingController {
|
|||||||
unassignBooking(
|
unassignBooking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Param('bookingId', ParseUUIDPipe) bookingId: 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')
|
@Post('schedules/:id/pin-wagons')
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ describe('TrainSchedulingService', () => {
|
|||||||
wagonAllocationContainerItemsRepository as never,
|
wagonAllocationContainerItemsRepository as never,
|
||||||
wagonAllocationBulkLoadsRepository as never,
|
wagonAllocationBulkLoadsRepository as never,
|
||||||
trainCheckpointEventsRepository as never,
|
trainCheckpointEventsRepository as never,
|
||||||
|
{} as never, // trainCompositionRemovalLogRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultFleetWagons = [
|
const defaultFleetWagons = [
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
|||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.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 { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.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 { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.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 { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.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 { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
@@ -143,6 +146,7 @@ export class TrainSchedulingService {
|
|||||||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||||||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||||
|
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||||
private readonly configService?: ConfigService,
|
private readonly configService?: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -467,7 +471,7 @@ export class TrainSchedulingService {
|
|||||||
return { ...detail, warnings, deferredBookings };
|
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);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
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`);
|
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) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
||||||
.flatMap((w) => w.allocations ?? [])
|
.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);
|
return this.getTrainScheduleById(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2236,6 +2255,103 @@ export class TrainSchedulingService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
|
||||||
|
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<any[]> {
|
||||||
|
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<any[]> {
|
||||||
|
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type FleetViewMode = "table" | "cards";
|
|||||||
|
|
||||||
const STORAGE_PREFIX = "edr-freight-fleet-view:";
|
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 => {
|
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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<RemovalTarget | null>(null);
|
||||||
|
|
||||||
|
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||||
|
const wagonCountByBooking = new Map<string, number>();
|
||||||
|
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 (
|
||||||
|
<Stack align="center" gap="xs" py="xl">
|
||||||
|
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||||
|
<Package size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600} c="gray.7">
|
||||||
|
No assigned bookings
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||||
|
Assign a paid booking from the Unassigned tab to load it onto a wagon.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{assignedBookings.map((booking) => {
|
||||||
|
const isActive = selectedBookingId === booking.id;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={booking.id}
|
||||||
|
padding="xs"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
onClick={() =>
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon size={30} radius="md" variant="light" color="green">
|
||||||
|
<Package size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
leftSection={<TrainFront size={9} />}
|
||||||
|
>
|
||||||
|
{wagonCountByBooking.get(booking.id)}
|
||||||
|
</Badge>
|
||||||
|
{!isDispatched ? (
|
||||||
|
<Tooltip label="Remove from train" withArrow>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
loading={unassign.isPending && unassign.variables?.bookingId === booking.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRemovalTarget({
|
||||||
|
bookingId: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
company: booking.customer,
|
||||||
|
weightTons: booking.weightTons ?? null,
|
||||||
|
wagonCount: wagonCountByBooking.get(booking.id) ?? 0,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{booking.customer ? (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="11px" c="dimmed" truncate>
|
||||||
|
{booking.customer}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
<Group gap={4} wrap="nowrap" mt={2}>
|
||||||
|
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
{(booking.weightTons ?? 0).toFixed(1)} T
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<RemoveBookingConfirmModal
|
||||||
|
opened={Boolean(removalTarget)}
|
||||||
|
onClose={() => setRemovalTarget(null)}
|
||||||
|
onConfirm={handleConfirmRemove}
|
||||||
|
isLoading={unassign.isPending}
|
||||||
|
target={removalTarget}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Stack align="center" gap="xs" py="xl">
|
||||||
|
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||||
|
<Icon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600} c="gray.7">
|
||||||
|
{emptyTitle}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||||
|
{emptyHint}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{bookings.map((booking) => {
|
||||||
|
const isActive = selectedBookingId === booking.id;
|
||||||
|
const deadline = fmtDateTime(booking.paymentDeadline);
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={booking.id}
|
||||||
|
padding="xs"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
onClick={() =>
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon size={30} radius="md" variant="light" color={accent}>
|
||||||
|
<Icon size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
{booking.isGovernment ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="grape"
|
||||||
|
leftSection={<Landmark size={9} />}
|
||||||
|
>
|
||||||
|
Gov
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
{booking.company ? (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Building2 size={11} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="11px" c="dimmed" truncate>
|
||||||
|
{booking.company}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
<Group justify="space-between" wrap="nowrap" mt={2} gap={6}>
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
{(booking.weightTons ?? 0).toFixed(1)} T · {booking.wagons}w
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{variant === "payment" && deadline ? (
|
||||||
|
<Text size="10px" fw={700} c="orange.7" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
Pay by {deadline}
|
||||||
|
</Text>
|
||||||
|
) : variant === "expired" ? (
|
||||||
|
<Badge size="xs" variant="light" color="red">
|
||||||
|
Expired
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="md">
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<ThemeIcon size={28} radius="md" variant="light" color="green">
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Box style={{ textAlign: "right" }}>{value}</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
radius="lg"
|
||||||
|
title={
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: freightBrand.gradient,
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package size={20} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={800}>{booking.reference ?? "Booking"}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Booking details
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
{booking.company ? (
|
||||||
|
<InfoRow
|
||||||
|
icon={<Building2 size={15} />}
|
||||||
|
label="Company"
|
||||||
|
value={
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{booking.company}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<InfoRow
|
||||||
|
icon={isBulk ? <Fuel size={15} /> : <ContainerIcon size={15} />}
|
||||||
|
label="Freight type"
|
||||||
|
value={
|
||||||
|
<Badge variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||||
|
{booking.freightType ?? (isBulk ? "BULK" : "CONTAINER")}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={<Weight size={15} />}
|
||||||
|
label="Weight"
|
||||||
|
value={
|
||||||
|
<Text size="sm" fw={700}>
|
||||||
|
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
icon={<TrainFront size={15} />}
|
||||||
|
label="Wagons"
|
||||||
|
value={
|
||||||
|
bookingWagons.length ? (
|
||||||
|
<Group gap={4} justify="flex-end">
|
||||||
|
{bookingWagons.map((w) => (
|
||||||
|
<Badge key={w.id} size="sm" variant="outline" color="green" radius="sm">
|
||||||
|
#{w.sequenceNo}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Not assigned to a wagon
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{booking.status ? (
|
||||||
|
<InfoRow
|
||||||
|
icon={<MapPin size={15} />}
|
||||||
|
label="Status"
|
||||||
|
value={
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
{booking.status}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{containers.length ? (
|
||||||
|
<>
|
||||||
|
<Divider
|
||||||
|
label={
|
||||||
|
<Group gap={6}>
|
||||||
|
<ContainerIcon size={13} />
|
||||||
|
<Text size="xs" fw={700}>
|
||||||
|
Containers ({containers.length})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{containers.map((c, i) => (
|
||||||
|
<Group
|
||||||
|
key={c.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<ContainerIcon size={14} color="var(--mantine-color-cyan-7)" />
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{c.containerNumber?.trim() || `Container ${i + 1}`}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{c.grossWeightTons != null ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{Number(c.grossWeightTons).toFixed(1)} T
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<TabKey, { label: string; icon: LucideIcon; color: string }> = {
|
||||||
|
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<BookingDetailData | null>(null);
|
||||||
|
const [tab, setTab] = useState<TabKey>("assigned");
|
||||||
|
|
||||||
|
const unassignedQuery = useUnassignedBookings(scheduleId);
|
||||||
|
const removalsQuery = useCompositionRemovals(scheduleId);
|
||||||
|
|
||||||
|
const { assignedCount, freeWagons, freeWeightTons } = useMemo(() => {
|
||||||
|
const wagons = scheduleDetail.trainSet?.wagons ?? [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
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<TabKey, number> = {
|
||||||
|
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 (
|
||||||
|
<Tooltip label={meta.label} withArrow position="top">
|
||||||
|
<Tabs.Tab value={value} px={6}>
|
||||||
|
<Group gap={5} wrap="nowrap" justify="center">
|
||||||
|
<Icon size={15} />
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
circle
|
||||||
|
variant={active ? "filled" : "light"}
|
||||||
|
color={active ? meta.color : "gray"}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderColor: "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header — reflects the active tab */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${freightBrand.mutedBg}, white)`,
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: freightBrand.gradient,
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layers size={18} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={800} size="sm">
|
||||||
|
{TAB_META[tab].label}
|
||||||
|
</Text>
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
{counts[tab]} booking{counts[tab] === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onChange={(v) => v && setTab(v as TabKey)}
|
||||||
|
variant="default"
|
||||||
|
color="green"
|
||||||
|
style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Tabs.List grow>
|
||||||
|
<TabButton value="assigned" />
|
||||||
|
<TabButton value="unassigned" />
|
||||||
|
<TabButton value="payment" />
|
||||||
|
<TabButton value="expired" />
|
||||||
|
<TabButton value="removed" />
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<ScrollArea style={{ flex: 1 }} type="auto" offsetScrollbars>
|
||||||
|
<Tabs.Panel value="assigned" p="md">
|
||||||
|
<AssignedBookingsPanel
|
||||||
|
scheduleDetail={scheduleDetail}
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
selectedBookingId={selectedBookingId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="unassigned" p="md">
|
||||||
|
<UnassignedBookingsPanel
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
selectedBookingId={selectedBookingId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
freeWagons={freeWagons}
|
||||||
|
freeWeightTons={freeWeightTons}
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="payment" p="md">
|
||||||
|
<BatchBookingList
|
||||||
|
bookings={awaitingPayment}
|
||||||
|
variant="payment"
|
||||||
|
selectedBookingId={selectedBookingId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
emptyTitle="No bookings awaiting payment"
|
||||||
|
emptyHint="Bookings selected for this batch with a payment notification sent will appear here."
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="expired" p="md">
|
||||||
|
<BatchBookingList
|
||||||
|
bookings={expired}
|
||||||
|
variant="expired"
|
||||||
|
selectedBookingId={selectedBookingId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
emptyTitle="No expired bookings"
|
||||||
|
emptyHint="Bookings whose payment window lapsed will appear here."
|
||||||
|
/>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="removed" p="md">
|
||||||
|
<RemovalLogPanel scheduleId={scheduleId} />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Footer summary */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
px="md"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<PackageCheck size={13} color="var(--mantine-color-green-7)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{assignedCount} on train
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<CreditCard size={13} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{counts.payment} to pay
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<XCircle size={13} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{counts.expired} expired
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<BookingDetailModal
|
||||||
|
opened={Boolean(detailBooking)}
|
||||||
|
onClose={() => setDetailBooking(null)}
|
||||||
|
booking={detailBooking}
|
||||||
|
wagons={scheduleDetail.trainSet?.wagons ?? []}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string | null>(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 <Text size="sm">{value || "TBD"}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Group gap={4}>
|
||||||
|
<TextInput
|
||||||
|
size="xs"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
disabled={isLoading}
|
||||||
|
placeholder="Container #"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
{error && <Text size="xs" c="red">{error}</Text>}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
style={{ cursor: "pointer", textDecoration: "underline" }}
|
||||||
|
title="Click to edit"
|
||||||
|
>
|
||||||
|
{value || "TBD"}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<TrainScheduleDetail["trainSet"]>["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 (
|
||||||
|
<Group gap={count > 2 ? 10 : 18} justify="center" wrap="nowrap" mt={2}>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: dark
|
||||||
|
? "radial-gradient(circle at 35% 35%, #2c4a3a, #0f291b)"
|
||||||
|
: "radial-gradient(circle at 35% 35%, var(--mantine-color-gray-5), var(--mantine-color-gray-8))",
|
||||||
|
border: "2px solid var(--mantine-color-gray-4)",
|
||||||
|
boxShadow: "inset 0 0 0 2px rgba(255,255,255,0.3), 0 1px 2px rgba(0,0,0,0.2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Coupler() {
|
||||||
|
return (
|
||||||
|
<Box style={{ width: 12, height: 70, display: "flex", alignItems: "center", flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, var(--mantine-color-gray-4), var(--mantine-color-gray-6), var(--mantine-color-gray-4))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocomotiveCar({ locomotive }: { locomotive: Locomotive }) {
|
||||||
|
const code = locomotive?.code ?? "LOCO";
|
||||||
|
return (
|
||||||
|
<Box style={{ width: 120, flexShrink: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 70,
|
||||||
|
borderRadius: "12px 22px 9px 9px",
|
||||||
|
background: `linear-gradient(160deg, ${freightBrand.primaryLight} 0%, ${freightBrand.primary} 45%, ${freightBrand.primaryDark} 100%)`,
|
||||||
|
boxShadow: `${freightBrand.shadowSm}, inset 0 1px 0 rgba(255,255,255,0.25)`,
|
||||||
|
border: "1px solid rgba(0,0,0,0.1)",
|
||||||
|
overflow: "hidden",
|
||||||
|
padding: "8px 9px 7px",
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* cab windows */}
|
||||||
|
<Box style={{ position: "absolute", top: 9, right: 9, display: "flex", gap: 4 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 13,
|
||||||
|
height: 11,
|
||||||
|
borderRadius: "3px 5px 3px 3px",
|
||||||
|
background: "linear-gradient(135deg, #E8FBFF 0%, #9ED9E8 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* headlight */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 12,
|
||||||
|
right: 5,
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: "#fde68a",
|
||||||
|
boxShadow: "0 0 9px 3px rgba(253,230,138,0.9)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* hazard stripe */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 5,
|
||||||
|
background: "repeating-linear-gradient(45deg, #fbbf24 0 6px, #1f2937 6px 12px)",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Group gap={5} wrap="nowrap" align="center">
|
||||||
|
<TrainFront size={16} />
|
||||||
|
<Text size="sm" fw={800} style={{ letterSpacing: 0.4 }}>
|
||||||
|
{code}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{locomotive?.maxPullWeightTons ? (
|
||||||
|
<Group gap={3} wrap="nowrap" mt={3} style={{ opacity: 0.95 }}>
|
||||||
|
<Gauge size={10} />
|
||||||
|
<Text size="9px" fw={700}>
|
||||||
|
{locomotive.maxPullWeightTons}T pull
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Wheels count={3} dark />
|
||||||
|
<Text size="9px" ta="center" c="dimmed" mt={2} fw={700} style={{ letterSpacing: 1 }}>
|
||||||
|
HEAD
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<HoverCard width={280} shadow="lg" radius="md" position="top" withArrow openDelay={120}>
|
||||||
|
<HoverCard.Target>
|
||||||
|
<Box
|
||||||
|
onClick={onSelect}
|
||||||
|
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
height: 70,
|
||||||
|
borderRadius: 11,
|
||||||
|
background: isEmpty
|
||||||
|
? "var(--mantine-color-gray-0)"
|
||||||
|
: "linear-gradient(180deg, white, var(--mantine-color-gray-0))",
|
||||||
|
border: isEmpty
|
||||||
|
? "1.5px dashed var(--mantine-color-gray-4)"
|
||||||
|
: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
boxShadow:
|
||||||
|
ringColor !== "transparent"
|
||||||
|
? `0 0 0 3px ${ringColor}, 0 4px 12px rgba(15,41,27,0.12)`
|
||||||
|
: isEmpty
|
||||||
|
? "none"
|
||||||
|
: "0 3px 10px rgba(15,41,27,0.08)",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
transition: "box-shadow 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* top accent strip */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 4,
|
||||||
|
background: isEmpty
|
||||||
|
? "var(--mantine-color-gray-3)"
|
||||||
|
: `linear-gradient(90deg, ${accentVar}, var(--mantine-color-${accent}-4))`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* header */}
|
||||||
|
<Group justify="space-between" px={7} pt={3} wrap="nowrap">
|
||||||
|
<Text size="10px" fw={800} c="gray.7">
|
||||||
|
#{wagon.sequenceNo}
|
||||||
|
</Text>
|
||||||
|
{isEmpty ? (
|
||||||
|
<Text size="8px" c="dimmed" fw={700} style={{ letterSpacing: 0.5 }}>
|
||||||
|
EMPTY
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Group gap={2} wrap="nowrap">
|
||||||
|
{isBulk ? (
|
||||||
|
<Fuel size={10} color={accentVar} />
|
||||||
|
) : (
|
||||||
|
<ContainerIcon size={10} color={accentVar} />
|
||||||
|
)}
|
||||||
|
<Text size="8px" fw={700} c={`${accent}.7`} style={{ letterSpacing: 0.3 }}>
|
||||||
|
{isBulk ? "BULK" : "CONT"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* body */}
|
||||||
|
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
|
||||||
|
{isEmpty ? (
|
||||||
|
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
|
||||||
|
Available
|
||||||
|
</Text>
|
||||||
|
) : isBulk ? (
|
||||||
|
<Stack gap={2} style={{ width: "100%" }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 5,
|
||||||
|
background: "var(--mantine-color-orange-0)",
|
||||||
|
border: "1px solid var(--mantine-color-orange-2)",
|
||||||
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: `${utilization}%`,
|
||||||
|
background:
|
||||||
|
"linear-gradient(90deg, var(--mantine-color-orange-6), var(--mantine-color-orange-4))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
|
||||||
|
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
|
||||||
|
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
|
||||||
|
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "0 2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
|
||||||
|
{cn}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* footer */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-1)",
|
||||||
|
padding: "2px 7px",
|
||||||
|
background: isEmpty ? "transparent" : "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={3}>
|
||||||
|
<Text size="8px" c="dimmed" fw={600} truncate>
|
||||||
|
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Wagon"}
|
||||||
|
</Text>
|
||||||
|
{!isEmpty ? (
|
||||||
|
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{assigned}T
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Wheels count={2} />
|
||||||
|
</Box>
|
||||||
|
</HoverCard.Target>
|
||||||
|
|
||||||
|
<HoverCard.Dropdown p="sm">
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: isEmpty
|
||||||
|
? "var(--mantine-color-gray-1)"
|
||||||
|
: freightBrand.gradient,
|
||||||
|
color: isEmpty ? "var(--mantine-color-gray-6)" : "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrainFront size={15} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={800}>
|
||||||
|
Wagon #{wagon.sequenceNo}
|
||||||
|
</Text>
|
||||||
|
<Text size="10px" c="dimmed">
|
||||||
|
{wagon.physicalWagonNumber ?? wagon.wagonType?.code ?? "Unassigned"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{!isEmpty ? (
|
||||||
|
<Badge size="xs" variant="light" color={isBulk ? "orange" : "cyan"}>
|
||||||
|
{isBulk ? "Bulk" : "Container"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Empty slot — available for allocation.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Stack gap={6}>
|
||||||
|
{company ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Building2 size={13} color={freightBrand.primary} />
|
||||||
|
<Text size="xs" fw={700} truncate>
|
||||||
|
{company}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Package size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{allocation?.bookingReference ?? "Unknown booking"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{containerNumbers.length ? (
|
||||||
|
<div>
|
||||||
|
<Text size="10px" c="dimmed" fw={700} mb={3} tt="uppercase">
|
||||||
|
Containers
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
{containerNumbers.map((cn, i) => (
|
||||||
|
<Badge key={i} size="xs" variant="outline" color="cyan" radius="sm">
|
||||||
|
{cn}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isBulk && allocation?.bulkLoad?.cargoDescription ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{allocation.bulkLoad.cargoDescription}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{assigned}T / {capacity}T ({utilization}%)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
height: 5,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: `${utilization}%`,
|
||||||
|
height: "100%",
|
||||||
|
background:
|
||||||
|
utilization >= 100
|
||||||
|
? "var(--mantine-color-red-5)"
|
||||||
|
: `var(--mantine-color-${accent}-5)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Text size="10px" c="dimmed" ta="center">
|
||||||
|
Click the wagon to edit or remove
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</HoverCard.Dropdown>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InteractiveTrainConsist = ({
|
||||||
|
wagons,
|
||||||
|
locomotive,
|
||||||
|
getCompany,
|
||||||
|
selectedWagonId,
|
||||||
|
onSelectWagon,
|
||||||
|
highlightBookingId,
|
||||||
|
}: InteractiveTrainConsistProps) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
padding: "8px 12px 18px",
|
||||||
|
borderRadius: 14,
|
||||||
|
background: "linear-gradient(180deg, var(--mantine-color-gray-0), white)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={0} wrap="nowrap" align="flex-start" style={{ minWidth: "min-content" }}>
|
||||||
|
{locomotive ? <LocomotiveCar locomotive={locomotive} /> : null}
|
||||||
|
{wagons.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed" pl="md" pt="lg">
|
||||||
|
No wagons assigned
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
wagons.map((wagon, i) => {
|
||||||
|
const bookingId = wagon.allocations?.[0]?.bookingId;
|
||||||
|
return (
|
||||||
|
<Group key={wagon.id} gap={0} wrap="nowrap" align="flex-start">
|
||||||
|
{i > 0 || locomotive ? <Coupler /> : null}
|
||||||
|
<WagonCar
|
||||||
|
wagon={wagon}
|
||||||
|
company={getCompany(bookingId)}
|
||||||
|
selected={selectedWagonId === wagon.id}
|
||||||
|
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
|
||||||
|
onSelect={() => onSelectWagon(wagon)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* track bed under the whole consist */}
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
bottom: 8,
|
||||||
|
height: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
"repeating-linear-gradient(90deg, var(--mantine-color-gray-4) 0 5px, transparent 5px 20px)",
|
||||||
|
opacity: 0.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 1,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 1,
|
||||||
|
height: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
background: "var(--mantine-color-gray-5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading...
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removals = removalQuery.data ?? [];
|
||||||
|
|
||||||
|
if (removals.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" py="xl">
|
||||||
|
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||||
|
<History size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600} c="gray.7">
|
||||||
|
No removals yet
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||||
|
Bookings removed from this train will appear here for audit.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{removals.map((removal) => (
|
||||||
|
<Card key={removal.id} padding="xs" radius="md" withBorder>
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon size={30} radius="md" variant="light" color="red">
|
||||||
|
<PackageX size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{removal.bookingReference || "Unknown booking"}
|
||||||
|
</Text>
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
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
|
||||||
|
</Text>
|
||||||
|
{removal.notes ? (
|
||||||
|
<Text size="11px" c="dimmed" mt={2} lineClamp={2}>
|
||||||
|
{removal.notes}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
radius="lg"
|
||||||
|
size="md"
|
||||||
|
withCloseButton={false}
|
||||||
|
title={
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<ThemeIcon size={40} radius="md" variant="light" color="red">
|
||||||
|
<PackageX size={21} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text fw={800}>Remove booking from train?</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
This change is logged and the customer is notified
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{/* Booking summary */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" wrap="nowrap">
|
||||||
|
<Text fw={800} size="sm">
|
||||||
|
{target?.reference ?? "Booking"}
|
||||||
|
</Text>
|
||||||
|
<Badge variant="light" color="green" leftSection={<TrainFront size={10} />}>
|
||||||
|
{target?.wagonCount ?? 0} wagon{target?.wagonCount === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group gap="lg" mt={6} wrap="wrap">
|
||||||
|
{target?.company ? (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Building2 size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{target.company}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{(target?.weightTons ?? 0).toFixed(1)} T
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* What happens */}
|
||||||
|
<Box
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 12,
|
||||||
|
background: "var(--mantine-color-orange-0)",
|
||||||
|
border: "1px solid var(--mantine-color-orange-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={6} mb={6} wrap="nowrap">
|
||||||
|
<AlertTriangle size={14} color="var(--mantine-color-orange-7)" />
|
||||||
|
<Text size="xs" fw={700} c="orange.8">
|
||||||
|
Removing this booking will:
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<List spacing={6} size="xs" center>
|
||||||
|
<List.Item
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||||
|
<Undo2 size={11} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Return it to the unassigned pool
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||||
|
<FileClock size={11} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Create a removal log entry for audit
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
icon={
|
||||||
|
<ThemeIcon size={18} radius="xl" variant="light" color="orange">
|
||||||
|
<Bell size={11} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Notify the customer to reschedule or cancel
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<PackageX size={16} />}
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
Remove booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Booking Details
|
||||||
|
</Text>
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Reference:</strong> {booking?.reference || "N/A"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Freight Type:</strong>{" "}
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{booking?.freightType || "N/A"}
|
||||||
|
</Badge>
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
|
||||||
|
</Text>
|
||||||
|
<Text size="sm">
|
||||||
|
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="orange" fw={500}>
|
||||||
|
⚠️ Warning: Removing this booking will:
|
||||||
|
</Text>
|
||||||
|
<ul style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
<li>Move the booking back to the unassigned pool</li>
|
||||||
|
<li>Create a removal log for audit</li>
|
||||||
|
<li>Notify the customer to reschedule or cancel</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={onConfirm} loading={isLoading}>
|
||||||
|
Remove Booking
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 3,
|
||||||
|
background: dashed ? "var(--mantine-color-gray-1)" : `var(--mantine-color-${color}-5)`,
|
||||||
|
border: dashed ? "1.5px dashed var(--mantine-color-gray-4)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrainConsistView = ({
|
||||||
|
scheduleDetail,
|
||||||
|
scheduleId,
|
||||||
|
maxWagons,
|
||||||
|
highlightBookingId,
|
||||||
|
}: TrainConsistViewProps) => {
|
||||||
|
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(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<string, string>();
|
||||||
|
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 (
|
||||||
|
<Stack gap="md" style={{ width: "100%" }}>
|
||||||
|
<TrainStatsBar
|
||||||
|
weightUsed={weightUsed}
|
||||||
|
weightMax={scheduleDetail.locomotive?.maxWeightTons ?? trainSet?.locomotive?.maxPullWeightTons ?? null}
|
||||||
|
lengthUsed={lengthUsed}
|
||||||
|
lengthMax={scheduleDetail.locomotive?.maxLengthMeters ?? trainSet?.locomotive?.maxTrainLengthMeters ?? null}
|
||||||
|
wagonCount={wagons.length}
|
||||||
|
wagonMax={maxWagons}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Consist panel */}
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ borderColor: "var(--mantine-color-gray-2)", overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="md"
|
||||||
|
py="sm"
|
||||||
|
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: freightBrand.gradient,
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrainFront size={18} />
|
||||||
|
</Box>
|
||||||
|
<div>
|
||||||
|
<Text fw={800} size="sm">
|
||||||
|
Train consist
|
||||||
|
</Text>
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
{wagons.length} wagons · {loadedCount} loaded · {wagons.length - loadedCount} empty
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Group gap="md" wrap="nowrap" visibleFrom="sm">
|
||||||
|
<LegendDot color="cyan" label="Container" />
|
||||||
|
<LegendDot color="orange" label="Bulk" />
|
||||||
|
<LegendDot color="gray" label="Empty" dashed />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box p="md">
|
||||||
|
<InteractiveTrainConsist
|
||||||
|
wagons={wagons}
|
||||||
|
locomotive={trainSet?.locomotive}
|
||||||
|
getCompany={(bookingId) => (bookingId ? companyByBooking.get(bookingId) ?? null : null)}
|
||||||
|
selectedWagonId={selectedWagonId}
|
||||||
|
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
|
||||||
|
highlightBookingId={highlightBookingId}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Selected wagon — editable detail card */}
|
||||||
|
{selectedWagon ? (
|
||||||
|
<Box>
|
||||||
|
<Group gap={6} mb={6} wrap="nowrap">
|
||||||
|
<Badge variant="light" color="green" radius="sm">
|
||||||
|
Editing wagon #{selectedWagon.sequenceNo}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Update container numbers or remove the booking
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<WagonCard
|
||||||
|
wagon={selectedWagon}
|
||||||
|
company={
|
||||||
|
selectedWagon.allocations?.[0]?.bookingId
|
||||||
|
? companyByBooking.get(selectedWagon.allocations[0].bookingId) ?? null
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
scheduleStatus={scheduleDetail.status}
|
||||||
|
onRemoveBooking={handleRemoveBooking}
|
||||||
|
onRemoveWagon={handleRemoveWagon}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : wagons.length ? (
|
||||||
|
<Paper
|
||||||
|
radius="md"
|
||||||
|
py="sm"
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
border: "1px dashed var(--mantine-color-gray-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={8} justify="center" c="dimmed">
|
||||||
|
<ThemeIcon size={24} radius="xl" variant="light" color="gray">
|
||||||
|
<MousePointerClick size={13} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Click a wagon in the train to edit container numbers or remove its booking.
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RemoveBookingModal
|
||||||
|
opened={removeModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setRemoveModalOpen(false);
|
||||||
|
}}
|
||||||
|
wagon={selectedWagon}
|
||||||
|
onConfirm={handleConfirmRemoveBooking}
|
||||||
|
isLoading={unassignMutation.isPending}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Group gap="sm" wrap="nowrap" align="center">
|
||||||
|
<RingProgress
|
||||||
|
size={62}
|
||||||
|
thickness={6}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: clamped, color }]}
|
||||||
|
rootColor="var(--mantine-color-gray-1)"
|
||||||
|
label={
|
||||||
|
<Group justify="center">
|
||||||
|
<ThemeIcon size={26} radius="xl" variant="transparent" style={{ color }}>
|
||||||
|
{icon}
|
||||||
|
</ThemeIcon>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
|
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.6 }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group gap={5} align="baseline" wrap="nowrap">
|
||||||
|
<Text size="lg" fw={800} lh={1.1} c="dark.5" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{current}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
/ {max} {unit}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{pct != null ? (
|
||||||
|
<Text size="10px" fw={700} style={{ color }}>
|
||||||
|
{Math.round(pct)}% utilized
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="10px" c="dimmed">
|
||||||
|
no limit set
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper
|
||||||
|
p="md"
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
style={{ borderColor: "var(--mantine-color-gray-2)", background: "white" }}
|
||||||
|
>
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
|
||||||
|
<StatTile
|
||||||
|
icon={<Weight size={15} />}
|
||||||
|
label="Weight"
|
||||||
|
pct={weightPct}
|
||||||
|
current={weightUsed.toFixed(1)}
|
||||||
|
max={weightMax?.toFixed(1) ?? "∞"}
|
||||||
|
unit="T"
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
px={{ base: 0, xs: "lg" }}
|
||||||
|
style={{
|
||||||
|
borderLeft: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatTile
|
||||||
|
icon={<Ruler size={15} />}
|
||||||
|
label="Length"
|
||||||
|
pct={lengthPct}
|
||||||
|
current={lengthUsed.toFixed(1)}
|
||||||
|
max={lengthMax?.toFixed(1) ?? "∞"}
|
||||||
|
unit="m"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<StatTile
|
||||||
|
icon={<Train size={15} />}
|
||||||
|
label="Wagons"
|
||||||
|
pct={wagonPct}
|
||||||
|
current={String(wagonCount)}
|
||||||
|
max={String(wagonMax)}
|
||||||
|
unit=""
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading...
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings = unassignedQuery.data ?? [];
|
||||||
|
|
||||||
|
if (bookings.length === 0) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" gap="xs" py="xl">
|
||||||
|
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||||
|
<ContainerIcon size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" fw={600} c="gray.7">
|
||||||
|
No unassigned bookings
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" ta="center" maw={220}>
|
||||||
|
Paid bookings waiting for a wagon will appear here.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const noFreeWagon = freeWagons <= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{/* Capacity availability banner */}
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px="sm"
|
||||||
|
py={6}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
background: noFreeWagon ? "var(--mantine-color-red-0)" : "var(--mantine-color-green-0)",
|
||||||
|
border: `1px solid ${
|
||||||
|
noFreeWagon ? "var(--mantine-color-red-2)" : "var(--mantine-color-green-1)"
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<TrainFront
|
||||||
|
size={13}
|
||||||
|
color={noFreeWagon ? "var(--mantine-color-red-6)" : "var(--mantine-color-green-7)"}
|
||||||
|
/>
|
||||||
|
<Text size="xs" fw={700} c={noFreeWagon ? "red.7" : "green.8"}>
|
||||||
|
{freeWagons} free wagon{freeWagons === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{freeWeightTons != null ? (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<Weight size={13} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{freeWeightTons.toFixed(1)} T headroom
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<Card
|
||||||
|
key={booking.id}
|
||||||
|
padding="xs"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
onClick={() =>
|
||||||
|
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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group gap={8} wrap="nowrap" align="flex-start">
|
||||||
|
<ThemeIcon size={30} radius="md" variant="light" color="orange">
|
||||||
|
<ContainerIcon size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap={4}>
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{booking.reference}
|
||||||
|
</Text>
|
||||||
|
{booking.priorityScore ? (
|
||||||
|
<Badge size="xs" color="green">
|
||||||
|
P{booking.priorityScore}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} wrap="nowrap" mt={2}>
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{booking.freightType}
|
||||||
|
</Badge>
|
||||||
|
<Group gap={3} wrap="nowrap">
|
||||||
|
<Weight size={11} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="11px" c="dimmed">
|
||||||
|
{weight.toFixed(1)} T
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{blockReason ? (
|
||||||
|
<Group gap={5} wrap="nowrap">
|
||||||
|
<AlertTriangle size={12} color="var(--mantine-color-red-6)" />
|
||||||
|
<Text size="10px" c="red.7" fw={600}>
|
||||||
|
{blockReason}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Tooltip label={blockReason} disabled={fits} withArrow position="bottom">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
disabled={!fits}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleAssign(booking.id, booking.reference);
|
||||||
|
}}
|
||||||
|
loading={
|
||||||
|
assignMutation.isPending &&
|
||||||
|
assignMutation.variables?.payload.bookingIds?.[0] === booking.id
|
||||||
|
}
|
||||||
|
leftSection={<Plus size={12} />}
|
||||||
|
>
|
||||||
|
Assign to train
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Card padding="sm" radius="md" withBorder style={{ borderColor: freightBrand.mutedBorder }}>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs" style={{ background: freightBrand.mutedBg }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap={6}>
|
||||||
|
<ThemeIcon size={28} radius="md" variant="white" color="green">
|
||||||
|
<TrainFront size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="sm" fw={800}>
|
||||||
|
Wagon #{wagon.sequenceNo}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="light" color="green">
|
||||||
|
{wagonType}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
{wagon.physicalWagonNumber || wagon.physicalWagonId ? (
|
||||||
|
<Text size="10px" c="dimmed">
|
||||||
|
{wagon.physicalWagonNumber || wagon.physicalWagonId?.slice(0, 8)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
{hasAllocations ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={isBulk ? "orange" : "cyan"}
|
||||||
|
leftSection={isBulk ? <Fuel size={11} /> : <ContainerIcon size={11} />}
|
||||||
|
>
|
||||||
|
{isBulk ? "Bulk" : "Container"}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack gap="sm" mt="sm">
|
||||||
|
{hasAllocations && allocation ? (
|
||||||
|
<>
|
||||||
|
{company ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Building2 size={14} color={freightBrand.primary} />
|
||||||
|
<Text size="sm" fw={700} truncate>
|
||||||
|
{company}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Package size={14} color="var(--mantine-color-gray-6)" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{allocation.bookingReference || "Unknown booking"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{allocation.loadType === "CONTAINER" && allocation.containerItems?.length ? (
|
||||||
|
<Box>
|
||||||
|
<Text size="10px" c="dimmed" fw={700} tt="uppercase" mb={4}>
|
||||||
|
Containers
|
||||||
|
</Text>
|
||||||
|
<Stack gap={6}>
|
||||||
|
{allocation.containerItems.map((item, idx) => (
|
||||||
|
<Group key={item.id} gap={8} wrap="nowrap">
|
||||||
|
<ContainerIcon size={13} color="var(--mantine-color-cyan-7)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
#{idx + 1}
|
||||||
|
</Text>
|
||||||
|
<ContainerNumberInput
|
||||||
|
value={item.containerNumber ?? null}
|
||||||
|
itemId={item.id}
|
||||||
|
scheduleId={scheduleId}
|
||||||
|
disabled={isDispatched}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isBulk ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Fuel size={14} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{allocation.bulkLoad?.cargoDescription || "Bulk load"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb={4}>
|
||||||
|
<Text size="xs" c="dimmed" fw={600}>
|
||||||
|
Weight
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{weightUsed.toFixed(1)} / {weightMax.toFixed(1)} T
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={Math.min(weightPercent, 100)}
|
||||||
|
color={weightPercent > 90 ? "red" : weightPercent > 75 ? "orange" : "green"}
|
||||||
|
size="sm"
|
||||||
|
radius="xl"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!isDispatched ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => onRemoveBooking(wagon)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Remove booking
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs" align="center" py="sm">
|
||||||
|
<ThemeIcon size={36} radius="xl" variant="light" color="gray">
|
||||||
|
<TrainFront size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Empty slot
|
||||||
|
</Text>
|
||||||
|
{!isDispatched ? (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<Trash2 size={14} />}
|
||||||
|
onClick={() => onRemoveWagon(wagon.id)}
|
||||||
|
>
|
||||||
|
Remove wagon
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
@@ -52,6 +52,10 @@ export const QUERY_KEYS = {
|
|||||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||||
batchBoardDetail: (scheduleId: string) =>
|
batchBoardDetail: (scheduleId: string) =>
|
||||||
["train-scheduling", "batch-board", scheduleId] as const,
|
["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: {
|
FLEET: {
|
||||||
|
|||||||
@@ -190,6 +190,14 @@ export const URL_CONSTANTS = {
|
|||||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||||
CANCEL_SCHEDULE: (id: string) =>
|
CANCEL_SCHEDULE: (id: string) =>
|
||||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
`/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: {
|
RULE_ENGINE: {
|
||||||
|
|||||||
@@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
|||||||
void qc.invalidateQueries({
|
void qc.invalidateQueries({
|
||||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
|
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 });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||||
};
|
};
|
||||||
@@ -244,3 +250,46 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
|||||||
invalidate,
|
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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
RingProgress,
|
RingProgress,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -17,17 +19,21 @@ import {
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Inbox,
|
Inbox,
|
||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
|
||||||
Ruler,
|
Ruler,
|
||||||
Train,
|
Train,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} 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 Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
|
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||||
|
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||||
import {
|
import {
|
||||||
BookingPipeline,
|
BookingPipeline,
|
||||||
HeroChip,
|
HeroChip,
|
||||||
@@ -35,6 +41,7 @@ import {
|
|||||||
WindowStatusPill,
|
WindowStatusPill,
|
||||||
} from "@/components/trainScheduling/batchVisuals";
|
} from "@/components/trainScheduling/batchVisuals";
|
||||||
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
|
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
|
||||||
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||||
@@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) =>
|
|||||||
}).format(new Date(iso)) + " EAT"
|
}).format(new Date(iso)) + " EAT"
|
||||||
: "No date";
|
: "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. */
|
/** Capacity ring color: gold normally, red once over capacity. */
|
||||||
function ringColor(pct: number) {
|
function ringColor(pct: number) {
|
||||||
if (pct >= 100) return "#fa5252";
|
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 (
|
||||||
|
<Group
|
||||||
|
gap={4}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: over ? "var(--mantine-color-red-0)" : "var(--mantine-color-gray-1)",
|
||||||
|
border: `1px solid ${over ? "var(--mantine-color-red-2)" : "var(--mantine-color-gray-2)"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={12} color={over ? "var(--mantine-color-red-6)" : "var(--mantine-color-gray-6)"} />
|
||||||
|
<Text size="xs" fw={700} c={over ? "red.7" : "gray.7"} lh={1.2}>
|
||||||
|
{pct != null ? `${Math.round(pct)}%` : "—"}
|
||||||
|
</Text>
|
||||||
|
<Text size="10px" c="dimmed" lh={1.2}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { capacity, counts, locomotive } = schedule;
|
const { capacity, counts, locomotive } = schedule;
|
||||||
|
|
||||||
const lengthPct =
|
const lengthPct = lengthPctOf(schedule);
|
||||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
const weightPct = weightPctOf(schedule);
|
||||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
|
||||||
: null;
|
|
||||||
const weightPct =
|
|
||||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
|
||||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const totalBookings = totalBookingCount(counts);
|
const totalBookings = totalBookingCount(counts);
|
||||||
|
|
||||||
@@ -298,7 +364,13 @@ function CardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BatchBoardPage() {
|
export default function BatchBoardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
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 schedules = data ?? [];
|
||||||
|
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
@@ -308,22 +380,199 @@ export default function BatchBoardPage() {
|
|||||||
return { openWindows, totalBookings, totalWagons };
|
return { openWindows, totalBookings, totalWagons };
|
||||||
}, [schedules]);
|
}, [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<BatchBoardSchedule>[] => {
|
||||||
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "train",
|
||||||
|
header: "Train / Route",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<ThemeIcon size={34} radius="md" variant="light" color="#F2A516">
|
||||||
|
<Train size={17} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
|
<Text size="sm" fw={700} lh={1.2} truncate>
|
||||||
|
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
|
||||||
|
</Text>
|
||||||
|
<Box maw={220}>
|
||||||
|
<RouteCorridor
|
||||||
|
origin={row.original.origin}
|
||||||
|
destination={row.original.destination}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "date",
|
||||||
|
header: "Departure",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { day, time } = splitDate(row.original.scheduleDate);
|
||||||
|
return (
|
||||||
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
background: "var(--mantine-color-orange-0)",
|
||||||
|
color: "#B26C09",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CalendarClock size={16} />
|
||||||
|
</Box>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" lh={1.2}>
|
||||||
|
{time || "—"}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "window",
|
||||||
|
header: "Window",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => <WindowStatusPill status={row.original.bookingWindowStatus} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "loco",
|
||||||
|
header: "Locomotive",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.locomotive ? (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<TrainFront size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
|
{row.original.locomotive.code}
|
||||||
|
</Text>
|
||||||
|
<Text size="10px" c="dimmed" lh={1.2}>
|
||||||
|
{fmtTons(row.original.locomotive.maxPullWeightTons)} pull
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="xs" c="red.6" fw={600}>
|
||||||
|
No loco
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "capacity",
|
||||||
|
header: "Capacity",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<CapacityChip icon={Weight} pct={weightPctOf(row.original)} text="wt" />
|
||||||
|
<CapacityChip icon={Ruler} pct={lengthPctOf(row.original)} text="len" />
|
||||||
|
<Group
|
||||||
|
gap={4}
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--mantine-color-green-0)",
|
||||||
|
border: "1px solid var(--mantine-color-green-1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Package size={12} color="var(--mantine-color-green-7)" />
|
||||||
|
<Text size="xs" fw={700} c="green.8" lh={1.2}>
|
||||||
|
{row.original.capacity.allocatedWagons}
|
||||||
|
</Text>
|
||||||
|
<Text size="10px" c="dimmed" lh={1.2}>
|
||||||
|
wgn
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bookings",
|
||||||
|
header: "Bookings",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const total = totalBookingCount(row.original.counts);
|
||||||
|
return (
|
||||||
|
<Stack gap={4} style={{ minWidth: 130 }}>
|
||||||
|
<Text size="sm" fw={700} c="dark.4" lh={1.2}>
|
||||||
|
{total} booking{total === 1 ? "" : "s"}
|
||||||
|
</Text>
|
||||||
|
<BookingPipeline counts={row.original.counts} size={10} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: "",
|
||||||
|
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Group justify="flex-end" wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
size="compact-sm"
|
||||||
|
rightSection={<ArrowRight size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/dashboard/operations/batch-board/${row.original.scheduleId}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View windows
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const tableStatus = isLoading ? "loading" : "success";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid py="lg" px="xl">
|
<Container fluid py="lg" px="xl">
|
||||||
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
<Breadcrumbs items={[{ label: "Operations" }, { label: "Batch board" }]} />
|
||||||
|
|
||||||
<Group justify="flex-end" mt="md">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
radius="lg"
|
|
||||||
leftSection={<RefreshCw size={16} />}
|
|
||||||
loading={isFetching}
|
|
||||||
onClick={() => void refetch()}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
|
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="md" mt="md">
|
||||||
<StatTile
|
<StatTile
|
||||||
icon={Train}
|
icon={Train}
|
||||||
@@ -359,46 +608,121 @@ export default function BatchBoardPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{isLoading ? (
|
<Card
|
||||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
radius="lg"
|
||||||
<CardSkeleton />
|
padding={0}
|
||||||
<CardSkeleton />
|
withBorder
|
||||||
<CardSkeleton />
|
mt="lg"
|
||||||
</SimpleGrid>
|
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||||
) : schedules.length === 0 ? (
|
>
|
||||||
<Paper radius="lg" withBorder p={48} mt="lg" bg="gray.0">
|
<Stack gap={0}>
|
||||||
<Stack align="center" gap="sm">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
<Box
|
<FleetToolbar
|
||||||
style={{
|
search={search}
|
||||||
display: "flex",
|
onSearchChange={setSearch}
|
||||||
alignItems: "center",
|
searchPlaceholder="Search schedules…"
|
||||||
justifyContent: "center",
|
viewMode={viewMode}
|
||||||
width: 64,
|
onViewModeChange={setViewMode}
|
||||||
height: 64,
|
filters={
|
||||||
borderRadius: 20,
|
<Select
|
||||||
background: "white",
|
size="sm"
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
radius="lg"
|
||||||
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
|
value={windowFilter}
|
||||||
|
onChange={(v) => 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)" } }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{viewMode === "table" ? (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paged}
|
||||||
|
status={tableStatus}
|
||||||
|
emptyMessage="No active schedules"
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.pageIndex,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount: filtered.length,
|
||||||
}}
|
}}
|
||||||
>
|
tableOptions={{
|
||||||
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
manualPagination: true,
|
||||||
</Box>
|
pageCount,
|
||||||
<Text fw={700} c="gray.7">
|
state: { pagination },
|
||||||
No active schedules
|
onPaginationChange: setPagination,
|
||||||
</Text>
|
}}
|
||||||
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
Schedules with an open booking window appear here as cards. Create or activate
|
footer={({ table, pagination: footerPagination }) => (
|
||||||
a schedule to get started.
|
<DataTableFooter
|
||||||
</Text>
|
table={table}
|
||||||
</Stack>
|
pagination={footerPagination}
|
||||||
</Paper>
|
options={{ labels: { items: "schedules" } }}
|
||||||
) : (
|
/>
|
||||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
)}
|
||||||
{schedules.map((s) => (
|
/>
|
||||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
) : isLoading ? (
|
||||||
))}
|
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||||
</SimpleGrid>
|
<CardSkeleton />
|
||||||
)}
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</SimpleGrid>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<Paper radius="lg" p={48} m="md" bg="gray.0">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: "white",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
boxShadow: "0 4px 12px rgba(15,23,42,0.06)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
||||||
|
</Box>
|
||||||
|
<Text fw={700} c="gray.7">
|
||||||
|
No active schedules
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
||||||
|
Schedules with an open booking window appear here. Create or activate a
|
||||||
|
schedule to get started.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
size="xs"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
@@ -44,6 +45,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
|
|
||||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||||
|
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
|
||||||
import {
|
import {
|
||||||
BookingPipeline,
|
BookingPipeline,
|
||||||
HeroChip,
|
HeroChip,
|
||||||
@@ -491,10 +493,20 @@ export default function BatchScheduleDetailPage() {
|
|||||||
[data],
|
[data],
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduleDetailQuery = useScheduleDetail(
|
const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER");
|
||||||
hasAssignedWagons ? scheduleId : undefined,
|
|
||||||
"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).
|
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||||
const dayGroups = useMemo(() => {
|
const dayGroups = useMemo(() => {
|
||||||
@@ -568,6 +580,8 @@ export default function BatchScheduleDetailPage() {
|
|||||||
// Date-stepper: which day is currently shown. Default to today, else the first
|
// 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.
|
// day with bookings, else the first day. Keep the selection if still valid.
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||||
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dayGroups.length) return;
|
if (!dayGroups.length) return;
|
||||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
||||||
@@ -636,8 +650,17 @@ export default function BatchScheduleDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Stack gap="lg" mt="md">
|
<Tabs value={activeTab} onChange={setActiveTab} mt="md">
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="composition">
|
||||||
|
Train Composition {scheduleDetailQuery.data?.trainSet?.wagons && scheduleDetailQuery.data.trainSet.wagons.length > 0 && `(${scheduleDetailQuery.data.trainSet.wagons.length})`}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="overview" pt="lg">
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
<Stack gap={9} style={{ minWidth: 0 }}>
|
<Stack gap={9} style={{ minWidth: 0 }}>
|
||||||
<Group gap="sm" wrap="wrap" align="center">
|
<Group gap="sm" wrap="wrap" align="center">
|
||||||
<Button
|
<Button
|
||||||
@@ -755,7 +778,6 @@ export default function BatchScheduleDetailPage() {
|
|||||||
variant="line"
|
variant="line"
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Booking pipeline */}
|
{/* Booking pipeline */}
|
||||||
<Paper
|
<Paper
|
||||||
@@ -987,7 +1009,7 @@ export default function BatchScheduleDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Train composition */}
|
{/* Train composition diagram */}
|
||||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||||
<Box mt="lg">
|
<Box mt="lg">
|
||||||
<TrainCompositionDiagram
|
<TrainCompositionDiagram
|
||||||
@@ -999,6 +1021,48 @@ export default function BatchScheduleDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="composition" pt="lg">
|
||||||
|
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||||||
|
<Group align="stretch" gap="md" wrap="nowrap">
|
||||||
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<TrainConsistView
|
||||||
|
scheduleDetail={scheduleDetailQuery.data}
|
||||||
|
scheduleId={scheduleId ?? ""}
|
||||||
|
maxWagons={53}
|
||||||
|
highlightBookingId={selectedBookingId}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box style={{ width: 380, flexShrink: 0, minHeight: 520 }}>
|
||||||
|
<CompositionBookingTabs
|
||||||
|
scheduleDetail={scheduleDetailQuery.data}
|
||||||
|
scheduleId={scheduleId ?? ""}
|
||||||
|
awaitingPayment={batchBookings.awaitingPayment}
|
||||||
|
expired={batchBookings.expired}
|
||||||
|
selectedBookingId={selectedBookingId}
|
||||||
|
onSelectBooking={setSelectedBookingId}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
withBorder
|
||||||
|
py={64}
|
||||||
|
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group justify="center">
|
||||||
|
<Loader color="green" size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading train composition…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
BatchBoardScheduleDetail,
|
BatchBoardScheduleDetail,
|
||||||
BookableSchedule,
|
BookableSchedule,
|
||||||
AssignBookingsPayload,
|
AssignBookingsPayload,
|
||||||
|
CompositionRemovalEntry,
|
||||||
|
CompositionUnassignedBooking,
|
||||||
CreateTrainSchedulePayload,
|
CreateTrainSchedulePayload,
|
||||||
EligibleContainerBookingsResponse,
|
EligibleContainerBookingsResponse,
|
||||||
FreightType,
|
FreightType,
|
||||||
@@ -350,4 +352,41 @@ export const trainSchedulingService = {
|
|||||||
country: yard.country,
|
country: yard.country,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeWagonSlot: async (scheduleId: string, wagonId: string): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.delete<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.REMOVE_WAGON_SLOT(scheduleId, wagonId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContainerItem: async (
|
||||||
|
scheduleId: string,
|
||||||
|
itemId: string,
|
||||||
|
payload: { containerNumber: string | null },
|
||||||
|
): Promise<{ id: string; containerNumber: string | null }> => {
|
||||||
|
const response = await client.patch<{ id: string; containerNumber: string | null }>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.UPDATE_CONTAINER_ITEM(scheduleId, itemId),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getUnassignedBookings: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<CompositionUnassignedBooking[]> => {
|
||||||
|
const response = await client.get<CompositionUnassignedBooking[]>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.UNASSIGNED_BOOKINGS(scheduleId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCompositionRemovals: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<CompositionRemovalEntry[]> => {
|
||||||
|
const response = await client.get<CompositionRemovalEntry[]>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.COMPOSITION_REMOVALS(scheduleId),
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -487,3 +487,23 @@ export interface PinWagonAssignment {
|
|||||||
export interface PinWagonsPayload {
|
export interface PinWagonsPayload {
|
||||||
assignments: PinWagonAssignment[];
|
assignments: PinWagonAssignment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompositionUnassignedBooking {
|
||||||
|
id: string;
|
||||||
|
reference: string | null;
|
||||||
|
freightType: FreightType | null;
|
||||||
|
priorityScore: number;
|
||||||
|
cargoTotalWeightVgm: number;
|
||||||
|
status: string | null;
|
||||||
|
schedulingStatus: SchedulingStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompositionRemovalEntry {
|
||||||
|
id: string;
|
||||||
|
scheduleId: string;
|
||||||
|
bookingId: string;
|
||||||
|
bookingReference: string | null;
|
||||||
|
removedByUserId: string | null;
|
||||||
|
removedAt: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user