mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge pull request #734 from Tria-plc/freight_feature/usermanagement
fix u=issue
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin maintenance reschedule: move a train's departure to a new date/time.
|
||||||
|
* Every allocated booking rides along (links and wagon assignments untouched);
|
||||||
|
* only the dates move — the schedule's train set, route, and window rule
|
||||||
|
* snapshot all stay exactly as they were.
|
||||||
|
*/
|
||||||
|
export class MaintenanceRescheduleDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: '2026-07-20T05:00:00.000Z',
|
||||||
|
description: 'New scheduled departure date/time (ISO 8601)',
|
||||||
|
})
|
||||||
|
@IsISO8601()
|
||||||
|
newDepartureDate!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Why the train is being moved (logged)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trigger?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
"The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings",
|
||||||
|
type: [String],
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
incomingBookingIds?: string[];
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
|
|||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||||
|
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "./booking-batch.service";
|
||||||
import { BookingJourneyService } from "./booking-journey.service";
|
import { BookingJourneyService } from "./booking-journey.service";
|
||||||
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
|
|||||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("schedules/:id/maintenance")
|
||||||
|
@TrainSchedulingManage()
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||||
|
})
|
||||||
|
async maintenanceReschedule(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: MaintenanceRescheduleDto,
|
||||||
|
) {
|
||||||
|
await this.trainSchedulingService.maintenanceReschedule(id, dto);
|
||||||
|
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("schedules/:id/doc-review-complete")
|
@Post("schedules/:id/doc-review-complete")
|
||||||
@TrainSchedulingManage()
|
@TrainSchedulingManage()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||||
|
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { type BookingWindowConfig } from './booking-window.config';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
|
|||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||||
|
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||||
|
* phase and inside the booking lead window — a maintenance move is an
|
||||||
|
* operational fact, not a planning choice. What moves and what stays:
|
||||||
|
*
|
||||||
|
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||||
|
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||||
|
* so a booking left on the old day would fall out of its own train's pool).
|
||||||
|
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||||
|
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||||||
|
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||||
|
* schedule mid- or post-window keeps its timeline untouched.
|
||||||
|
*
|
||||||
|
* Customers of every moved booking are notified (maintenanceMoved).
|
||||||
|
*/
|
||||||
|
async maintenanceReschedule(
|
||||||
|
id: string,
|
||||||
|
dto: MaintenanceRescheduleDto,
|
||||||
|
): Promise<TrainSchedule> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
|
}
|
||||||
|
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const departure = new Date(dto.newDepartureDate);
|
||||||
|
if (Number.isNaN(departure.getTime())) {
|
||||||
|
throw new BadRequestException('Invalid departure date.');
|
||||||
|
}
|
||||||
|
if (departure.getTime() <= Date.now()) {
|
||||||
|
throw new BadRequestException('New departure must be in the future.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMs =
|
||||||
|
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||||
|
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||||
|
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// PRE_WINDOW only: the stamped open/close were derived from the old
|
||||||
|
// departure and the window hasn't opened yet, so re-derive them from the
|
||||||
|
// schedule's own rule snapshot against the new date (joining the target
|
||||||
|
// day's route group timeline when one exists, exactly like
|
||||||
|
// updateScheduleDate). Mid/post-window schedules keep their timeline.
|
||||||
|
const windowFields =
|
||||||
|
schedule.windowPhase === 'PRE_WINDOW'
|
||||||
|
? await (async () => {
|
||||||
|
const merged = effectiveWindowConfig(
|
||||||
|
schedule,
|
||||||
|
await this.getWindowConfig(),
|
||||||
|
);
|
||||||
|
const times =
|
||||||
|
schedule.direction === 'EXPORT'
|
||||||
|
? computeExportWindowTimes(departure, merged)
|
||||||
|
: computeImportWindowTimes(departure, merged, new Date());
|
||||||
|
const anchor =
|
||||||
|
schedule.direction === 'EXPORT'
|
||||||
|
? null
|
||||||
|
: await this.findGroupWindowAnchor(
|
||||||
|
this.dataSource.manager,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
departure,
|
||||||
|
);
|
||||||
|
return anchor
|
||||||
|
? this.groupWindowFieldsFrom(anchor, departure)
|
||||||
|
: {
|
||||||
|
windowOpensAt: times.windowOpensAt,
|
||||||
|
windowClosesAt: times.windowClosesAt,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: {};
|
||||||
|
|
||||||
|
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||||
|
scheduledDepartureDate: departure,
|
||||||
|
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
|
||||||
|
...windowFields,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Everything aboard or targeted rides along: bookings linked on the train
|
||||||
|
// (schedule_bookings) plus reservations still pointing at it via
|
||||||
|
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
|
||||||
|
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
|
||||||
|
const targeted = await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
|
||||||
|
relations: { company: true },
|
||||||
|
});
|
||||||
|
const aboard = targeted.filter(
|
||||||
|
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
|
||||||
|
);
|
||||||
|
if (aboard.length) {
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||||
|
for (const booking of aboard) {
|
||||||
|
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
|
||||||
|
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
|
||||||
|
`${aboard.length} booking(s) moved with the train.`,
|
||||||
|
);
|
||||||
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
|
return fresh ?? schedule;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||||
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
|
|||||||
createdAt: schedule.createdAt ?? null,
|
createdAt: schedule.createdAt ?? null,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
direction: schedule.direction ?? null,
|
||||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination:
|
destination:
|
||||||
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
|
|||||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||||
|
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||||
|
// included (the pull-limit check already counts their tare) — so append the
|
||||||
|
// train's remaining wagons as consist-only entries and the composition views
|
||||||
|
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
|
||||||
|
// really is: loaded slots first, then the empty consist. Skipped for frozen
|
||||||
|
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
|
||||||
|
// later trains, so the live consist no longer describes THIS departure.
|
||||||
|
const coveredPhysicalIds = new Set<string>();
|
||||||
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
|
const frozenSlot = isWagonAllocationFrozen
|
||||||
|
? snapshotSlotByTrainSetWagonId.get(slot.id)
|
||||||
|
: undefined;
|
||||||
|
const physicalId = frozenSlot
|
||||||
|
? frozenSlot.physicalWagonId
|
||||||
|
: slot.physicalWagonId ?? null;
|
||||||
|
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||||
|
}
|
||||||
|
const maxSlotSequenceNo = Math.max(
|
||||||
|
0,
|
||||||
|
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||||
|
);
|
||||||
|
const emptyConsistWagons =
|
||||||
|
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||||
|
? (
|
||||||
|
await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: { trainId: schedule.trainSet.trainId },
|
||||||
|
relations: { wagonType: true },
|
||||||
|
order: { sequenceNumber: 'ASC' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||||
|
.map((wagon, index) => ({
|
||||||
|
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||||
|
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||||
|
id: wagon.id,
|
||||||
|
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||||
|
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||||
|
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||||
|
assignedWeightTons: 0,
|
||||||
|
tareWeightTons: wagon.wagonType
|
||||||
|
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||||
|
: null,
|
||||||
|
status: 'EMPTY',
|
||||||
|
physicalWagonId: wagon.id,
|
||||||
|
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||||
|
wagonType: wagon.wagonType
|
||||||
|
? {
|
||||||
|
id: wagon.wagonType.id,
|
||||||
|
code: wagon.wagonType.code,
|
||||||
|
name: wagon.wagonType.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
allocations: [],
|
||||||
|
consistOnly: true,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
|
|||||||
: null,
|
: null,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
|
.concat(emptyConsistWagons),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
bookings:
|
bookings:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -642,6 +643,20 @@ export default function GlCreateBookingForm() {
|
|||||||
});
|
});
|
||||||
}, [isContainer, contract, containerLines, contractWithReturn]);
|
}, [isContainer, contract, containerLines, contractWithReturn]);
|
||||||
|
|
||||||
|
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||||
|
// the booking can never be planned. The server rejects it too (the price
|
||||||
|
// modal's `pairingErrors`), but that only lands after GL has filled the whole
|
||||||
|
// form — mirror the customer portal (new-booking-form/schema.ts `calcWagons`)
|
||||||
|
// and block it inline instead. Size strings arrive as "20ft" from the contract
|
||||||
|
// scope but as a bare "20" from the rebook seed, so match on the leading digits.
|
||||||
|
const ft20Total = useMemo(() => {
|
||||||
|
if (!isContainer) return 0;
|
||||||
|
return containerLines
|
||||||
|
.filter((l) => parseInt(l.containerSize, 10) === 20)
|
||||||
|
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||||
|
}, [isContainer, containerLines]);
|
||||||
|
const hasOdd20ft = ft20Total % 2 === 1;
|
||||||
|
|
||||||
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
||||||
|
|
||||||
const bulkErrors = useMemo<BulkErrors>(() => {
|
const bulkErrors = useMemo<BulkErrors>(() => {
|
||||||
@@ -688,7 +703,7 @@ export default function GlCreateBookingForm() {
|
|||||||
)
|
)
|
||||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||||
|
|
||||||
const formValid = cargoValid && !dateError && !routeError;
|
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||||
|
|
||||||
/** The create-booking DTO from the current form state — shared by the
|
/** The create-booking DTO from the current form state — shared by the
|
||||||
* authoritative price preview and the actual submit so what GL confirms is
|
* authoritative price preview and the actual submit so what GL confirms is
|
||||||
@@ -1286,6 +1301,21 @@ export default function GlCreateBookingForm() {
|
|||||||
</Box>
|
</Box>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasOdd20ft ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title={`Odd number of 20ft containers (${ft20Total})`}
|
||||||
|
>
|
||||||
|
20ft containers travel two per wagon, so they must be booked in
|
||||||
|
even numbers. Add one more 20ft container or remove one (e.g.
|
||||||
|
book {ft20Total + 1} or {ft20Total - 1} instead of {ft20Total})
|
||||||
|
— the booking cannot be created with an unpaired 20ft container.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
) : (
|
) : (
|
||||||
@@ -1530,14 +1560,27 @@ export default function GlCreateBookingForm() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button
|
<Tooltip
|
||||||
color="edr-green"
|
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||||
radius="md"
|
withArrow
|
||||||
leftSection={<Receipt size={16} />}
|
disabled={!hasOdd20ft}
|
||||||
onClick={openPriceModal}
|
|
||||||
>
|
>
|
||||||
Review price & book
|
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||||
</Button>
|
so the wrapper carries the hover target. */}
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
|
onClick={openPriceModal}
|
||||||
|
// Same hard block the customer portal applies at review time —
|
||||||
|
// an unpaired 20ft can never be planned onto a wagon.
|
||||||
|
disabled={hasOdd20ft}
|
||||||
|
>
|
||||||
|
Review price & book
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function PinWagonsForm({
|
|||||||
autoFillOnMount?: boolean;
|
autoFillOnMount?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const originYardId = schedule.originStation?.id;
|
const originYardId = schedule.originStation?.id;
|
||||||
const slots = schedule.trainSet?.wagons ?? [];
|
// Consist-only rows are the built train's coupled-but-empty wagons — display
|
||||||
|
// entries with no TrainSetWagon slot behind them, so nothing can be pinned.
|
||||||
|
const slots = (schedule.trainSet?.wagons ?? []).filter((w) => !w.consistOnly);
|
||||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const wagonOptionsByType = useMemo(() => {
|
const wagonOptionsByType = useMemo(() => {
|
||||||
|
|||||||
@@ -359,7 +359,9 @@ function WagonCar({
|
|||||||
|
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Empty slot — available for allocation.
|
{wagon.consistOnly
|
||||||
|
? "Empty wagon — coupled on the train, no load planned."
|
||||||
|
: "Empty slot — available for allocation."}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ export const WagonCard = ({
|
|||||||
<TrainFront size={18} />
|
<TrainFront size={18} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Empty slot
|
{wagon.consistOnly ? "Empty wagon — coupled on the train" : "Empty slot"}
|
||||||
</Text>
|
</Text>
|
||||||
{!isDispatched ? (
|
{!isDispatched && !wagon.consistOnly ? (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
|||||||
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
import BuildTrainModal from "@/components/trainBuilder/BuildTrainModal";
|
||||||
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
import EditTrainDetailsModal from "@/components/trainBuilder/EditTrainDetailsModal";
|
||||||
import {
|
import {
|
||||||
directionColor,
|
|
||||||
directionRowStyle,
|
|
||||||
trainStatusColor,
|
trainStatusColor,
|
||||||
trainStatusLabel,
|
trainStatusLabel,
|
||||||
} from "@/components/trainBuilder/trainStatus";
|
} from "@/components/trainBuilder/trainStatus";
|
||||||
@@ -164,14 +162,9 @@ export default function TrainBuilderListPage() {
|
|||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{active?.trainNumber ? (
|
{active?.trainNumber ? (
|
||||||
<Group gap={6} wrap="nowrap">
|
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
||||||
<Text size="sm" fw={700} ff="monospace" lh={1.2}>
|
{active.trainNumber}
|
||||||
{active.trainNumber}
|
</Text>
|
||||||
</Text>
|
|
||||||
<Badge size="xs" variant="light" color={directionColor(active.direction)}>
|
|
||||||
{active.direction ?? "—"}
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
) : null}
|
) : null}
|
||||||
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
|
<Text size="xs" c="dimmed" ff="monospace" lh={1.2}>
|
||||||
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
|
IMP {row.original.importTrainNumber ?? "—"} · EXP{" "}
|
||||||
@@ -348,7 +341,6 @@ export default function TrainBuilderListPage() {
|
|||||||
data={trains}
|
data={trains}
|
||||||
status={tableStatus}
|
status={tableStatus}
|
||||||
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
onRowClick={(train) => navigate(`/dashboard/train-builder/${train.id}`)}
|
||||||
rowStyle={(train) => directionRowStyle(train.activeSchedule?.direction)}
|
|
||||||
error={
|
error={
|
||||||
trainsQuery.isError
|
trainsQuery.isError
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ColumnDef } from "@edr/ui-common";
|
import type { ColumnDef } from "@edr/ui-common";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -38,6 +39,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|||||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
|
import {
|
||||||
|
directionColor,
|
||||||
|
directionRowStyle,
|
||||||
|
} from "@/components/trainBuilder/trainStatus";
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||||
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
import { showScheduleWarnings } from "@/components/trainScheduling/locomotiveOptions";
|
||||||
@@ -303,9 +308,20 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Text size="sm" fw={600} lh={1.2}>
|
<Group gap={6} wrap="nowrap">
|
||||||
{row.original.routeName ?? "—"}
|
<Text size="sm" fw={600} lh={1.2}>
|
||||||
</Text>
|
{row.original.routeName ?? "—"}
|
||||||
|
</Text>
|
||||||
|
{row.original.direction ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={directionColor(row.original.direction)}
|
||||||
|
>
|
||||||
|
{row.original.direction}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
<Box maw={220}>
|
<Box maw={220}>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
origin={row.original.origin}
|
origin={row.original.origin}
|
||||||
@@ -660,6 +676,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onRowClick={(schedule) =>
|
onRowClick={(schedule) =>
|
||||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||||
}
|
}
|
||||||
|
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
||||||
error={
|
error={
|
||||||
schedulesQuery.isError
|
schedulesQuery.isError
|
||||||
? {
|
? {
|
||||||
@@ -909,7 +926,18 @@ function ScheduleCard({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<FreightTypeBadge freightType={schedule.freightType} />
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<FreightTypeBadge freightType={schedule.freightType} />
|
||||||
|
{schedule.direction ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={directionColor(schedule.direction)}
|
||||||
|
>
|
||||||
|
{schedule.direction}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||||
<MetricChip value={schedule.wagonCount} label="wgn" />
|
<MetricChip value={schedule.wagonCount} label="wgn" />
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export interface TrainScheduleListItem {
|
|||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
scheduleDate: string;
|
scheduleDate: string;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
|
/** Trade direction of this departure (IMPORT / EXPORT), when known. */
|
||||||
|
direction?: string | null;
|
||||||
routeName?: string | null;
|
routeName?: string | null;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
@@ -608,6 +610,11 @@ export interface TrainScheduleDetail {
|
|||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
allocations: TrainScheduleWagonAllocation[];
|
allocations: TrainScheduleWagonAllocation[];
|
||||||
|
/**
|
||||||
|
* Coupled-but-empty wagon of the built train — no TrainSetWagon slot
|
||||||
|
* behind it, so remove/edit actions do not apply.
|
||||||
|
*/
|
||||||
|
consistOnly?: boolean;
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
bookings: Array<{
|
bookings: Array<{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -269,6 +270,19 @@ function NewShipmentBookingForm({
|
|||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 20ft containers ride two per wagon, so an odd total leaves one unpaired and
|
||||||
|
// the booking can never be planned. The server's shipment validation reports
|
||||||
|
// it too, but only once the price modal opens — block it inline instead, the
|
||||||
|
// same way the direct-booking wizard does (new-booking-form `calcWagons`).
|
||||||
|
const watchedContainers = form.watch("containers");
|
||||||
|
const ft20Total =
|
||||||
|
contract.freightType === "CONTAINER"
|
||||||
|
? (watchedContainers ?? [])
|
||||||
|
.filter((l) => l.containerSize === "20ft")
|
||||||
|
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
|
||||||
|
: 0;
|
||||||
|
const hasOdd20ft = ft20Total % 2 === 1;
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
mutationFn: (dto: Freight.CreateBookingUnderContractDto) =>
|
||||||
completeBookingId
|
completeBookingId
|
||||||
@@ -367,6 +381,8 @@ function NewShipmentBookingForm({
|
|||||||
// run it for every freight type; container contracts additionally get
|
// run it for every freight type; container contracts additionally get
|
||||||
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
||||||
const handleReview = form.handleSubmit((values) => {
|
const handleReview = form.handleSubmit((values) => {
|
||||||
|
// An unpaired 20ft can never be planned onto a wagon — don't even price it.
|
||||||
|
if (hasOdd20ft) return;
|
||||||
setPendingValues(values);
|
setPendingValues(values);
|
||||||
validateMutation.reset();
|
validateMutation.reset();
|
||||||
validateMutation.mutate(buildDto(values));
|
validateMutation.mutate(buildDto(values));
|
||||||
@@ -483,15 +499,26 @@ function NewShipmentBookingForm({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
<Group justify="flex-end" className="mx-auto max-w-4xl">
|
||||||
<Button
|
<Tooltip
|
||||||
type="button"
|
label={`Book an even number of 20ft containers — ${ft20Total} is odd and would leave one unpaired.`}
|
||||||
color="edr-green"
|
withArrow
|
||||||
radius="md"
|
disabled={!hasOdd20ft}
|
||||||
leftSection={<Receipt size={16} />}
|
|
||||||
onClick={handleReview}
|
|
||||||
>
|
>
|
||||||
Review price & book
|
{/* Mantine tooltips get no pointer events from a disabled button,
|
||||||
</Button>
|
so the wrapper carries the hover target. */}
|
||||||
|
<Box>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Receipt size={16} />}
|
||||||
|
onClick={handleReview}
|
||||||
|
disabled={hasOdd20ft}
|
||||||
|
>
|
||||||
|
Review price & book
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
</form>
|
</form>
|
||||||
@@ -1265,6 +1292,28 @@ function CargoStep({
|
|||||||
This contract has no container sizes in scope.
|
This contract has no container sizes in scope.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{(() => {
|
||||||
|
const ft20 = lines
|
||||||
|
.filter((l) => l.containerSize === "20ft")
|
||||||
|
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||||
|
if (ft20 % 2 !== 1) return null;
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title={`Odd number of 20ft containers (${ft20})`}
|
||||||
|
>
|
||||||
|
<Text fz={13}>
|
||||||
|
20ft containers travel two per wagon, so they must be booked in
|
||||||
|
even numbers. Please add one more 20ft container or remove one
|
||||||
|
(e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — the
|
||||||
|
booking cannot be submitted with an unpaired 20ft container.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowLeft, CalendarDays, Send } from "lucide-react";
|
import { AlertCircle, ArrowLeft, CalendarDays, Send } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
@@ -98,7 +99,14 @@ export default function NewShipmentRequestPage() {
|
|||||||
contract.cargoScope?.[0];
|
contract.cargoScope?.[0];
|
||||||
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
|
||||||
|
|
||||||
|
// 20ft containers ride two per wagon, so an odd total can never be planned —
|
||||||
|
// and GL's create-booking form blocks it too, so an odd request would only
|
||||||
|
// dead-end there. Same even-number rule the booking forms apply.
|
||||||
|
const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0;
|
||||||
|
const hasOdd20ft = ft20Requested % 2 === 1;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (hasOdd20ft) return;
|
||||||
const dto: Freight.CreateBookingRequestDto = {
|
const dto: Freight.CreateBookingRequestDto = {
|
||||||
contractRouteId: route?.id,
|
contractRouteId: route?.id,
|
||||||
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
scheduledDate: hasCustoms ? undefined : scheduledDate || undefined,
|
||||||
@@ -202,6 +210,23 @@ export default function NewShipmentRequestPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasOdd20ft ? (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
icon={<AlertCircle size={16} />}
|
||||||
|
title={`Odd number of 20ft containers (${ft20Requested})`}
|
||||||
|
>
|
||||||
|
<Text fz={13}>
|
||||||
|
20ft containers travel two per wagon, so they must be requested
|
||||||
|
in even numbers. Please add one more 20ft container or remove
|
||||||
|
one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "}
|
||||||
|
instead of {ft20Requested}).
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{capacity?.length ? (
|
{capacity?.length ? (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Remaining capacity is shown on the contract — GL will validate your request.
|
Remaining capacity is shown on the contract — GL will validate your request.
|
||||||
@@ -220,6 +245,7 @@ export default function NewShipmentRequestPage() {
|
|||||||
leftSection={<Send size={16} />}
|
leftSection={<Send size={16} />}
|
||||||
loading={submit.isPending}
|
loading={submit.isPending}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
|
disabled={hasOdd20ft}
|
||||||
>
|
>
|
||||||
Submit shipment request
|
Submit shipment request
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user