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:
Hagernesh
2026-08-04 06:54:52 +00:00
parent 5c33ea90bd
commit dfc7dfec5a
13 changed files with 198 additions and 578 deletions

View File

@@ -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
`);
}
}

View File

@@ -28,6 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm'; import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.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 }> { async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId); 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( let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo", `SELECT tsw.sequence_no AS "sequenceNo",
COALESCE(wt.code, wt.name) AS "wagonType", COALESCE(wt.code, wt.name) AS "wagonType",
@@ -291,7 +296,10 @@ export class BookingsService {
LEFT JOIN freight.containers c LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.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`, ORDER BY inv.created_at`,
[bookingId], [bookingId],
) )

View File

@@ -169,6 +169,11 @@ export class CreateEmptyContainerReturnDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
performedBy?: string; performedBy?: string;
@ApiPropertyOptional({ enum: ['EDR', 'CUSTOMER'] })
@IsOptional()
@IsIn(['EDR', 'CUSTOMER'])
returnedBy?: 'EDR' | 'CUSTOMER';
} }
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto { export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {

View File

@@ -53,4 +53,7 @@ export class EmptyContainerReturn extends BaseEntity {
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
performedBy?: string | null; performedBy?: string | null;
@Column({ name: 'returned_by', type: 'varchar', length: 20, nullable: true })
returnedBy?: 'EDR' | 'CUSTOMER' | null;
} }

View File

@@ -161,6 +161,7 @@ export class ImportOperationsService {
condition: dto.condition ?? null, condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null, handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null, performedBy: dto.performedBy ?? null,
returnedBy: dto.returnedBy ?? null,
}), }),
); );
} }

View File

