mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +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 { TrainSchedule } from './entities/train-schedule.entity';
|
||||
import { TrainCompositionRemovalLog } from './entities/train-composition-removal-log.entity';
|
||||
import { WagonAllocationBulkLoad } from './entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from './entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from './train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from './train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from './wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from './wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
|
||||
@@ -17,6 +19,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
TypeOrmModule.forFeature([
|
||||
TrainSchedule,
|
||||
TrainScheduleBooking,
|
||||
TrainCompositionRemovalLog,
|
||||
WagonBookingAllocation,
|
||||
WagonAllocationContainerItem,
|
||||
WagonAllocationBulkLoad,
|
||||
@@ -25,6 +28,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
providers: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
@@ -32,6 +36,7 @@ import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.r
|
||||
exports: [
|
||||
TrainSchedulesRepository,
|
||||
TrainScheduleBookingsRepository,
|
||||
TrainCompositionRemovalLogRepository,
|
||||
WagonBookingAllocationsRepository,
|
||||
WagonAllocationContainerItemsRepository,
|
||||
WagonAllocationBulkLoadsRepository,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateContainerItemDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
|
||||
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
@@ -18,6 +21,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
@@ -176,8 +180,44 @@ export class TrainSchedulingController {
|
||||
unassignBooking(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainSchedulingService.unassignBooking(id, bookingId);
|
||||
return this.trainSchedulingService.unassignBooking(id, bookingId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Delete('schedules/:id/wagons/:trainSetWagonId')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Remove an empty wagon slot from a train' })
|
||||
removeWagonSlot(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('trainSetWagonId', ParseUUIDPipe) trainSetWagonId: string,
|
||||
) {
|
||||
return this.trainSchedulingService.removeTrainSetWagonSlot(id, trainSetWagonId);
|
||||
}
|
||||
|
||||
@Patch('schedules/:id/container-items/:itemId')
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: 'Update a container number on a wagon slot' })
|
||||
updateContainerItem(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: UpdateContainerItemDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateContainerItem(id, itemId, dto);
|
||||
}
|
||||
|
||||
@Get('schedules/:id/unassigned-bookings')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get unassigned bookings for a schedule' })
|
||||
getUnassignedBookings(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getUnassignedBookings(id);
|
||||
}
|
||||
|
||||
@Get('schedules/:id/composition-removals')
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: 'Get removal log for a schedule' })
|
||||
getCompositionRemovals(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getCompositionRemovals(id);
|
||||
}
|
||||
|
||||
@Post('schedules/:id/pin-wagons')
|
||||
|
||||
@@ -144,6 +144,7 @@ describe('TrainSchedulingService', () => {
|
||||
wagonAllocationContainerItemsRepository as never,
|
||||
wagonAllocationBulkLoadsRepository as never,
|
||||
trainCheckpointEventsRepository as never,
|
||||
{} as never, // trainCompositionRemovalLogRepository
|
||||
);
|
||||
|
||||
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 { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
@@ -41,6 +43,7 @@ import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
@@ -143,6 +146,7 @@ export class TrainSchedulingService {
|
||||
private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
|
||||
private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -467,7 +471,7 @@ export class TrainSchedulingService {
|
||||
return { ...detail, warnings, deferredBookings };
|
||||
}
|
||||
|
||||
async unassignBooking(scheduleId: string, bookingId: string) {
|
||||
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -481,6 +485,9 @@ export class TrainSchedulingService {
|
||||
throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
const bookingReference = booking?.reference ?? null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const allocationIds = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((w) => w.allocations ?? [])
|
||||
@@ -529,6 +536,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
await this.trainCompositionRemovalLogRepository.create({
|
||||
scheduleId,
|
||||
bookingId,
|
||||
bookingReference,
|
||||
removedByUserId: userId ?? null,
|
||||
removedAt: new Date(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
|
||||
);
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
@@ -2236,6 +2255,103 @@ export class TrainSchedulingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<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>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
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:";
|
||||
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2";
|
||||
type ViewModeSlug = FleetResourceSlug | "routes" | "train-scheduling-v2" | "batch-board";
|
||||
|
||||
const readStored = (slug: ViewModeSlug): FleetViewMode => {
|
||||
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,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
unassignedBookings: (id: string) =>
|
||||
["train-scheduling", "unassigned", id] as const,
|
||||
compositionRemovals: (id: string) =>
|
||||
["train-scheduling", "removals", id] as const,
|
||||
},
|
||||
|
||||
FLEET: {
|
||||
|
||||
@@ -190,6 +190,14 @@ export const URL_CONSTANTS = {
|
||||
SCHEDULE_BY_ID: (id: string) => `/train-scheduling/container/schedules/${id}`,
|
||||
CANCEL_SCHEDULE: (id: string) =>
|
||||
`/train-scheduling/container/schedules/${id}/cancel`,
|
||||
REMOVE_WAGON_SLOT: (scheduleId: string, wagonId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
|
||||
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
|
||||
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
|
||||
COMPOSITION_REMOVALS: (scheduleId: string) =>
|
||||
`/train-scheduling/schedules/${scheduleId}/composition-removals`,
|
||||
},
|
||||
|
||||
RULE_ENGINE: {
|
||||
|
||||
@@ -153,6 +153,12 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
|
||||
});
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.BOOKINGS.ROOT });
|
||||
};
|
||||
@@ -244,3 +250,46 @@ export const useScheduleMutations = (scheduleId?: string) => {
|
||||
invalidate,
|
||||
};
|
||||
};
|
||||
|
||||
export const useUnassignedBookings = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.unassignedBookings(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getUnassignedBookings(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
export const useCompositionRemovals = (scheduleId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId ?? ""),
|
||||
queryFn: () => trainSchedulingService.getCompositionRemovals(scheduleId!),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
export const useRemoveWagonSlot = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (wagonId: string) =>
|
||||
trainSchedulingService.removeWagonSlot(scheduleId, wagonId),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(scheduleId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateContainerItem = (scheduleId: string) => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ itemId, containerNumber }: { itemId: string; containerNumber: string | null }) =>
|
||||
trainSchedulingService.updateContainerItem(scheduleId, itemId, { containerNumber }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(scheduleId),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
@@ -17,17 +19,21 @@ import {
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Inbox,
|
||||
Package,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
Train,
|
||||
TrainFront,
|
||||
Weight,
|
||||
} from "lucide-react";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -35,6 +41,7 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor, StatTile } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useBatchBoard } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
@@ -58,6 +65,27 @@ const fmtScheduleDate = (iso: string | null) =>
|
||||
}).format(new Date(iso)) + " EAT"
|
||||
: "No date";
|
||||
|
||||
const splitDate = (iso: string | null) => {
|
||||
if (!iso) return { day: "—", time: "" };
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return { day: "—", time: "" };
|
||||
return {
|
||||
day: new Intl.DateTimeFormat("en-GB", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date),
|
||||
time:
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
}).format(date) + " EAT",
|
||||
};
|
||||
};
|
||||
|
||||
/** Capacity ring color: gold normally, red once over capacity. */
|
||||
function ringColor(pct: number) {
|
||||
if (pct >= 100) return "#fa5252";
|
||||
@@ -109,18 +137,56 @@ function CapacityRing({
|
||||
);
|
||||
}
|
||||
|
||||
/** Small percent chip used in the table's capacity column. */
|
||||
function CapacityChip({
|
||||
icon: Icon,
|
||||
pct,
|
||||
text,
|
||||
}: {
|
||||
icon: typeof Weight;
|
||||
pct: number | null;
|
||||
text: string;
|
||||
}) {
|
||||
const over = pct != null && pct >= 100;
|
||||
return (
|
||||
<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 }) {
|
||||
const navigate = useNavigate();
|
||||
const { capacity, counts, locomotive } = schedule;
|
||||
|
||||
const lengthPct =
|
||||
capacity.maxLengthMeters && capacity.maxLengthMeters > 0
|
||||
? (capacity.allocatedLengthMeters / capacity.maxLengthMeters) * 100
|
||||
: null;
|
||||
const weightPct =
|
||||
capacity.maxWeightTons && capacity.maxWeightTons > 0
|
||||
? (capacity.usedWeightTons / capacity.maxWeightTons) * 100
|
||||
: null;
|
||||
const lengthPct = lengthPctOf(schedule);
|
||||
const weightPct = weightPctOf(schedule);
|
||||
|
||||
const totalBookings = totalBookingCount(counts);
|
||||
|
||||
@@ -298,7 +364,13 @@ function CardSkeleton() {
|
||||
}
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isFetching, refetch } = useBatchBoard();
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [windowFilter, setWindowFilter] = useState("ALL");
|
||||
|
||||
const schedules = data ?? [];
|
||||
|
||||
const summary = useMemo(() => {
|
||||
@@ -308,22 +380,199 @@ export default function BatchBoardPage() {
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return schedules.filter((s) => {
|
||||
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.status,
|
||||
s.bookingWindowStatus,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedules, search, windowFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<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 (
|
||||
<Container fluid py="lg" px="xl">
|
||||
<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">
|
||||
<StatTile
|
||||
icon={Train}
|
||||
@@ -359,46 +608,121 @@ export default function BatchBoardPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p={48} mt="lg" 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)",
|
||||
<Card
|
||||
radius="lg"
|
||||
padding={0}
|
||||
withBorder
|
||||
mt="lg"
|
||||
style={{ borderColor: "var(--mantine-color-gray-2)" }}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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 as cards. Create or activate
|
||||
a schedule to get started.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" mt="lg">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={({ table, pagination: footerPagination }) => (
|
||||
<DataTableFooter
|
||||
table={table}
|
||||
pagination={footerPagination}
|
||||
options={{ labels: { items: "schedules" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
@@ -44,6 +45,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { TrainConsistView, CompositionBookingTabs } from "@/components/trainScheduling/compositionEditor";
|
||||
import {
|
||||
BookingPipeline,
|
||||
HeroChip,
|
||||
@@ -491,10 +493,20 @@ export default function BatchScheduleDetailPage() {
|
||||
[data],
|
||||
);
|
||||
|
||||
const scheduleDetailQuery = useScheduleDetail(
|
||||
hasAssignedWagons ? scheduleId : undefined,
|
||||
"CONTAINER",
|
||||
);
|
||||
const scheduleDetailQuery = useScheduleDetail(scheduleId, "CONTAINER");
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
if (!data) return { awaitingPayment: [], expired: [] };
|
||||
const all = [
|
||||
...data.windows.flatMap((w) => w.bookings),
|
||||
...data.pendingContract.bookings,
|
||||
];
|
||||
return {
|
||||
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
|
||||
expired: all.filter((b) => b.state === "EXPIRED"),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
@@ -568,6 +580,8 @@ export default function BatchScheduleDetailPage() {
|
||||
// Date-stepper: which day is currently shown. Default to today, else the first
|
||||
// day with bookings, else the first day. Keep the selection if still valid.
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!dayGroups.length) return;
|
||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
||||
@@ -636,8 +650,17 @@ export default function BatchScheduleDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Tabs value={activeTab} onChange={setActiveTab} mt="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 }}>
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
<Button
|
||||
@@ -755,7 +778,6 @@ export default function BatchScheduleDetailPage() {
|
||||
variant="line"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{/* Booking pipeline */}
|
||||
<Paper
|
||||
@@ -987,7 +1009,7 @@ export default function BatchScheduleDetailPage() {
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{/* Train composition */}
|
||||
{/* Train composition diagram */}
|
||||
{hasAssignedWagons && scheduleDetailQuery.data ? (
|
||||
<Box mt="lg">
|
||||
<TrainCompositionDiagram
|
||||
@@ -999,6 +1021,48 @@ export default function BatchScheduleDetailPage() {
|
||||
/>
|
||||
</Box>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
AssignBookingsPayload,
|
||||
CompositionRemovalEntry,
|
||||
CompositionUnassignedBooking,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
@@ -350,4 +352,41 @@ export const trainSchedulingService = {
|
||||
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 {
|
||||
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