feat(warehouse): Batch 7 — Import Arrive Queue (arrived import trains, read-only)

- SchedulingReadFacade: importArriveQueue() + importTrainDetail() — read-only SELECTs only,
  direction derived from origin/destination station countries (route-based), so EXPORT/DOMESTIC
  trains are excluded. Per-train booking/container/cargo counts.
- GET /warehouse-inventory/import/arrive-queue + /import/trains/:scheduleId/items
- Import tab restructured into sub-tabs: Arrive Queue (implemented) / Unloaded Queue /
  Dispatch Queue (placeholders for next batch)
- ImportArriveQueueTab: train table (schedule id, train #, route, origin, destination, arrival,
  bookings/containers/cargoes, status) with Open (expandable assigned-items detail) + Auto Unload
  (reuses existing per-booking unload endpoint over the train's bookings)
- Batch7TestDataSeeder: 1 arrived IMPORT train (SEED-IMP-001) + 1 arrived EXPORT train
  (SEED-EXP-001, proves exclusion); idempotent
- Train schedule service logic untouched (facade is SELECT-only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-21 11:15:31 +00:00
parent a8bc5b19de
commit 35c3341baf
9 changed files with 522 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import {
Alert,
Badge,
@@ -16,17 +16,20 @@ import {
Textarea,
TextInput,
} from '@mantine/core';
import { Info, PackageSearch, Truck } from 'lucide-react';
import { ChevronDown, ChevronRight, Info, PackageSearch, Train, Truck } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import {
useBulkDispatchExport,
useBulkReceive,
useEligibleBookings,
useImportArriveQueue,
useImportTrainItems,
useLoadPassedExport,
useLoadedExport,
useReadyToLoadExport,
useReceiveInventory,
useUnloadBooking,
useWarehouseYards,
useWarehouseZones,
useWarehouses,
@@ -34,12 +37,15 @@ import {
import type {
BulkDispatchResult,
BulkReceiveResult,
ImportTrain,
ImportTrainItem,
LoadPassedExportResult,
ReadyToLoadRow,
ReceiveInventoryPayload,
} from '@/types/warehouse';
import { warehouseService } from '@/services/warehouse.service';
import { BookingSelect } from './BookingSelect';
import { extractErrorMessage, formatNumber } from './options';
import { extractErrorMessage, formatDate, formatNumber } from './options';
interface ReceiveInventoryModalProps {
opened: boolean;
@@ -644,6 +650,218 @@ function LoadedExportTab({
);
}
/** Assigned bookings/items for an arrived import train (read-only detail view). */
function ImportTrainDetailTable({ 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 on this train.
</Text>
);
}
return (
<Table withTableBorder verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking ID</Table.Th>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Customer ID</Table.Th>
<Table.Th>Customer Name</Table.Th>
<Table.Th>Container #</Table.Th>
<Table.Th>Cargo Type</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Arrival</Table.Th>
<Table.Th>Current Status</Table.Th>
<Table.Th>Last Mile</Table.Th>
<Table.Th>Pickup Option</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((it: ImportTrainItem) => (
<Table.Tr key={it.bookingId}>
<Table.Td>
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" fw={600}>{it.bookingReference ?? '—'}</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">{it.customerId ? `${it.customerId.slice(0, 8)}` : '—'}</Text>
</Table.Td>
<Table.Td>{it.customerName ?? '—'}</Table.Td>
<Table.Td>{it.containerNumber ?? '—'}</Table.Td>
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color="gray">{it.currentStatus ?? '—'}</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" variant="light" color={it.lastMileRequested ? 'blue' : 'gray'}>
{it.lastMileRequested ? 'Yes' : 'No'}
</Badge>
</Table.Td>
<Table.Td>{it.pickupOption}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
/** Import Arrive Queue: arrived IMPORT trains, with Open (detail) + Auto Unload per train. */
function ImportArriveQueueTab({
location,
enabled,
onChanged,
}: {
location: Location;
enabled: boolean;
onChanged?: () => void;
}) {
const { toast } = useToast();
const { data: trains = [], isLoading } = useImportArriveQueue(enabled);
const unloadBooking = useUnloadBooking();
const [openId, setOpenId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const locationPayload = location.warehouseId
? { warehouseId: location.warehouseId, yardId: location.yardId, zoneId: location.zoneId }
: undefined;
const autoUnload = async (train: ImportTrain) => {
setBusyId(train.scheduleId);
try {
const items = (await warehouseService.importTrainItems(train.scheduleId)).data;
if (items.length === 0) {
toast({ title: 'No bookings to unload on this train' });
return;
}
let ok = 0;
for (const it of items) {
try {
await unloadBooking.mutateAsync({ bookingId: it.bookingId, payload: locationPayload });
ok += 1;
} catch {
/* already unloaded / not eligible — skip */
}
}
toast({
title: `Auto-unloaded ${ok}/${items.length} booking(s)`,
description: ok < items.length ? `${items.length - ok} skipped (already unloaded or not eligible)` : undefined,
});
onChanged?.();
} catch (error) {
toast({ variant: 'destructive', title: 'Auto unload failed', description: extractErrorMessage(error) });
} finally {
setBusyId(null);
}
};
return (
<Stack gap="sm" mt="sm">
<Text size="sm" c="dimmed">
<b>{trains.length}</b> arrived import train{trains.length !== 1 ? 's' : ''}
</Text>
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : trains.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No arrived import trains. Trains appear here once their schedule status is ARRIVED.
</Text>
) : (
<Table.ScrollContainer minWidth={1500}>
<Table highlightOnHover verticalSpacing="xs" striped>
<Table.Thead>
<Table.Tr>
<Table.Th>Schedule ID</Table.Th>
<Table.Th>Train #</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Origin</Table.Th>
<Table.Th>Destination</Table.Th>
<Table.Th>Arrival Time</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((t: ImportTrain) => {
const isOpen = openId === t.scheduleId;
return (
<Fragment key={t.scheduleId}>
<Table.Tr>
<Table.Td>
<Text size="xs" c="dimmed">{t.scheduleId.slice(0, 8)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>{t.trainNumber ?? '—'}</Text>
</Table.Td>
<Table.Td>{t.route ?? '—'}</Table.Td>
<Table.Td>{t.origin ?? '—'}</Table.Td>
<Table.Td>{t.destination ?? '—'}</Table.Td>
<Table.Td>{formatDate(t.arrivalTime)}</Table.Td>
<Table.Td ta="center">{t.totalBookings}</Table.Td>
<Table.Td ta="center">{t.totalContainers}</Table.Td>
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
<Table.Td>
<Badge color="indigo" variant="light" size="sm">{t.status}</Badge>
</Table.Td>
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button
size="compact-xs"
variant="light"
leftSection={isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
onClick={() => setOpenId(isOpen ? null : t.scheduleId)}
>
Open
</Button>
<Button
size="compact-xs"
color="indigo"
leftSection={<Truck size={14} />}
loading={busyId === t.scheduleId}
onClick={() => autoUnload(t)}
>
Auto Unload
</Button>
</Group>
</Table.Td>
</Table.Tr>
{isOpen && (
<Table.Tr>
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
<ImportTrainDetailTable scheduleId={t.scheduleId} />
</Table.Td>
</Table.Tr>
)}
</Fragment>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Stack>
);
}
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
@@ -669,7 +887,29 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
</Tabs.List>
<Tabs.Panel value="IMPORT">
<EligibleTab direction="IMPORT" location={location} enabled={opened} onChanged={onReceived} />
<Tabs defaultValue="arrive-queue" mt="xs">
<Tabs.List>
<Tabs.Tab value="arrive-queue" leftSection={<Train size={14} />}>
Arrive Queue
</Tabs.Tab>
<Tabs.Tab value="unloaded-queue">Unloaded Queue</Tabs.Tab>
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="arrive-queue">
<ImportArriveQueueTab location={location} enabled={opened} onChanged={onReceived} />
</Tabs.Panel>
<Tabs.Panel value="unloaded-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">
Unloaded Queue coming in the next batch.
</Text>
</Tabs.Panel>
<Tabs.Panel value="dispatch-queue">
<Text c="dimmed" ta="center" py="lg" size="sm">
Dispatch Queue coming in the next batch.
</Text>
</Tabs.Panel>
</Tabs>
</Tabs.Panel>
<Tabs.Panel value="EXPORT">
<Tabs defaultValue="receive-queue" mt="xs">