Merge pull request #424 from Tria-plc/import_loading

Import loading

Import loading confirmation — backend done, frontend panel + page wiring plus frontend
Approve-delivery → exit gating gap — confirmed real: release() exit-paper generation doesn't check whether customer approved/signed delivery. Fix proposed, awaiting your go-ahead.
Terminal inventory filter — done, confirmed correct, no data to show until bookings exist.
This commit is contained in:
Hagernesh Tadesse
2026-07-03 17:09:31 +03:00
committed by GitHub
20 changed files with 529 additions and 7 deletions

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings.
* Tracking only — does not gate dispatch.
*/
export class AddLoadingStatusToTrainScheduleBookings1900000000000
implements MigrationInterface
{
name = "AddLoadingStatusToTrainScheduleBookings1900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
DROP COLUMN IF EXISTS loading_status
`);
}
}

View File

@@ -1,9 +1,15 @@
import { BaseEntity } from '@edr/api-common';
import { LoadingStatus } from '@edr/types';
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from './train-schedule.entity';
export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [
LoadingStatus.Unloaded,
LoadingStatus.Loaded,
] as const;
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
@Index(['trainScheduleId', 'bookingId'], { unique: true })
@Index(['bookingId'], { unique: true })
@@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity {
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' })
loadingStatus!: string;
}

View File

@@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
findByScheduleId(
trainScheduleId: string,
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
return this.repo(manager).find({
where: { trainScheduleId },
select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true },
});
}
async updateLoadingStatusMany(
trainScheduleId: string,
bookingIds: string[],
loadingStatus: string,
manager?: EntityManager,
): Promise<void> {
if (!bookingIds.length) return;
await this.repo(manager).update(
{ trainScheduleId, bookingId: In(bookingIds) },
{ loadingStatus },
);
}
}

View File

@@ -0,0 +1,15 @@
import { LoadingStatus } from '@edr/types';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator';
export class UpdateImportLoadingStatusDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ enum: LoadingStatus })
@IsEnum(LoadingStatus)
loadingStatus!: LoadingStatus;
}

View File

@@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
@@ -325,6 +326,28 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getCompositionRemovals(id);
}
@Get("schedules/:id/import-loading-bookings")
@TrainSchedulingView()
@ApiOperation({
summary: "List import bookings eligible for loading confirmation on this schedule",
})
getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getImportLoadingBookings(id);
}
@Patch("schedules/:id/import-loading-status")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)",
})
updateImportLoadingStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateImportLoadingStatusDto,
) {
return this.trainSchedulingService.updateImportLoadingStatus(id, dto);
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })

View File

@@ -1,5 +1,6 @@
import {
AllocationLoadType,
LoadingStatus,
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
@@ -45,6 +46,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
@@ -744,6 +746,87 @@ export class TrainSchedulingService {
);
}
async getImportLoadingBookings(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
if (!scheduleBookings.length) {
return { count: 0, items: [] };
}
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
const statusByBookingId = new Map(
scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
);
const candidateIds = scheduleBookings
.map((sb) => sb.bookingId)
.filter((id) => allocatedBookingIds.has(id));
if (!candidateIds.length) {
return { count: 0, items: [] };
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
const items = bookings
.filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
.map((b) => ({
id: b.id,
reference: b.reference ?? null,
customer: b.company?.name ?? null,
weightTons: b.cargoTotalWeightVgm,
loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
}));
return { count: items.length, items };
}
async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
const [scheduleBookings, allocations, bookings] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
const bookingById = new Map(bookings.map((b) => [b.id, b]));
const invalid: string[] = [];
for (const id of dto.bookingIds) {
const booking = bookingById.get(id);
if (
!scheduledIds.has(id) ||
!allocatedIds.has(id) ||
!booking ||
booking.tradeDirection !== 'IMPORT' ||
booking.paymentStatus !== 'PAID'
) {
invalid.push(id);
}
}
if (invalid.length) {
throw new BadRequestException(
`Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getImportLoadingBookings(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {

View File

@@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto {
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
status?: WarehouseInventoryStatus;
@ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] })
@IsOptional()
@IsEnum(['IMPORT', 'EXPORT'])
direction?: 'IMPORT' | 'EXPORT';
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -430,6 +430,7 @@ export class WarehouseInventoryService {
...(filter.status ? { status: filter.status } : {}),
...(createdAt ? { createdAt } : {}),
...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}),
...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}),
};
const search = filter.search?.trim();
@@ -2032,6 +2033,22 @@ export class WarehouseInventoryService {
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
if (item.bookingId) {
const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> =
await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) {
throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated',
);
}
}
}
const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()

View File

@@ -328,7 +328,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT",
icon: <Package />,
},
{
@@ -375,7 +375,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
},
],

View File

