mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { Fragment, useState } from 'react';
|
|
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
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,
|
|
useImportArriveQueue,
|
|
useImportTrainItems,
|
|
} from '@/hooks/useWarehouses';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
|
|
|
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);
|
|
|
|
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
|
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
|
|
|
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>Inspection</Table.Th>
|
|
<Table.Th>Pickup</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{items.map((item: ImportTrainItem) => (
|
|
<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>
|
|
<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 autoUnload = useAutoUnloadArrivedBookings();
|
|
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
|
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
|
|
|
const unloadTrain = async (train: ImportTrain) => {
|
|
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(train.scheduleId)) 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">
|
|
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Open a train to review assigned bookings, then auto unload it.
|
|
</Text>
|
|
</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={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
|
{fullyUnloaded ? 'UNLOADED' : 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}
|
|
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} />
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</Table.Tbody>
|
|
</Table>
|
|
</Table.ScrollContainer>
|
|
)}
|
|
</Card>
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
}
|