mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
wagon work space, container validation
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 train‑specific reorder (registered in module)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user