mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(freight): filter container returns by returned-by truck type
Top Returned Containers table ignored the EDR/Customer tab because the backend never persisted which truck type performed the return. Added returned_by column + DTO/entity field, wired create payload to send it, and filtered the table by the active tab.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class EmptyContainerReturnedBy3210000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerReturnedBy3210000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS returned_by varchar(20) NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS returned_by
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -239,6 +240,10 @@ export class BookingsService {
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
// The sheet attests that EDR has taken custody. For export that happens at
|
||||
// cargo receipt (GRN), so the GRN is required even when wagons are already
|
||||
// allocated — an allocation is a plan, not possession.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
@@ -291,7 +296,10 @@ export class BookingsService {
|
||||
LEFT JOIN freight.containers c
|
||||
ON c.id = inv.container_id AND c.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
|
||||
AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> ''
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
ORDER BY inv.created_at`,
|
||||
[bookingId],
|
||||
)
|
||||
|
||||
@@ -169,6 +169,11 @@ export class CreateEmptyContainerReturnDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['EDR', 'CUSTOMER'] })
|
||||
@IsOptional()
|
||||
@IsIn(['EDR', 'CUSTOMER'])
|
||||
returnedBy?: 'EDR' | 'CUSTOMER';
|
||||
}
|
||||
|
||||
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
|
||||
|
||||
@@ -53,4 +53,7 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
|
||||
@Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true })
|
||||
returnedBy?: 'EDR' | 'CUSTOMER' | null;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ export class ImportOperationsService {
|
||||
condition: dto.condition ?? null,
|
||||
handoverNote: dto.handoverNote ?? null,
|
||||
performedBy: dto.performedBy ?? null,
|
||||
returnedBy: dto.returnedBy ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Container />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Dispatch Queue",
|
||||
href: "/dashboard/dispatch-queue",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.warehouseInventory.view,
|
||||
},
|
||||
{
|
||||
label: "Terminal Inventory",
|
||||
href: "/dashboard/warehouse-inventory?direction=IMPORT",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState, type MouseEvent } from 'react';
|
||||
import { Button } from '@mantine/core';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractDownloadErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
/** Opens (or downloads) an inventory item's GRN document — same button everywhere it appears. */
|
||||
export function GrnDocumentButton({
|
||||
inventoryId,
|
||||
grnNumber,
|
||||
}: {
|
||||
inventoryId: string;
|
||||
grnNumber?: string | null;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
@@ -71,6 +71,7 @@ import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { GrnDocumentButton } from './GrnDocumentButton';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
@@ -98,44 +99,6 @@ interface ReceiveInventoryModalProps {
|
||||
onReceived?: () => void;
|
||||
}
|
||||
|
||||
function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; grnNumber?: string | null }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(inventoryId);
|
||||
const opened = openPdfBlob(response.data, `grn-${grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface Location {
|
||||
warehouseId: string;
|
||||
@@ -1513,7 +1476,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
Inspection / Report
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -2228,7 +2191,7 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportArriveQueueTab({
|
||||
export function ImportArriveQueueTab({
|
||||
enabled,
|
||||
onChanged,
|
||||
}: {
|
||||
@@ -2769,7 +2732,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspection / Report</Menu.Item>
|
||||
{/* Double handling is decided once the goods are off
|
||||
the wagon (every row here is unloaded) — Yes is
|
||||
what makes the fee rule bill this booking. */}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Fragment, useState, type MouseEvent } from 'react';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
|
||||
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import {
|
||||
getNextInventoryAction,
|
||||
type InventoryAction,
|
||||
@@ -11,8 +9,8 @@ import {
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { TruckBreakdownRow } from './TruckBreakdownRow';
|
||||
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import { GrnDocumentButton } from './GrnDocumentButton';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
@@ -64,45 +62,6 @@ const noteLineValue = (notes: string | null | undefined, label: string) => {
|
||||
const handoverDocumentReference = (item: WarehouseInventoryItem) =>
|
||||
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference');
|
||||
|
||||
function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openDocument = async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (!item.grnNumber) {
|
||||
toast({ variant: 'destructive', title: 'GRN document unavailable', description: 'This item has no GRN number yet.' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const response = await warehouseService.downloadGrnDocument(item.id);
|
||||
const opened = openPdfBlob(response.data, `grn-${item.grnNumber}.pdf`, pdfWindow);
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="teal"
|
||||
leftSection={<FileText size={12} />}
|
||||
disabled={!item.grnNumber}
|
||||
loading={loading}
|
||||
onClick={openDocument}
|
||||
>
|
||||
{item.grnNumber ?? 'No GRN'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
items,
|
||||
busyId,
|
||||
@@ -239,7 +198,7 @@ export function WarehouseInventoryTable({
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<GrnDocumentButton item={item} />
|
||||
<GrnDocumentButton inventoryId={item.id} grnNumber={item.grnNumber} />
|
||||
</Table.Td>
|
||||
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
|
||||
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
|
||||
@@ -341,7 +300,7 @@ export function WarehouseInventoryTable({
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDownloadBundle && item.grnNumber && (
|
||||
<Tooltip label="Download document bundle (GRN + gate clearance + handover)" withArrow>
|
||||
<Tooltip label="Download document bundle (GRN + exit paper + handover)" withArrow>
|
||||
<ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
|
||||
<Download size={16} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -1,338 +1,11 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
|
||||
import { Card } from '@mantine/core';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
VisualEmptyState,
|
||||
WarehouseOpsKpiStrip,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
warehousesAtStation,
|
||||
yardsForBooking,
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const response = (error as { response?: { data?: { message?: unknown } } }).response;
|
||||
const message = response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
if (typeof message === 'string') return message;
|
||||
}
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
train.pendingUnloadBookings ?? train.totalBookings;
|
||||
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
function isUnloadPending(item: ImportTrainItem) {
|
||||
return !item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, AssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId);
|
||||
// A train only ever unloads at the warehouse actually sitting at its
|
||||
// destination station — Indode's train never offers Sebeta's warehouse.
|
||||
const scopedWarehouses = useMemo(
|
||||
() => warehousesAtStation(warehouses, train.destinationStationId),
|
||||
[warehouses, train.destinationStationId],
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[scopedWarehouses],
|
||||
);
|
||||
// With exactly one warehouse at the station there is nothing to choose —
|
||||
// pre-fill it so staff only has to pick yard/zone, not re-discover Indode.
|
||||
useEffect(() => {
|
||||
if (scopedWarehouses.length !== 1) return;
|
||||
const onlyWarehouseId = scopedWarehouses[0].id;
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
if (!assignments[item.bookingId]?.warehouseId) {
|
||||
onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId });
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scopedWarehouses, items]);
|
||||
|
||||
// Once a booking's warehouse is known, its yard (and then zone) follow from
|
||||
// what the cargo actually is — a Wheat booking only ever has one candidate
|
||||
// yard (Dry Bulk) once Indode's real yard layout is configured, so staff
|
||||
// never see a picker for something that isn't actually a choice.
|
||||
useEffect(() => {
|
||||
items.filter(isUnloadPending).forEach((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
if (!draft?.warehouseId) return;
|
||||
|
||||
if (!draft.yardId) {
|
||||
const candidateYards = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
});
|
||||
if (candidateYards.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!draft.zoneId) {
|
||||
const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId);
|
||||
if (candidateZones.length === 1) {
|
||||
onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id });
|
||||
}
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignments, items, yards, zones]);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" py="md" size="sm">
|
||||
No assigned bookings found for this train.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const yardOptions = yardsForBooking(yards, {
|
||||
warehouseId: draft.warehouseId,
|
||||
freightType: item.freightType,
|
||||
tradeDirection: 'IMPORT',
|
||||
cargoTypeCode: item.cargoTypeCode,
|
||||
}).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
// The yard is already scoped to what this cargo can go into — a
|
||||
// zone's own type always matches its parent yard's purpose (see the
|
||||
// Indode seed migration), so no separate zone-type filter is needed.
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId)
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.bookingReference ?? item.bookingId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{item.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(item.weight)}</Table.Td>
|
||||
<Table.Td>{formatDate(item.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.currentStatus === 'UNLOADED' ? 'green' : 'orange'} size="sm">
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
import { WarehouseOpsKpiStrip } from '@/components/warehouses';
|
||||
import { ImportArriveQueueTab } from '@/components/warehouses/ReceiveInventoryModal';
|
||||
|
||||
/** Arrived import trains awaiting unload into warehouse inventory. */
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: alreadyUnloaded
|
||||
? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.`
|
||||
: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Auto unload failed',
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyScheduleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
@@ -344,136 +17,7 @@ export default function ArrivalQueuePage() {
|
||||
<WarehouseOpsKpiStrip />
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train, assign each booking to a warehouse yard and zone, then unload it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : trains.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="container"
|
||||
title="No arrived import trains"
|
||||
description="Import trains appear here once their train schedule status is ARRIVED."
|
||||
/>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1150}>
|
||||
<Table verticalSpacing="sm" highlightOnHover striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Origin</Table.Th>
|
||||
<Table.Th>Destination</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th ta="center">Bookings</Table.Th>
|
||||
<Table.Th ta="center">Containers</Table.Th>
|
||||
<Table.Th ta="center">Cargoes</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ImportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
const fullyUnloaded = isFullyUnloaded(train);
|
||||
const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train);
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{train.trainNumber ?? '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{train.scheduleId.slice(0, 8)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{train.route ?? '—'}</Table.Td>
|
||||
<Table.Td>{train.origin ?? '—'}</Table.Td>
|
||||
<Table.Td>{train.destination ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs">{formatDate(train.arrivalTime)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">{train.totalBookings}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
onClick={() => setOpenScheduleId(isOpen ? null : train.scheduleId)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows
|
||||
train={train}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[train.scheduleId]: {
|
||||
...(current[train.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId
|
||||
? draft
|
||||
: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
<ImportArriveQueueTab enabled />
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -27,9 +27,30 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
const RETURN_STATUS_ORDER: EmptyContainerReturnStatus[] = [
|
||||
"RETURNED",
|
||||
"ASSIGNED_STORAGE",
|
||||
"DOCUMENTATION_CLEARED",
|
||||
"WAGON_ALLOCATED",
|
||||
"TRANSPORTED_TO_DJIBOUTI",
|
||||
"HANDOVER_ISSUED",
|
||||
"COMPLETED",
|
||||
];
|
||||
|
||||
const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
|
||||
RETURNED: "Returned",
|
||||
ASSIGNED_STORAGE: "Assigned Storage",
|
||||
DOCUMENTATION_CLEARED: "Documentation Cleared",
|
||||
WAGON_ALLOCATED: "Wagon Allocated",
|
||||
TRANSPORTED_TO_DJIBOUTI: "Transported to Djibouti",
|
||||
HANDOVER_ISSUED: "Handover Issued",
|
||||
COMPLETED: "Completed",
|
||||
};
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
@@ -164,6 +185,11 @@ export default function ContainerReturnsPage() {
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const filteredReturnedContainers = useMemo(() => {
|
||||
if (filterType === "all") return returnedContainers;
|
||||
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase());
|
||||
}, [returnedContainers, filterType]);
|
||||
|
||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (filterType === "all") return allGroups;
|
||||
@@ -202,6 +228,7 @@ export default function ContainerReturnsPage() {
|
||||
facility: container.warehouse,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
returnedBy: truck.returnType,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
@@ -223,6 +250,26 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const advanceStatusMutation = useMutation({
|
||||
mutationFn: (id: string) => {
|
||||
const current = returnedContainers.find((r: any) => r.id === id);
|
||||
const nextIndex = RETURN_STATUS_ORDER.indexOf(current?.status ?? "RETURNED") + 1;
|
||||
const status = RETURN_STATUS_ORDER[nextIndex] ?? "COMPLETED";
|
||||
return importOperationsService.updateEmptyReturnStatus(id, { status });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: "Return status updated" });
|
||||
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update return status",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||
|
||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||
@@ -257,7 +304,7 @@ export default function ContainerReturnsPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
{filteredReturnedContainers.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} mb="xs">Returned Containers</Text>
|
||||
<Table.ScrollContainer minWidth={1000} mb="lg">
|
||||
@@ -266,27 +313,55 @@ export default function ContainerReturnsPage() {
|
||||
<Table.Tr>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Returned Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{returnedContainers.map((ret: any) => (
|
||||
<Table.Tr key={ret.id}>
|
||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{ret.status}</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filteredReturnedContainers.map((ret: any) => {
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Table.Tr key={ret.id}>
|
||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{ret.returnedBy ? (
|
||||
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
|
||||
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">Done</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
@@ -423,14 +423,7 @@ export function TruckRows({ group }: { group: BookingGroup }) {
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Arrival
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!primaryId}
|
||||
onClick={() => openRelease(t)}
|
||||
>
|
||||
Truck Leaving
|
||||
{t.arrivedAt ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
@@ -452,7 +445,7 @@ export function TruckRows({ group }: { group: BookingGroup }) {
|
||||
disabled={!primaryId}
|
||||
onClick={() => setInspectId(primaryId)}
|
||||
>
|
||||
Inspect / report
|
||||
Inspection / Report
|
||||
</Menu.Item>
|
||||
{isEdr && (
|
||||
<>
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface EmptyContainerReturn {
|
||||
status: EmptyContainerReturnStatus;
|
||||
wagonAllocationReference: string | null;
|
||||
performedBy: string | null;
|
||||
returnedBy: 'EDR' | 'CUSTOMER' | null;
|
||||
}
|
||||
|
||||
export interface CreateEmptyContainerReturnPayload {
|
||||
@@ -115,6 +116,7 @@ export interface CreateEmptyContainerReturnPayload {
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
performedBy?: string;
|
||||
returnedBy?: 'EDR' | 'CUSTOMER';
|
||||
}
|
||||
|
||||
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
|
||||
|
||||
Reference in New Issue
Block a user