mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1045 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -3718,6 +3718,30 @@ export class TrainSchedulingService {
|
||||
// unload each one by hand. The final station is covered by
|
||||
// arriveSchedule's bulk fallback above.
|
||||
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
|
||||
// A pass is also a position fix: the locomotives, every wagon still
|
||||
// aboard, and the built train are physically AT this yard now — not at
|
||||
// the origin they departed from. Wagons released at earlier stops no
|
||||
// longer carry this schedule id and stay where they alighted; the final
|
||||
// arrival settle still writes the wagon-movement ledger rows.
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
if (locoIds.length) {
|
||||
await manager
|
||||
.getRepository(Locomotive)
|
||||
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
|
||||
}
|
||||
await manager
|
||||
.getRepository(Wagon)
|
||||
.update(
|
||||
{ currentTrainScheduleId: scheduleId },
|
||||
{ currentYardId: station.yardId },
|
||||
);
|
||||
if (schedule.trainSet?.trainId) {
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(schedule.trainSet.trainId, { currentYardId: station.yardId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return this.getScheduleCheckpoints(scheduleId);
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
|
||||
import { BuildTrainDto } from './dto/build-train.dto';
|
||||
@@ -94,8 +97,12 @@ export class TrainBuilderController {
|
||||
@Post(':id/wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
|
||||
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
|
||||
return this.trainBuilderService.assignWagons(id, dto);
|
||||
assignWagons(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AssignTrainWagonsDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@@ -104,8 +111,9 @@ export class TrainBuilderController {
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId);
|
||||
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@@ -114,8 +122,9 @@ export class TrainBuilderController {
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
|
||||
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
|
||||
@@ -11,7 +11,11 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
|
||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
@@ -58,7 +62,10 @@ export interface ActiveScheduleRef {
|
||||
export class TrainBuilderService {
|
||||
private readonly logger = new Logger(TrainBuilderService.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
) {}
|
||||
|
||||
async buildTrain(dto: BuildTrainDto) {
|
||||
const locomotiveIds = [...new Set(dto.locomotiveIds)];
|
||||
@@ -467,19 +474,26 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/** Append AVAILABLE wagons from the train's own yard to the consist. */
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
|
||||
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const currentCount = await manager
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId: train.id } });
|
||||
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
|
||||
userId ?? null,
|
||||
train.currentYardId ?? null,
|
||||
);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string) {
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -497,6 +511,13 @@ export class TrainBuilderService {
|
||||
status: WagonStatus.Available,
|
||||
});
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -506,7 +527,7 @@ export class TrainBuilderService {
|
||||
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
|
||||
* it clears maintenance. The freed sequence gap is closed.
|
||||
*/
|
||||
async sendWagonToMaintenance(id: string, wagonId: string) {
|
||||
async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -546,6 +567,13 @@ export class TrainBuilderService {
|
||||
);
|
||||
}
|
||||
await this.resequenceWagons(manager, train.id);
|
||||
await this.syncLiveScheduleAfterConsistChange(
|
||||
manager,
|
||||
train.id,
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
yardId,
|
||||
);
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
@@ -781,6 +809,81 @@ export class TrainBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Train Builder edits a train's physical consist directly on `Wagon.trainId`
|
||||
* — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a
|
||||
* wagon added/removed here (while the train already has a live DRAFT/
|
||||
* SCHEDULED schedule) used to leave the schedule's capacity, history, and
|
||||
* booking-window status silently stale. This mirrors what
|
||||
* TrainSchedulingService.adjustScheduleConsist does when the SAME edit is
|
||||
* made from the schedule's own consist editor, so both entry points agree.
|
||||
*/
|
||||
private async syncLiveScheduleAfterConsistChange(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
): Promise<void> {
|
||||
if (!changes.length) return;
|
||||
const trainSet = await manager
|
||||
.getRepository(TrainSet)
|
||||
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
|
||||
const schedule = trainSet
|
||||
? await manager.getRepository(TrainSchedule).findOne({
|
||||
where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) },
|
||||
})
|
||||
: null;
|
||||
|
||||
const consist = await manager.getRepository(Wagon).find({
|
||||
where: { trainId },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const wagonCount = consist.length;
|
||||
const totalWeightTons = round(
|
||||
consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0),
|
||||
);
|
||||
const totalLengthMeters = round(
|
||||
consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0),
|
||||
);
|
||||
if (trainSet) {
|
||||
await manager
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||
}
|
||||
if (!schedule) return;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
const now = new Date();
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
changes.map((c) =>
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: schedule.id,
|
||||
trainId,
|
||||
action: c.action,
|
||||
wagonId: c.wagonId,
|
||||
wagonNumber: c.wagonNumber,
|
||||
adjustedByUserId: userId,
|
||||
yardId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
|
||||
// schedule reopens its booking window; filling the last one closes it.
|
||||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||||
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
|
||||
if (!usage) return;
|
||||
const nowFull = usage.remainingSlots <= 0;
|
||||
if (wasFull && !nowFull) {
|
||||
await this.bookingBatchService.refreshWindowStatus(schedule.id);
|
||||
} else if (!wasFull && nowFull) {
|
||||
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
|
||||
}
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
@@ -856,7 +959,7 @@ export class TrainBuilderService {
|
||||
train: Train,
|
||||
wagonIds: string[],
|
||||
startCount: number,
|
||||
): Promise<void> {
|
||||
): Promise<Wagon[]> {
|
||||
const uniqueIds = [...new Set(wagonIds)];
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
@@ -883,7 +986,7 @@ export class TrainBuilderService {
|
||||
}
|
||||
toAttach.push(wagon);
|
||||
}
|
||||
if (!toAttach.length) return;
|
||||
if (!toAttach.length) return [];
|
||||
|
||||
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
|
||||
|
||||
@@ -896,6 +999,7 @@ export class TrainBuilderService {
|
||||
status: WagonStatus.Assigned,
|
||||
});
|
||||
}
|
||||
return toAttach;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// apps/edr-freight-api/src/modules/trains/trains.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import { TrainBuilderController } from './train-builder.controller';
|
||||
@@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller';
|
||||
import { TrainsService } from './trains.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
|
||||
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule],
|
||||
controllers: [TrainsController, TrainBuilderController],
|
||||
providers: [TrainsService, TrainBuilderService],
|
||||
exports: [TrainsService, TrainBuilderService],
|
||||
|
||||
@@ -34,6 +34,10 @@ interface LegBookingUsage {
|
||||
reference: string;
|
||||
wagons: number;
|
||||
grossTons: number;
|
||||
/** Linked to the schedule but has NO wagon allocation — its weight is on no consist slot. */
|
||||
unallocated?: boolean;
|
||||
/** The booking's own origin → destination, so a sub-leg booking reads as such. */
|
||||
route?: string | null;
|
||||
}
|
||||
|
||||
interface EdgeUsage {
|
||||
@@ -45,6 +49,8 @@ interface EdgeUsage {
|
||||
lengthMeters: number;
|
||||
bookingRefs: string[];
|
||||
bookings: LegBookingUsage[];
|
||||
/** Gross tons of linked-but-unallocated bookings riding this leg — not yet on any slot. */
|
||||
pendingTons: number;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
@@ -106,9 +112,33 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
if (stops.length < 2) return [];
|
||||
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
|
||||
const lastIdx = stops.length - 1;
|
||||
const spans = wagons.map((w) =>
|
||||
spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx),
|
||||
);
|
||||
// Booking legs, for slot-span fallback and for labelling dropdown rows.
|
||||
const bookingById = new Map((schedule.bookings ?? []).map((b) => [b.id, b]));
|
||||
const bookingSpan = (bookingId: string) => {
|
||||
const b = bookingById.get(bookingId);
|
||||
if (!b) return null;
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const toRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
|
||||
const to = toRaw != null && toRaw > from ? toRaw : lastIdx;
|
||||
return { from, to };
|
||||
};
|
||||
// A slot rides its stamped board→alight span. Slots without a stamp (an
|
||||
// API build predating the fields, or plans written before spans existed)
|
||||
// fall back to the union of their OWN bookings' legs — an a→b-only wagon
|
||||
// must not count on the b→c leg. Empty stamped-less wagons ride everything.
|
||||
const spans = wagons.map((w) => {
|
||||
if (w.boardYardId || w.alightYardId) {
|
||||
return spanOf(w.boardYardId, w.alightYardId, indexOf, lastIdx);
|
||||
}
|
||||
const legs = (w.allocations ?? [])
|
||||
.map((a) => bookingSpan(a.bookingId))
|
||||
.filter((s): s is { from: number; to: number } => s != null);
|
||||
if (!legs.length) return { from: 0, to: lastIdx };
|
||||
return {
|
||||
from: Math.min(...legs.map((s) => s.from)),
|
||||
to: Math.max(...legs.map((s) => s.to)),
|
||||
};
|
||||
});
|
||||
return stops.slice(0, -1).map((from, edge) => {
|
||||
const active = wagons.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
@@ -128,11 +158,16 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
for (const a of w.allocations ?? []) {
|
||||
if (!a.bookingReference) continue;
|
||||
refs.add(a.bookingReference);
|
||||
const linked = bookingById.get(a.bookingId);
|
||||
const row = byBooking.get(a.bookingId) ?? {
|
||||
bookingId: a.bookingId,
|
||||
reference: a.bookingReference,
|
||||
wagons: 0,
|
||||
grossTons: 0,
|
||||
route:
|
||||
linked?.origin && linked?.destination
|
||||
? `${linked.origin} → ${linked.destination}`
|
||||
: null,
|
||||
};
|
||||
row.grossTons += Number(a.allocatedWeightTons) || 0;
|
||||
byBooking.set(a.bookingId, row);
|
||||
@@ -143,9 +178,35 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
if (row) row.wagons += 1;
|
||||
}
|
||||
}
|
||||
// Linked bookings with NO wagon allocation ride their leg too — without
|
||||
// this they vanish from the tab entirely (two Dire→DCT bookings hidden
|
||||
// while a through booking showed alone). Flagged so staff see the gap;
|
||||
// their tonnage is deliberately NOT in the leg totals, which reflect
|
||||
// what is physically on consist slots.
|
||||
for (const b of schedule.bookings ?? []) {
|
||||
if (byBooking.has(b.id)) continue;
|
||||
const bFrom = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const bToRaw = b.destinationYardId ? indexOf.get(b.destinationYardId) : lastIdx;
|
||||
const bTo = bToRaw != null && bToRaw > bFrom ? bToRaw : lastIdx;
|
||||
if (!(bFrom <= edge && edge < bTo)) continue;
|
||||
const reference = b.reference ?? b.id;
|
||||
refs.add(reference);
|
||||
byBooking.set(b.id, {
|
||||
bookingId: b.id,
|
||||
reference,
|
||||
wagons: Number(b.wagonsRequired) || 0,
|
||||
grossTons: Number(b.weightTons) || 0,
|
||||
unallocated: true,
|
||||
route:
|
||||
b.origin && b.destination ? `${b.origin} → ${b.destination}` : null,
|
||||
});
|
||||
}
|
||||
const bookings = [...byBooking.values()]
|
||||
.map((b) => ({ ...b, grossTons: round1(b.grossTons) }))
|
||||
.sort((a, b) => b.grossTons - a.grossTons);
|
||||
const pendingTons = round1(
|
||||
bookings.filter((b) => b.unallocated).reduce((sum, b) => sum + b.grossTons, 0),
|
||||
);
|
||||
return {
|
||||
edge,
|
||||
from,
|
||||
@@ -155,9 +216,21 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
lengthMeters: round1(lengthMeters),
|
||||
bookingRefs: [...refs],
|
||||
bookings,
|
||||
pendingTons,
|
||||
};
|
||||
});
|
||||
}, [stops, wagons]);
|
||||
}, [stops, wagons, schedule.bookings]);
|
||||
|
||||
const unallocatedRefs = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
edges.flatMap((e) =>
|
||||
e.bookings.filter((b) => b.unallocated).map((b) => b.reference),
|
||||
),
|
||||
),
|
||||
],
|
||||
[edges],
|
||||
);
|
||||
|
||||
if (stops.length < 2) {
|
||||
return (
|
||||
@@ -210,6 +283,14 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
|
||||
return (
|
||||
<Stack gap="lg" mt="lg">
|
||||
{unallocatedRefs.length ? (
|
||||
<Alert radius="lg" color="orange" icon={<Info size={16} />}>
|
||||
{unallocatedRefs.join(", ")}{" "}
|
||||
{unallocatedRefs.length === 1 ? "is" : "are"} linked to this train but
|
||||
have no wagons allocated — their weight is not on any leg yet. Re-run
|
||||
allocation (or add them from the workspace) to place them.
|
||||
</Alert>
|
||||
) : null}
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
@@ -267,6 +348,11 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.grossTons} cap={weightCap} unit="T" />
|
||||
{e.pendingTons > 0 ? (
|
||||
<Text size="xs" c="orange.8" fw={600}>
|
||||
+{e.pendingTons}T unallocated
|
||||
</Text>
|
||||
) : null}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<UsageCell used={e.lengthMeters} cap={lengthCap} unit="m" />
|
||||
@@ -303,21 +389,36 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
|
||||
<Table.Tbody>
|
||||
{e.bookings.map((b) => (
|
||||
<Table.Tr key={b.bookingId}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="sm" fw={500}>
|
||||
<Table.Td w="50%">
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
|
||||
{b.reference}
|
||||
{b.route ? (
|
||||
<Text span size="xs" c="dimmed">
|
||||
{" "}
|
||||
({b.route})
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{b.unallocated ? (
|
||||
<Badge size="xs" color="orange" variant="filled">
|
||||
no wagons
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w="30%">
|
||||
<Group gap={4}>
|
||||
<Table.Td w="25%">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Train size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.wagons} wagon{b.wagons === 1 ? "" : "s"}
|
||||
{b.wagons > 0 ? b.wagons : "—"} wagon
|
||||
{b.wagons === 1 ? "" : "s"}
|
||||
{b.unallocated && b.wagons > 0 ? " needed" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w="30%">
|
||||
<Group gap={4}>
|
||||
<Table.Td w="25%">
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Weight size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{b.grossTons}T
|
||||
|
||||
Reference in New Issue
Block a user