Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx

436 lines
17 KiB
TypeScript

import { Fragment, useEffect, useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Select,
Stack,
Table,
Text,
} from '@mantine/core';
import { ChevronDown, ChevronRight, PackageOpen, Truck } from 'lucide-react';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import {
VisualEmptyState,
WarehouseHero,
formatDate,
formatNumber,
} 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 locationTypesForFreight = (freightType: string | null | undefined) => {
const normalized = (freightType ?? '').toUpperCase();
if (normalized === 'CONTAINER') {
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
}
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
};
const isContainerFreight = (freightType: string | null | undefined) =>
(freightType ?? '').toUpperCase() === 'CONTAINER';
function isUnloadPending(item: ImportTrainItem) {
return !item.currentStatus || item.currentStatus === 'RECEIVED';
}
function ImportTrainDetailRows({
scheduleId,
warehouses,
yards,
zones,
assignments,
onAssignmentChange,
onReadyChange,
}: {
scheduleId: string;
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(scheduleId);
const warehouseOptions = useMemo(
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
[warehouses],
);
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 { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
const yardOptions = yards
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
const zoneOptions = zones
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
.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. */
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 (
<Container size="xxl" py="lg">
<Breadcrumbs items={[{ label: 'Arrival queue' }]} />
<Stack gap="lg" mt="sm">
<WarehouseHero
variant="container"
secondaryVariant="warehouse"
title="Arrival / Unloading Queue"
subtitle="Arrived import trains ready to unload assigned bookings into warehouse inventory."
/>
<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
scheduleId={train.scheduleId}
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>
</Stack>
</Container>
);
}