mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Merge pull request #494 from Tria-plc/PertruckExit
Export Loading to train
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user