@@ -0,0 +1,172 @@
import { useMemo, useState } from "react";
import { PackageCheck } from "lucide-react";
import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type {
ImportLoadingBooking,
ImportLoadingBookingsResponse,
LoadingStatus,
} from "@/types/trainScheduling";
function ImportLoadingBookingRow({
booking,
selected,
onToggle,
}: {
booking: ImportLoadingBooking;
selected: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<PackageCheck size={14} />
<Text fw={600} size="sm">
{booking.reference ?? booking.id}
</Text>
<Badge
variant="light"
size="xs"
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{booking.loadingStatus}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{booking.customer ?? "Unknown customer"}
</Text>
<Text size="xs" c="dimmed">
{booking.weightTons}T
</Text>
</Stack>
</Group>
);
}
export function ImportLoadingConfirmationPanel({
scheduleId,
items,
isLoading,
}: {
scheduleId: string;
items: ImportLoadingBooking[];
isLoading?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const queryClient = useQueryClient();
const updateStatus = useMutation<
ImportLoadingBookingsResponse,
Error,
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus }
>({
...api.trainScheduling.updateImportLoadingStatus.mutationOptions(),
onSuccess: () => {
setSelectedIds([]);
queryClient.invalidateQueries({
queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }),
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Could not update loading status");
},
});
const toggle = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const allIds = useMemo(() => items.map((b) => b.id), [items]);
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading import bookings
</Text>
</Group>
);
}
if (!items.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No paid import bookings with wagons allocated on this schedule
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Import bookings ({items.length})
</Text>
<Group gap="sm">
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
Clear
</Button>
</Group>
</Group>
<Stack gap="sm">
{items.map((booking) => (
<ImportLoadingBookingRow
key={booking.id}
booking={booking}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
<Group gap="sm">
<Button
color="edr-green"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
}
>
Mark loaded
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
}
>
Mark unloaded
</Button>
</Group>
</Stack>
);
}

View File

@@ -109,6 +109,8 @@ export const QUERY_KEYS = {
["train-scheduling", "unassigned", id] as const,
compositionRemovals: (id: string) =>
["train-scheduling", "removals", id] as const,
importLoadingBookings: (id: string) =>
["train-scheduling", "import-loading-bookings", id] as const,
},
FLEET: {

View File

@@ -322,6 +322,10 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-status`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>

View File

@@ -42,6 +42,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
const eligibleFilters = useMemo(
() =>
schedule
@@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
{schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Import loading confirmation</Text>
<Text size="sm" c="dimmed">
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
is tracking only it does not block dispatch.
</Text>
<ImportLoadingConfirmationPanel
scheduleId={scheduleId as string}
items={importLoadingQuery.data?.items ?? []}
isLoading={importLoadingQuery.isLoading}
/>
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">

View File

@@ -14,7 +14,17 @@ import {
Text,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Eye,
FileText,
History,
PackageOpen,
ShieldCheck,
Truck,
} from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
@@ -36,6 +46,7 @@ import {
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
AutoUnloadExportDjiboutiResult,
ExportTrain,
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument();
const qc = useQueryClient();
const secureGatePass = useMutation({
mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId),
onSuccess: () =>
qc.invalidateQueries({
queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'],
}),
});
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
@@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() {
.map((doc) => [doc.scheduleId as string, doc]),
);
const secureGate = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
await secureGatePass.mutateAsync(train.scheduleId);
toast({
title: 'Gate pass secured',
description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Could not secure gate pass',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
const unloadTrain = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
>
Open
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<ShieldCheck size={14} />}
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
onClick={() => secureGate(train)}
>
Secure Gate Pass
</Button>
<Button
size="compact-xs"
color="green"
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items

View File

@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
);
const warehousesQuery = useWarehouses();
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
return (
<PageContainer>
<PageHeader
title="Warehouse Inventory"
title={
direction === 'IMPORT'
? 'Import Terminal Inventory'
: direction === 'EXPORT'
? 'Export Terminal Inventory'
: 'Warehouse Inventory'
}
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Group gap="xs">

View File

@@ -48,6 +48,8 @@ import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -357,6 +359,25 @@ export const api = {
QUERY_KEYS.TRAIN_SCHEDULING.compositionRemovals(scheduleId),
),
importLoadingBookings: endpoint<{ id: string }, ImportLoadingBookingsResponse>(
"train-scheduling",
"import-loading-bookings",
({ id }) => trainSchedulingService.getImportLoadingBookings(id),
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id),
),
updateImportLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
ImportLoadingBookingsResponse
>(
"train-scheduling",
"update-import-loading-status",
({ id, bookingIds, loadingStatus }) =>
trainSchedulingService.updateImportLoadingStatus(id, { bookingIds, loadingStatus }),
undefined,
({ id }) => [QUERY_KEYS.TRAIN_SCHEDULING.importLoadingBookings(id)],
),
// ── Mutations ──────────────────────────────────────────────────────────
runAllocation: endpoint<
{ scheduleId: string },

View File

@@ -15,6 +15,8 @@ import type {
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
ImportLoadingBookingsResponse,
LoadingStatus,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -298,6 +300,26 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportLoadingBookings: async (
scheduleId: string,
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.get<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_BOOKINGS(scheduleId),
);
return unwrap(response.data);
},
updateImportLoadingStatus: async (
scheduleId: string,
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
): Promise<ImportLoadingBookingsResponse> => {
const response = await client.patch<ImportLoadingBookingsResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_LOADING_STATUS(scheduleId),
payload,
);
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {

View File

@@ -455,6 +455,21 @@ export interface ImportDjiboutiDocumentRecord {
notes?: string | null;
}
export type LoadingStatus = "LOADED" | "UNLOADED";
export interface ImportLoadingBooking {
id: string;
reference: string | null;
customer: string | null;
weightTons: number;
loadingStatus: LoadingStatus;
}
export interface ImportLoadingBookingsResponse {
count: number;
items: ImportLoadingBooking[];
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;

View File

@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
containerId?: string;
goodsId?: string;
status?: InventoryStatus;
direction?: 'IMPORT' | 'EXPORT';
search?: string;
dateFrom?: string;
dateTo?: string;

View File

@@ -198,6 +198,11 @@ export enum TrainSetWagonStatus {
Departed = "DEPARTED",
}
export enum LoadingStatus {
Loaded = "LOADED",
Unloaded = "UNLOADED",
}
export enum WagonStatus {
Available = "AVAILABLE",
Assigned = "ASSIGNED",