add detail batch and allocation monitoring page

This commit is contained in:
Marshal
2026-06-13 18:28:39 +00:00
parent e3cd369b01
commit b73bf2154e
30 changed files with 3336 additions and 72 deletions

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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' },
});
}
}

View File

@@ -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,

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateContainerItemDto {
@IsString()
@IsOptional()
containerNumber?: string | null;
}

View File

@@ -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')

View File

@@ -144,6 +144,7 @@ describe('TrainSchedulingService', () => {
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository
);
const defaultFleetWagons = [

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 ?? []}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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),
});
},
});
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
},
};

View File

@@ -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;
}