Merge pull request #494 from Tria-plc/PertruckExit

Export Loading to train
This commit is contained in:
Hagernesh Tadesse
2026-07-06 22:21:03 +03:00
committed by GitHub
10 changed files with 1205 additions and 6 deletions

View File

@@ -0,0 +1,211 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Modal,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import {
warehouseService,
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
];
const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [tab, setTab] = useState('ALL');
const [selected, setSelected] = useState<string[]>([]);
const [truckId, setTruckId] = useState<string | null>(null);
const itemsKey = ['container-items', bookingId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getContainerItems(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: trucks = [] } = useQuery({
queryKey: ['ci-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const visible = useMemo(
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: itemsKey });
setSelected([]);
toast({ title: 'Containers loaded onto truck' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="90%"
title={<Text fw={700}>Container / bulk items {bookingReference ? `· ${bookingReference}` : ''}</Text>}
>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'ALL')} mb="sm">
<Tabs.List>
{STAGE_TABS.map((t) => {
const count = t.value === 'ALL' ? items.length : items.filter((i) => i.stage === t.value).length;
return (
<Tabs.Tab key={t.value} value={t.value} rightSection={<Badge size="xs" variant="light">{count}</Badge>}>
{t.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : items.length === 0 ? (
<Alert color="gray" variant="light">No container or bulk items on this booking.</Alert>
) : (
<Stack gap="sm">
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th />
<Table.Th>Container</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Contract</Table.Th>
<Table.Th>Last mile</Table.Th>
<Table.Th ta="right">Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{visible.map((i) => (
<Table.Tr key={i.containerNumber}>
<Table.Td>
<Checkbox
checked={selected.includes(i.containerNumber)}
onChange={() => toggle(i.containerNumber)}
disabled={!isLoadable(i)}
/>
</Table.Td>
<Table.Td><Text fw={600}>{i.containerNumber}</Text></Table.Td>
<Table.Td>{i.goods ?? '—'}</Table.Td>
<Table.Td><Badge color={STAGE_COLOR[i.stage]} variant="light">{i.stage}</Badge></Table.Td>
<Table.Td>{i.truckPlate ?? '—'}</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
>
Exit Paper
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{/* Multiselect → load onto a truck */}
<Group justify="space-between" align="flex-end">
<Text size="sm" c="dimmed">{selected.length} selected</Text>
<Group gap="sm" align="flex-end">
<Select
label="Load onto truck"
placeholder={truckOptions.length ? 'Select truck' : 'No arrived truck'}
data={truckOptions}
value={truckId}
onChange={setTruckId}
disabled={truckOptions.length === 0}
w={260}
/>
<Button
color="edr-green"
disabled={selected.length === 0 || !truckId}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load selected
</Button>
</Group>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -0,0 +1,252 @@
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Select,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
RECEIVED: 'blue',
STORED: 'gray',
RESERVED: 'grape',
READY_FOR_LOADING: 'teal',
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
/**
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
* already-allocated wagons. Loading follows train + wagon allocation: only items
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [scheduleId, setScheduleId] = useState<string | null>(null);
const [tab, setTab] = useState('received');
const [selected, setSelected] = useState<string[]>([]);
const trainsKey = ['loadable-trains'];
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: trainsKey,
queryFn: () => warehouseService.getLoadableTrains(),
});
const itemsKey = ['train-loadable-items', scheduleId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
enabled: Boolean(scheduleId),
});
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
const visible = tab === 'loaded' ? loaded : received;
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label:
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
(t.origin || t.destination ? ` · ${t.origin ?? '?'}${t.destination ?? '?'}` : '') +
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
}));
const selectableVisible = visible.filter((i) => i.loadable);
const allSelected =
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
const toggle = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: itemsKey });
queryClient.invalidateQueries({ queryKey: trainsKey });
setSelected([]);
toast({
title: 'Loaded onto train',
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
});
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const renderRow = (i: TrainLoadableItem) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox
checked={selected.includes(i.id)}
onChange={() => toggle(i.id)}
disabled={!i.loadable}
/>
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.customerName ?? '—'}</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
);
return (
<Stack gap="md">
<Group align="flex-end" justify="space-between">
<Select
label="Train"
description="Allocated EXPORT trains awaiting loading"
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
data={trainOptions}
value={scheduleId}
onChange={(v) => {
setScheduleId(v);
setSelected([]);
setTab('received');
}}
disabled={trainOptions.length === 0}
leftSection={<TrainFront size={16} />}
w={460}
searchable
/>
</Group>
{!scheduleId ? (
<Alert color="gray" variant="light">
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
after train and wagon allocation.
</Alert>
) : (
<>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
<Tabs.List>
<Tabs.Tab
value="received"
rightSection={
<Badge size="xs" variant="light" color="blue">
{received.length}
</Badge>
}
>
Received
</Tabs.Tab>
<Tabs.Tab
value="loaded"
rightSection={
<Badge size="xs" variant="light" color="green">
{loaded.length}
</Badge>
}
>
Loaded
</Tabs.Tab>
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : visible.length === 0 ? (
<Alert color="gray" variant="light">
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
</Alert>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>
{tab === 'received' && (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectableVisible.length === 0}
/>
)}
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{tab === 'received' && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
</Text>
<Button
color="edr-green"
leftSection={<TrainFront size={16} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
)}
</>
)}
</Stack>
);
}

View File

@@ -62,6 +62,7 @@ import type {
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal';
@@ -2161,6 +2162,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2376,7 +2378,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Table.Td ta="right">
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Tooltip label="View details" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
<ActionIcon variant="subtle" color="gray" onClick={() => setContainerItemsItem(toInventoryItem(r))}>
<Eye size={16} />
</ActionIcon>
</Tooltip>
@@ -2500,6 +2502,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}
bookingId={containerItemsItem?.booking?.id ?? null}
bookingReference={containerItemsItem?.booking?.reference ?? null}
/>
</Stack>
);
}