@@ -435,12 +435,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Container />, icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view, permission: FREIGHT_PERMS.warehouseInventory.view,
}, },
{
label: "Dispatch Queue",
href: "/dashboard/dispatch-queue",
icon: <Send />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{ {
label: "Terminal Inventory", label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory?direction=IMPORT", href: "/dashboard/warehouse-inventory?direction=IMPORT",

View File

@@ -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>
);
}

View File

@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState, type MouseEvent } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { import {
ActionIcon, ActionIcon,
Alert, Alert,
@@ -71,6 +71,7 @@ import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal'; import { DeliverInventoryModal } from './DeliverInventoryModal';
import { ContainerItemsModal } from './ContainerItemsModal'; import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal'; import { FeePreviewModal } from './FeePreviewModal';
import { GrnDocumentButton } from './GrnDocumentButton';
import { InspectionReportModal } from './InspectionReportModal'; import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal'; import { InventoryHistoryModal } from './InventoryHistoryModal';
@@ -98,44 +99,6 @@ interface ReceiveInventoryModalProps {
onReceived?: () => void; 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 { interface Location {
warehouseId: string; warehouseId: string;
@@ -1513,7 +1476,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
</Table.Td> </Table.Td>
<Table.Td ta="right"> <Table.Td ta="right">
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}> <Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report Inspection / Report
</Button> </Button>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -2228,7 +2191,7 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
const isFullyUnloaded = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) =>
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
function ImportArriveQueueTab({ export function ImportArriveQueueTab({
enabled, enabled,
onChanged, onChanged,
}: { }: {
@@ -2769,7 +2732,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
{r.handoverDocumentReference ? 'View handover' : 'Handover'} {r.handoverDocumentReference ? 'View handover' : 'Handover'}
</Menu.Item> </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 {/* Double handling is decided once the goods are off
the wagon (every row here is unloaded) — Yes is the wagon (every row here is unloaded) — Yes is
what makes the fee rule bill this booking. */} what makes the fee rule bill this booking. */}

View File

@@ -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 { 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 { 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 { import {
getNextInventoryAction, getNextInventoryAction,
type InventoryAction, type InventoryAction,
@@ -11,8 +9,8 @@ import {
} from '@/types/warehouse'; } from '@/types/warehouse';
import { InventoryStatusBadge } from './badges'; import { InventoryStatusBadge } from './badges';
import { TruckBreakdownRow } from './TruckBreakdownRow'; import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options'; import { GrnDocumentButton } from './GrnDocumentButton';
import { openPdfBlob } from './pdf'; import { formatDate, formatNumber, humanizeEnum } from './options';
interface WarehouseInventoryTableProps { interface WarehouseInventoryTableProps {
items: WarehouseInventoryItem[]; items: WarehouseInventoryItem[];
@@ -64,45 +62,6 @@ const noteLineValue = (notes: string | null | undefined, label: string) => {
const handoverDocumentReference = (item: WarehouseInventoryItem) => const handoverDocumentReference = (item: WarehouseInventoryItem) =>
item.handoverDocumentReference ?? noteLineValue(item.notes, 'Handover Reference'); 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({ export function WarehouseInventoryTable({
items, items,
busyId, busyId,
@@ -239,7 +198,7 @@ export function WarehouseInventoryTable({
)} )}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<GrnDocumentButton item={item} /> <GrnDocumentButton inventoryId={item.id} grnNumber={item.grnNumber} />
</Table.Td> </Table.Td>
<Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td> <Table.Td>{item.warehouse?.facility?.name ?? '-'}</Table.Td>
<Table.Td>{item.warehouse?.code ?? '-'}</Table.Td> <Table.Td>{item.warehouse?.code ?? '-'}</Table.Td>
@@ -341,7 +300,7 @@ export function WarehouseInventoryTable({
</Tooltip> </Tooltip>
)} )}
{onDownloadBundle && item.grnNumber && ( {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)}> <ActionIcon variant="subtle" color="grape" onClick={() => onDownloadBundle(item)}>
<Download size={16} /> <Download size={16} />
</ActionIcon> </ActionIcon>

View File

@@ -1,338 +1,11 @@
import { Fragment, useEffect, useMemo, useState } from 'react'; import { Card } from '@mantine/core';
import {
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Table,
Text,
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { import { WarehouseOpsKpiStrip } from '@/components/warehouses';
VisualEmptyState, import { ImportArriveQueueTab } from '@/components/warehouses/ReceiveInventoryModal';
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>
);
}
/** Arrived import trains awaiting unload into warehouse inventory. */ /** Arrived import trains awaiting unload into warehouse inventory. */
export default function ArrivalQueuePage() { 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 ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
@@ -344,136 +17,7 @@ export default function ArrivalQueuePage() {
<WarehouseOpsKpiStrip /> <WarehouseOpsKpiStrip />
<Card withBorder radius="md" padding="lg"> <Card withBorder radius="md" padding="lg">
<Group justify="space-between" mb="md"> <ImportArriveQueueTab enabled />
<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>
)}
</Card> </Card>
</PageContainer> </PageContainer>
); );

View File

@@ -27,9 +27,30 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { warehouseService } from "@/services/warehouse.service"; import { warehouseService } from "@/services/warehouse.service";
import { importOperationsService } from "@/services/importOperations.service"; import { importOperationsService } from "@/services/importOperations.service";
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
type ReturnType = "all" | "edr" | "customer"; 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 { interface ContainerReturnRow {
key: string; key: string;
containerNumber: string; containerNumber: string;
@@ -164,6 +185,11 @@ export default function ContainerReturnsPage() {
enabled: bookingIds.length > 0 && !queueLoading, 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 allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
if (filterType === "all") return allGroups; if (filterType === "all") return allGroups;
@@ -202,6 +228,7 @@ export default function ContainerReturnsPage() {
facility: container.warehouse, facility: container.warehouse,
condition: container.condition, condition: container.condition,
handoverNote: container.handoverNote, handoverNote: container.handoverNote,
returnedBy: truck.returnType,
}); });
results.push(result); 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; const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
if (queueLoading || containerReturnsQuery.isLoading) { if (queueLoading || containerReturnsQuery.isLoading) {
@@ -257,7 +304,7 @@ export default function ContainerReturnsPage() {
</Button> </Button>
</Group> </Group>
{returnedContainers.length > 0 && ( {filteredReturnedContainers.length > 0 && (
<> <>
<Text fw={600} mb="xs">Returned Containers</Text> <Text fw={600} mb="xs">Returned Containers</Text>
<Table.ScrollContainer minWidth={1000} mb="lg"> <Table.ScrollContainer minWidth={1000} mb="lg">
@@ -266,27 +313,55 @@ export default function ContainerReturnsPage() {
<Table.Tr> <Table.Tr>
<Table.Th>Container Number</Table.Th> <Table.Th>Container Number</Table.Th>
<Table.Th>Booking Ref</Table.Th> <Table.Th>Booking Ref</Table.Th>
<Table.Th>Returned By</Table.Th>
<Table.Th>Returned Date</Table.Th> <Table.Th>Returned Date</Table.Th>
<Table.Th>Facility</Table.Th> <Table.Th>Facility</Table.Th>
<Table.Th>Yard</Table.Th> <Table.Th>Yard</Table.Th>
<Table.Th>Condition</Table.Th> <Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>Status</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{returnedContainers.map((ret: any) => ( {filteredReturnedContainers.map((ret: any) => {
<Table.Tr key={ret.id}> const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
<Table.Td>{ret.containerNumber}</Table.Td> return (
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td> <Table.Tr key={ret.id}>
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td> <Table.Td>{ret.containerNumber}</Table.Td>
<Table.Td>{ret.facility || "—"}</Table.Td> <Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
<Table.Td>{ret.yard || "—"}</Table.Td> <Table.Td>
<Table.Td>{ret.condition || "—"}</Table.Td> {ret.returnedBy ? (
<Table.Td> <Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
<Badge size="sm">{ret.status}</Badge> {ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
</Table.Td> </Badge>
</Table.Tr> ) : (
))} "—"
)}
</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.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>

View File

@@ -423,14 +423,7 @@ export function TruckRows({ group }: { group: BookingGroup }) {
disabled={!primaryId} disabled={!primaryId}
onClick={() => openRelease(t)} onClick={() => openRelease(t)}
> >
Truck Arrival {t.arrivedAt ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
</Menu.Item>
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!primaryId}
onClick={() => openRelease(t)}
>
Truck Leaving
</Menu.Item> </Menu.Item>
<Menu.Divider /> <Menu.Divider />
<Menu.Item <Menu.Item
@@ -452,7 +445,7 @@ export function TruckRows({ group }: { group: BookingGroup }) {
disabled={!primaryId} disabled={!primaryId}
onClick={() => setInspectId(primaryId)} onClick={() => setInspectId(primaryId)}
> >
Inspect / report Inspection / Report
</Menu.Item> </Menu.Item>
{isEdr && ( {isEdr && (
<> <>

View File

@@ -102,6 +102,7 @@ export interface EmptyContainerReturn {
status: EmptyContainerReturnStatus; status: EmptyContainerReturnStatus;
wagonAllocationReference: string | null; wagonAllocationReference: string | null;
performedBy: string | null; performedBy: string | null;
returnedBy: 'EDR' | 'CUSTOMER' | null;
} }
export interface CreateEmptyContainerReturnPayload { export interface CreateEmptyContainerReturnPayload {
@@ -115,6 +116,7 @@ export interface CreateEmptyContainerReturnPayload {
condition?: string; condition?: string;
handoverNote?: string; handoverNote?: string;
performedBy?: string; performedBy?: string;
returnedBy?: 'EDR' | 'CUSTOMER';
} }
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload { export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {