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 { 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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
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. */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user