mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 14:08:11 +00:00
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:
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 />,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1013,6 +1013,7 @@ export interface InventoryFilter {
|
||||
containerId?: string;
|
||||
goodsId?: string;
|
||||
status?: InventoryStatus;
|
||||
direction?: 'IMPORT' | 'EXPORT';
|
||||
search?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
|
||||
@@ -198,6 +198,11 @@ export enum TrainSetWagonStatus {
|
||||
Departed = "DEPARTED",
|
||||
}
|
||||
|
||||
export enum LoadingStatus {
|
||||
Loaded = "LOADED",
|
||||
Unloaded = "UNLOADED",
|
||||
}
|
||||
|
||||
export enum WagonStatus {
|
||||
Available = "AVAILABLE",
|
||||
Assigned = "ASSIGNED",
|
||||
|
||||
Reference in New Issue
Block a user