wagon work space, container validation

This commit is contained in:
Marshal
2026-07-10 23:29:28 +00:00
parent 5d8658b5d6
commit dd87c2a722
12 changed files with 878 additions and 47 deletions

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
@@ -192,6 +193,11 @@ export class ContractBookingService {
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(dto, {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -575,6 +581,15 @@ export class ContractBookingService {
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(
dto,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
@@ -653,6 +668,10 @@ export class ContractBookingService {
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
// numbers must be free on the newly chosen train day too.
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
@@ -1344,6 +1363,131 @@ export class ContractBookingService {
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
/**
* A physical container rides one train only. Reject the submission when a
* container number is entered twice in the same booking (the portal checks
* this client-side, the API must not trust it) or already sits on another
* customer's active booking for the same train — same shipment day AND same
* route (origin/destination yards).
*/
private async assertContainerNumbersAvailable(
dto: CreateBookingUnderContractDto,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const numbers = (dto.containers ?? []).flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
if (!numbers.length) return;
const seen = new Set<string>();
const withinBooking = new Set<string>();
for (const n of numbers) {
if (seen.has(n)) withinBooking.add(n);
seen.add(n);
}
if (withinBooking.size) {
throw new BadRequestException(
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
);
}
// Intercity bookings have no shipment day yet — nothing to clash with.
if (!dto.scheduledDate) return;
await this.assertNumbersFreeOnTrain(
numbers,
dto.scheduledDate,
route,
excludeBookingId,
);
}
/**
* Same train guard for a booking whose containers are already persisted
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
* stored numbers must be free on the newly chosen day for its route.
*/
private async assertPersistedContainersAvailable(
booking: Booking,
scheduledDate: string,
): Promise<void> {
const rows: Array<{ containerNumber: string }> = await this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.select('unit.container_number', 'containerNumber')
.where('line.booking_id = :bookingId', { bookingId: booking.id })
.getRawMany();
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
if (!numbers.length) return;
await this.assertNumbersFreeOnTrain(
numbers,
scheduledDate,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
}
/**
* Reject when any of `numbers` sits on another active booking of the same
* train — same day and same route. Bookings without route yards (legacy
* rows) are matched on the day alone rather than let through.
*/
private async assertNumbersFreeOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
.select('unit.container_number', 'containerNumber')
.addSelect('b.reference', 'reference')
.where('unit.container_number IN (:...numbers)', { numbers })
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.andWhere('b.deleted_at IS NULL');
if (route.originYardId && route.destinationYardId) {
// Same train = same day + same corridor. A clashing booking whose yards
// were never denormalized still blocks (NULL yards match any route).
qb.andWhere(
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
{ originYardId: route.originYardId },
).andWhere(
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
{ destinationYardId: route.destinationYardId },
);
}
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {

View File

@@ -0,0 +1,12 @@
import { WagonStatus } from '@edr/types';
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
export class BulkSetWagonStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsEnum(WagonStatus)
status!: WagonStatus;
}

View File

@@ -0,0 +1,11 @@
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class BulkTransferWagonsDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsUUID()
toYardId!: string;
}

View File

@@ -10,12 +10,16 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@ApiTags('wagons')
@@ -78,6 +82,20 @@ export class WagonsController {
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
}
@Post('bulk-transfer')
@FleetManage()
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkTransfer(dto, user?.id);
}
@Post('bulk-status')
@FleetManage()
@ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto);
}
}
// Separate controller for trainspecific reorder (registered in module)

View File

@@ -1,15 +1,18 @@
import { WagonMovementKind, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@Injectable()
export class WagonsService {
@@ -168,6 +171,101 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
/**
* Relocate many wagons to one destination yard in a single transaction. Each
* wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
* `Manual`) so the yard history stays auditable — mirrors the single-wagon
* `update` path. Wagons already in the destination yard are skipped.
*/
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
const yard = await this.dataSource
.getRepository(Yard)
.findOne({ where: { id: toYardId } });
if (!yard) throw new NotFoundException('Destination yard not found');
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
let moved = 0;
for (const wagon of wagons) {
const previousYardId = wagon.currentYardId ?? null;
if (previousYardId === toYardId) continue;
wagon.currentYardId = toYardId;
// Drop the eager relation so the scalar FK wins on save (see `update`).
wagon.currentYard = null;
await queryRunner.manager.save(Wagon, wagon);
await queryRunner.manager.save(
queryRunner.manager.create(WagonMovement, {
wagonId: wagon.id,
fromYardId: previousYardId,
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
moved++;
}
await queryRunner.commitTransaction();
return { moved };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* Set the same status on many wagons in one transaction (e.g. flip a batch
* from Available to Assigned in the yard workspace). Only the `status` column
* is touched — train assignment is managed through the assign/unassign flow.
*/
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
const { wagonIds, status } = dto;
if (!wagonIds.length) return { updated: 0 };
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
for (const wagon of wagons) {
wagon.status = status;
}
await queryRunner.manager.save(Wagon, wagons);
await queryRunner.commitTransaction();
return { updated: wagons.length };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();

View File

@@ -0,0 +1,441 @@
import { Freight } from "@edr/types";
import {
Badge,
Box,
Button,
Card,
Divider,
Grid,
Group,
Loader,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { Wagon } from "@/services/wagon.service";
export interface WagonYardWorkspaceModalProps {
opened: boolean;
onClose: () => void;
}
const numberOrZero = (v: number | string): number => {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
};
/**
* Yard workspace: pick a yard + wagon type (the two selects filter each other
* to only in-inventory combinations), see how many wagons of that type sit in
* that yard and how they split Available / Assigned, then bulk-transfer a
* quantity to another yard or flip a quantity between Available and Assigned.
*/
const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => {
const { toast } = useToast();
const { data: wagons = [], isLoading: wagonsLoading } = useQuery(
api.wagons.list.queryOptions({ input: {} }),
);
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions());
const [yardId, setYardId] = useState<string | null>(null);
const [typeId, setTypeId] = useState<string | null>(null);
const [transferYardId, setTransferYardId] = useState<string | null>(null);
const [transferQty, setTransferQty] = useState<number | string>(1);
const [toAssignedQty, setToAssignedQty] = useState<number | string>(1);
const [toAvailableQty, setToAvailableQty] = useState<number | string>(1);
const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions());
const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions());
const yardLabel = useMemo(() => {
const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
return (id: string) => byId.get(id) ?? id;
}, [yards]);
const typeLabel = useMemo(() => {
const byId = new Map(
wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]),
);
return (id: string) => byId.get(id) ?? id;
}, [wagonTypes]);
// Only wagons that currently sit in a yard participate in the workspace.
const yardWagons = useMemo(
() => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)),
[wagons],
);
// Each select is constrained by the other's current value so only real
// (yard, type) combinations that hold stock can be picked.
const yardOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (typeId && w.wagonTypeId !== typeId) continue;
ids.add(w.currentYardId);
}
return [...ids]
.map((id) => ({ value: id, label: yardLabel(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, typeId, yardLabel]);
const typeOptions = useMemo(() => {
const ids = new Set<string>();
for (const w of yardWagons) {
if (yardId && w.currentYardId !== yardId) continue;
ids.add(w.wagonTypeId);
}
return [...ids]
.map((id) => ({ value: id, label: typeLabel(id) }))
.sort((a, b) => a.label.localeCompare(b.label));
}, [yardWagons, yardId, typeLabel]);
const matching = useMemo(() => {
if (!yardId || !typeId) return [] as Wagon[];
return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId);
}, [yardWagons, yardId, typeId]);
const availableWagons = useMemo(
() => matching.filter((w) => w.status === Freight.WagonStatus.Available),
[matching],
);
const assignedWagons = useMemo(
() => matching.filter((w) => w.status === Freight.WagonStatus.Assigned),
[matching],
);
// Available first so a partial transfer moves idle wagons before assigned ones.
const transferPool = useMemo(() => {
const rest = matching.filter(
(w) =>
w.status !== Freight.WagonStatus.Available &&
w.status !== Freight.WagonStatus.Assigned,
);
return [...availableWagons, ...assignedWagons, ...rest];
}, [matching, availableWagons, assignedWagons]);
const total = matching.length;
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
const destinationYardOptions = useMemo(
() =>
yards
.filter((y) => y.id !== yardId)
.map((y) => ({ value: y.id, label: y.label || y.code || y.id }))
.sort((a, b) => a.label.localeCompare(b.label)),
[yards, yardId],
);
const bothSelected = Boolean(yardId && typeId);
// Reset the action inputs whenever the yard/type selection changes.
useEffect(() => {
setTransferYardId(null);
setTransferQty(1);
setToAssignedQty(1);
setToAvailableQty(1);
}, [yardId, typeId]);
// Reset the whole workspace when it is reopened.
useEffect(() => {
if (!opened) {
setYardId(null);
setTypeId(null);
}
}, [opened]);
const showError = (err: unknown, fallback: string) => {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback;
toast({ title: fallback, description: String(message), variant: "destructive" });
};
const handleTransfer = async () => {
const n = numberOrZero(transferQty);
if (!transferYardId || n < 1) return;
const ids = transferPool.slice(0, n).map((w) => w.id);
if (!ids.length) return;
try {
const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId });
toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` });
setTransferQty(1);
setTransferYardId(null);
} catch (err) {
showError(err, "Transfer failed");
}
};
const handleFlip = async (
pool: Wagon[],
qty: number | string,
status: Freight.WagonStatus,
label: string,
reset: () => void,
) => {
const n = numberOrZero(qty);
if (n < 1) return;
const ids = pool.slice(0, n).map((w) => w.id);
if (!ids.length) return;
try {
const res = await setStatus.mutateAsync({ wagonIds: ids, status });
toast({ title: `${res.updated} wagon(s) set to ${label}` });
reset();
} catch (err) {
showError(err, "Status update failed");
}
};
const busy = transfer.isPending || setStatus.isPending;
return (
<Modal
opened={opened}
onClose={onClose}
size="min(1040px, 96vw)"
radius="lg"
centered
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Warehouse size={18} />
</ThemeIcon>
<div>
<Text fw={600}>Wagon Yard Workspace</Text>
<Text size="xs" c="dimmed">
Move and re-status wagons by yard and type
</Text>
</div>
</Group>
}
>
<Stack gap="lg">
{/* ---- Selectors ---- */}
<Grid gutter="md">
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Yard"
placeholder="Select a yard"
data={yardOptions}
value={yardId}
onChange={setYardId}
searchable
clearable
nothingFoundMessage="No yards with stock"
radius="md"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6 }}>
<Select
label="Wagon type"
placeholder="Select a wagon type"
data={typeOptions}
value={typeId}
onChange={setTypeId}
searchable
clearable
nothingFoundMessage="No wagon types here"
radius="md"
/>
</Grid.Col>
</Grid>
{wagonsLoading ? (
<Group justify="center" p="xl">
<Loader />
</Group>
) : !bothSelected ? (
<Card withBorder radius="md" bg="var(--mantine-color-gray-0)">
<Text size="sm" c="dimmed" ta="center" py="lg">
Select a yard and a wagon type to see how many wagons are there and act on them.
</Text>
</Card>
) : (
<>
{/* ---- Counts ---- */}
<SimpleGrid cols={{ base: 3 }} spacing="md">
<StatCard label="In yard" value={total} color="gray" />
<StatCard label="Available" value={availableCount} color="teal" />
<StatCard label="Assigned" value={assignedCount} color="blue" />
</SimpleGrid>
<Divider />
<Grid gutter="lg">
{/* ---- Transfer ---- */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%">
<Group gap="xs" mb="sm">
<ThemeIcon variant="light" color="grape" radius="md" size="md">
<ArrowRightLeft size={16} />
</ThemeIcon>
<Title order={5}>Transfer to another yard</Title>
</Group>
<Stack gap="sm">
<NumberInput
label="How many wagons"
min={1}
max={total}
value={transferQty}
onChange={setTransferQty}
disabled={total === 0}
radius="md"
/>
<Select
label="Destination yard"
placeholder="Select destination"
data={destinationYardOptions}
value={transferYardId}
onChange={setTransferYardId}
searchable
radius="md"
/>
<Button
leftSection={<ArrowRightLeft size={16} />}
onClick={handleTransfer}
loading={transfer.isPending}
disabled={busy || !transferYardId || numberOrZero(transferQty) < 1 || total === 0}
>
Transfer
</Button>
<Text size="xs" c="dimmed">
Available wagons move first. Up to {total} can be transferred.
</Text>
</Stack>
</Card>
</Grid.Col>
{/* ---- Re-status ---- */}
<Grid.Col span={{ base: 12, md: 6 }}>
<Card withBorder radius="md" h="100%">
<Group gap="xs" mb="sm">
<ThemeIcon variant="light" color="orange" radius="md" size="md">
<Repeat size={16} />
</ThemeIcon>
<Title order={5}>Change status</Title>
</Group>
<Stack gap="md">
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
Available Assigned
</Text>
<Badge color="teal" variant="light">
{availableCount} available
</Badge>
</Group>
<Group align="flex-end" gap="sm">
<NumberInput
min={1}
max={availableCount}
value={toAssignedQty}
onChange={setToAssignedQty}
disabled={availableCount === 0}
radius="md"
style={{ flex: 1 }}
/>
<Button
variant="light"
color="blue"
leftSection={<PackageCheck size={16} />}
loading={setStatus.isPending}
disabled={busy || availableCount === 0 || numberOrZero(toAssignedQty) < 1}
onClick={() =>
handleFlip(
availableWagons,
toAssignedQty,
Freight.WagonStatus.Assigned,
"Assigned",
() => setToAssignedQty(1),
)
}
>
Assign
</Button>
</Group>
</Box>
<Divider variant="dashed" />
<Box>
<Group justify="space-between" mb={4}>
<Text size="sm" fw={500}>
Assigned Available
</Text>
<Badge color="blue" variant="light">
{assignedCount} assigned
</Badge>
</Group>
<Group align="flex-end" gap="sm">
<NumberInput
min={1}
max={assignedCount}
value={toAvailableQty}
onChange={setToAvailableQty}
disabled={assignedCount === 0}
radius="md"
style={{ flex: 1 }}
/>
<Button
variant="light"
color="teal"
leftSection={<PackageCheck size={16} />}
loading={setStatus.isPending}
disabled={busy || assignedCount === 0 || numberOrZero(toAvailableQty) < 1}
onClick={() =>
handleFlip(
assignedWagons,
toAvailableQty,
Freight.WagonStatus.Available,
"Available",
() => setToAvailableQty(1),
)
}
>
Free up
</Button>
</Group>
</Box>
</Stack>
</Card>
</Grid.Col>
</Grid>
</>
)}
</Stack>
</Modal>
);
};
const StatCard = ({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="1.75rem" fw={700} c={`${color}.7`} lh={1.1} mt={4}>
{value}
</Text>
</Card>
);
export default WagonYardWorkspaceModal;

View File

@@ -220,6 +220,10 @@ export default function DocumentClearanceDetailPage() {
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
workflowFiles={workflowFiles}
roleMode="ET"
// A bare initiated instance still has no cargo/price — the
// stepper's "Create booking" step must read as NOT-yet-created
// so it never claims the booking is done before GL completes it.
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
onChanged={() => void refetch()}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}

View File

@@ -25,7 +25,6 @@ import {
AlertTriangle,
ArrowDown,
ArrowUp,
Banknote,
Building2,
CalendarClock,
CalendarDays,
@@ -106,12 +105,6 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
icon: CalendarRange,
hint: "Year the contract is signed",
},
{
token: "{{pricing.totalAmount}}",
label: "Total price",
icon: Banknote,
hint: "Total contract price from the pricing schedule",
},
];
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
@@ -244,7 +237,11 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
];
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
const KNOWN_TOKENS = new Set<string>([
...ALL_PLACEHOLDERS.map((p) => p.token),
// Still filled by the renderer, just no longer offered as an insert button.
"{{pricing.totalAmount}}",
]);
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
function unknownTokens(text: string): string[] {
@@ -722,14 +719,20 @@ function ArticleEditorModal({
if (kind === "bullet") {
prefix = "- ";
} else {
// Sub-clause nests one level under the clause the caret is on/above;
// New clause always starts a fresh top-level number.
// New clause always starts a fresh top-level number. Sub-clause nests
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
const above = parseArticleBody(before);
const lastDepth = above.paragraph
? 1
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
const depth =
kind === "sub" ? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH) : 1;
kind === "sub"
? lastDepth <= 1
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
: lastDepth
: 1;
// Digits are placeholders — renumberBody assigns the real value.
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
}
@@ -863,7 +866,7 @@ function ArticleEditorModal({
</Button>
</Tooltip>
<Tooltip
label="Numbered point under the current clause — 1.1, then 1.1.1 if clicked again"
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
withArrow
>
<Button

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -14,9 +14,19 @@ import {
Text,
ThemeIcon,
} from "@mantine/core";
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
import {
AlertCircle,
ClipboardList,
FileText,
PackagePlus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import type { BookingDetail } from "@/types/booking";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
@@ -46,6 +56,7 @@ type GlClearanceDetail =
reference: string;
tradeDirection: string;
clearance: Freight.ClearanceView;
booking: BookingDetail;
};
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
@@ -70,6 +81,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
reference: booking.reference,
tradeDirection: booking.tradeDirection,
clearance,
booking,
};
}
}
@@ -77,6 +89,8 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
@@ -125,6 +139,20 @@ export default function GlClearanceDetailPage() {
? (data.clearance.vesselDepartureDate ?? null)
: null;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
const shipmentBooking = data.kind === "booking" ? data.booking : null;
const bookingCompleted = Number(shipmentBooking?.totalAmount ?? 0) > 0;
// Import boundary (DO collected) / export boundary (release) reached →
// clearance is ready and GL creates the real booking. Show the create-booking
// CTA here so the GL user who finishes the DJ step isn't left without a next
// action. Permission-gated so only booking creators (GL Ethiopia) see it.
const canCompleteBooking =
shipmentBooking?.status === "CLEARANCE_READY" &&
Boolean(shipmentBooking?.contractId) &&
!bookingCompleted &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
return (
<PageContainer>
<Stack gap="lg">
@@ -144,6 +172,7 @@ export default function GlClearanceDetailPage() {
<Group gap="sm">
{isImport ? (
<Button
variant={canCompleteBooking ? "default" : "filled"}
color="edr-green"
leftSection={<Upload size={16} />}
disabled={!canUploadDo}
@@ -153,6 +182,7 @@ export default function GlClearanceDetailPage() {
</Button>
) : (
<Button
variant={canCompleteBooking ? "default" : "filled"}
color="edr-green"
leftSection={<Upload size={16} />}
onClick={() => setUploadKind("ro")}
@@ -160,6 +190,19 @@ export default function GlClearanceDetailPage() {
{hasRo ? "Replace RO" : "Upload RO"}
</Button>
)}
{canCompleteBooking && shipmentBooking ? (
<Button
color="edr-green"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : null}
</Group>
}
/>
@@ -209,7 +252,15 @@ export default function GlClearanceDetailPage() {
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
// For a per-booking instance, "created" means COMPLETED (has
// cargo/price), not merely that a booking row exists — a bare
// instance is not yet a real booking. Contract-level clearance
// keeps its linked-booking signal.
bookingCreated={
data.kind === "booking"
? bookingCompleted
: Boolean(linkedBookingId)
}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])

View File

@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { Plus } from "lucide-react";
import { Plus, Warehouse } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, useLocation } from "react-router-dom";
@@ -14,6 +14,7 @@ import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -46,6 +47,7 @@ const FleetResourcePage = () => {
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
const { viewMode, setViewMode } = useFleetViewMode(slug);
const serverListFilters = useMemo((): FleetListFilters | undefined => {
@@ -369,12 +371,25 @@ const FleetResourcePage = () => {
{config.subtitle}
</Text>
</div>
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
<Group gap="sm">
{slug === "wagons" ? (
<Button
variant="light"
color="edr-green"
leftSection={<Warehouse size={16} />}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setWagonWorkspaceOpen(true)}
>
Yard Workspace
</Button>
) : null}
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
setEditing(null);
setFormOpen(true);
}}>
{config.addLabel}
</Button>
</Group>
</Group>
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
@@ -389,31 +404,28 @@ const FleetResourcePage = () => {
onViewModeChange={setViewMode}
filters={
listFilterSelects ? (
<Group gap="xs" wrap="wrap">
<Group gap="sm" wrap="wrap" align="center">
{listFilterSelects.map((filter) => (
<Group key={filter.key} gap={4} wrap="wrap">
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
<Group gap={4} wrap="wrap">
{filter.data.map((option) => (
<Button
key={option.value}
size="xs"
radius="md"
variant={filter.value === option.value ? "filled" : "outline"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: option.value,
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
>
{option.label}
</Button>
))}
</Group>
</Group>
<Select
key={filter.key}
aria-label={filter.label}
placeholder={filter.data[0]?.label ?? filter.label}
data={filter.data}
value={filter.value}
onChange={(value) => {
setListFilterValues((prev) => ({
...prev,
[filter.key]: value ?? "ALL",
}));
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
size="sm"
radius="lg"
w={200}
searchable={filter.data.length > 8}
comboboxProps={{ withinPortal: true }}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
))}
</Group>
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
@@ -586,6 +598,13 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
{slug === "wagons" ? (
<WagonYardWorkspaceModal
opened={wagonWorkspaceOpen}
onClose={() => setWagonWorkspaceOpen(false)}
/>
) : null}
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}

View File

@@ -1591,6 +1591,30 @@ export const api = {
undefined,
() => [["wagons"]],
),
bulkTransfer: endpoint<
{ wagonIds: string[]; toYardId: string },
{ moved: number }
>(
"wagons",
"bulkTransfer",
({ wagonIds, toYardId }) =>
wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data),
undefined,
() => [["wagons"]],
),
bulkSetStatus: endpoint<
{ wagonIds: string[]; status: Wagon["status"] },
{ updated: number }
>(
"wagons",
"bulkSetStatus",
({ wagonIds, status }) =>
wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data),
undefined,
() => [["wagons"]],
),
},
trains: {

View File

@@ -83,4 +83,10 @@ export const wagonService = {
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
/** Relocate many wagons to one yard in a single call (writes movement ledger). */
bulkTransfer: (wagonIds: string[], toYardId: string) =>
apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }),
/** Set the same status on many wagons in a single call. */
bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) =>
apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }),
};