mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Export Load on train
This commit is contained in:
@@ -387,7 +387,9 @@ export class WarehouseFeeService {
|
|||||||
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
// PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total,
|
||||||
// which is stored in the cargo's own unit of measure.
|
// which is stored in the cargo's own unit of measure.
|
||||||
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
const cargoQuantity = Math.max(0, Number(item.cargoQuantity) || 0);
|
||||||
const quantity = basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
// Double handling applies to IMPORT only — no charge for export/domestic.
|
||||||
|
const isImport = (item.tradeDirection ?? '').toUpperCase() === 'IMPORT';
|
||||||
|
const quantity = !isImport ? 0 : basis === 'PER_CONTAINER' ? containerCount : cargoQuantity;
|
||||||
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
const sourceAmount = Math.round(rate * quantity * 100) / 100;
|
||||||
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
|
||||||
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
const convertedRate = ruleCurrency ? await this.convertAmount(rate, ruleCurrency, targetCurrency) : 0;
|
||||||
@@ -455,6 +457,32 @@ export class WarehouseFeeService {
|
|||||||
);
|
);
|
||||||
if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`);
|
||||||
|
|
||||||
|
// Truck detention applies to IMPORT only — no charge for export/domestic.
|
||||||
|
if ((leg.tradeDirection ?? '').toUpperCase() !== 'IMPORT') {
|
||||||
|
const cur = this.normalizeCurrency(billingCurrency);
|
||||||
|
return {
|
||||||
|
ruleType: 'TRUCK_DETENTION_FEE',
|
||||||
|
basis: null,
|
||||||
|
ruleId: null,
|
||||||
|
ruleName: null,
|
||||||
|
freeDays: 0,
|
||||||
|
ratePerDay: 0,
|
||||||
|
currency: cur,
|
||||||
|
ruleCurrency: null,
|
||||||
|
billingCurrency: cur,
|
||||||
|
startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null,
|
||||||
|
endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : new Date()).toISOString(),
|
||||||
|
endIsOpen: !leg.deliveredAt,
|
||||||
|
elapsedDays: 0,
|
||||||
|
chargeableDays: 0,
|
||||||
|
containerCount: 0,
|
||||||
|
billableUnits: 0,
|
||||||
|
amount: 0,
|
||||||
|
tiers: [],
|
||||||
|
groups: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Group the leg's vehicles by type so each truck type is billed by its own
|
// Group the leg's vehicles by type so each truck type is billed by its own
|
||||||
// matching rule (rates differ by truck type). Falls back to one untyped group.
|
// matching rule (rates differ by truck type). Falls back to one untyped group.
|
||||||
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> =
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Select,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { TrainFront } from 'lucide-react';
|
import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
|
import {
|
||||||
|
warehouseService,
|
||||||
|
type LoadableTrain,
|
||||||
|
type TrainLoadableItem,
|
||||||
|
} from '@/services/warehouse.service';
|
||||||
import { extractErrorMessage } from './options';
|
import { extractErrorMessage } from './options';
|
||||||
|
|
||||||
const STAGE_COLOR: Record<string, string> = {
|
const STAGE_COLOR: Record<string, string> = {
|
||||||
@@ -29,224 +33,275 @@ const STAGE_COLOR: Record<string, string> = {
|
|||||||
|
|
||||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
||||||
|
|
||||||
|
interface BookingGroup {
|
||||||
|
bookingId: string | null;
|
||||||
|
bookingReference: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
items: TrainLoadableItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] {
|
||||||
|
const map = new Map<string, BookingGroup>();
|
||||||
|
for (const i of items) {
|
||||||
|
const key = i.bookingId ?? i.bookingReference ?? 'unknown';
|
||||||
|
let g = map.get(key);
|
||||||
|
if (!g) {
|
||||||
|
g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] };
|
||||||
|
map.set(key, g);
|
||||||
|
}
|
||||||
|
g.items.push(i);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
|
* Load to Train — a datatable of allocated EXPORT trains. Expand a train to see
|
||||||
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
|
* the bookings allocated to it; expand a booking to see its containers/cargoes
|
||||||
* already-allocated wagons. Loading follows train + wagon allocation: only items
|
* and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an
|
||||||
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
|
* allocated wagon are selectable.
|
||||||
*/
|
*/
|
||||||
export function LoadToTrainPanel() {
|
export function LoadToTrainPanel() {
|
||||||
const { toast } = useToast();
|
const { data: trains = [], isLoading } = useQuery({
|
||||||
const queryClient = useQueryClient();
|
queryKey: ['loadable-trains'],
|
||||||
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(),
|
queryFn: () => warehouseService.getLoadableTrains(),
|
||||||
});
|
});
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
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) =>
|
const toggle = (id: string) =>
|
||||||
|
setExpanded((s) => {
|
||||||
|
const next = new Set(s);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (trains.length === 0) {
|
||||||
|
return (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.ScrollContainer minWidth={720}>
|
||||||
|
<Table verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th w={40} />
|
||||||
|
<Table.Th>Train</Table.Th>
|
||||||
|
<Table.Th>Route</Table.Th>
|
||||||
|
<Table.Th ta="center">Ready</Table.Th>
|
||||||
|
<Table.Th ta="center">Loaded</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{trains.map((t) => (
|
||||||
|
<TrainRow
|
||||||
|
key={t.scheduleId}
|
||||||
|
train={t}
|
||||||
|
expanded={expanded.has(t.scheduleId)}
|
||||||
|
onToggle={() => toggle(t.scheduleId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) {
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['train-loadable-items', train.scheduleId],
|
||||||
|
queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId),
|
||||||
|
enabled: expanded,
|
||||||
|
});
|
||||||
|
const bookings = useMemo(() => groupByBooking(items), [items]);
|
||||||
|
const route =
|
||||||
|
train.origin || train.destination ? `${train.origin ?? '?'} → ${train.destination ?? '?'}` : '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
|
||||||
|
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<TrainFront size={16} />
|
||||||
|
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{route}</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge color="blue" variant="light">
|
||||||
|
{train.readyCount}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Badge color="green" variant="light">
|
||||||
|
{train.loadedCount}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
{expanded && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={5} p={0}>
|
||||||
|
<Box p="sm" bg="var(--mantine-color-gray-0)">
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="md">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : bookings.length === 0 ? (
|
||||||
|
<Alert color="gray" variant="light">
|
||||||
|
No arrived containers/cargoes allocated to this train yet.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length;
|
||||||
|
const selectable = booking.items.filter((i) => i.loadable);
|
||||||
|
const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id));
|
||||||
|
const toggleItem = (id: string) =>
|
||||||
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||||
const toggleAll = () =>
|
const toggleAll = () =>
|
||||||
setSelected((s) =>
|
setSelected((s) =>
|
||||||
allSelected
|
allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id),
|
||||||
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
|
|
||||||
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadMutation = useMutation({
|
const loadMutation = useMutation({
|
||||||
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
|
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] });
|
||||||
queryClient.invalidateQueries({ queryKey: trainsKey });
|
queryClient.invalidateQueries({ queryKey: ['loadable-trains'] });
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
toast({
|
toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` });
|
||||||
title: 'Loaded onto train',
|
|
||||||
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (e) =>
|
onError: (e) =>
|
||||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(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 (
|
return (
|
||||||
<Stack gap="md">
|
<Paper withBorder radius="sm" p="xs">
|
||||||
<Group align="flex-end" justify="space-between">
|
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
|
||||||
<Select
|
<Group gap="xs" wrap="nowrap">
|
||||||
label="Train"
|
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
description="Allocated EXPORT trains awaiting loading"
|
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
|
||||||
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
|
<Text size="sm" c="dimmed">
|
||||||
data={trainOptions}
|
{booking.customerName ?? '—'}
|
||||||
value={scheduleId}
|
</Text>
|
||||||
onChange={(v) => {
|
</Group>
|
||||||
setScheduleId(v);
|
<Group gap="xs" wrap="nowrap">
|
||||||
setSelected([]);
|
<Badge variant="light" color="blue">
|
||||||
setTab('received');
|
{booking.items.length} item(s)
|
||||||
}}
|
</Badge>
|
||||||
disabled={trainOptions.length === 0}
|
{loadedCount > 0 && (
|
||||||
leftSection={<TrainFront size={16} />}
|
<Badge variant="light" color="green">
|
||||||
w={460}
|
{loadedCount} loaded
|
||||||
searchable
|
</Badge>
|
||||||
/>
|
)}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!scheduleId ? (
|
{open && (
|
||||||
<Alert color="gray" variant="light">
|
<>
|
||||||
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
|
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
|
||||||
after train and wagon allocation.
|
<Table.Thead>
|
||||||
</Alert>
|
<Table.Tr>
|
||||||
) : (
|
<Table.Th w={36}>
|
||||||
<>
|
<Checkbox
|
||||||
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
|
checked={allSelected}
|
||||||
<Tabs.List>
|
indeterminate={!allSelected && selected.length > 0}
|
||||||
<Tabs.Tab
|
onChange={toggleAll}
|
||||||
value="received"
|
disabled={selectable.length === 0}
|
||||||
rightSection={
|
/>
|
||||||
<Badge size="xs" variant="light" color="blue">
|
</Table.Th>
|
||||||
{received.length}
|
<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>Inspection</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{booking.items.map((i) => (
|
||||||
|
<Table.Tr key={i.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(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>
|
</Badge>
|
||||||
}
|
</Table.Td>
|
||||||
>
|
<Table.Td>
|
||||||
Received
|
{i.wagonNumber ? (
|
||||||
</Tabs.Tab>
|
<Badge variant="outline" color="indigo">
|
||||||
<Tabs.Tab
|
{i.wagonNumber}
|
||||||
value="loaded"
|
</Badge>
|
||||||
rightSection={
|
) : (
|
||||||
<Badge size="xs" variant="light" color="green">
|
<Text size="xs" c="red">
|
||||||
{loaded.length}
|
Not allocated
|
||||||
</Badge>
|
</Text>
|
||||||
}
|
)}
|
||||||
>
|
</Table.Td>
|
||||||
Loaded
|
<Table.Td>
|
||||||
</Tabs.Tab>
|
{i.inspectionStatus ? (
|
||||||
</Tabs.List>
|
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||||
</Tabs>
|
{i.inspectionStatus}
|
||||||
|
</Badge>
|
||||||
{isLoading ? (
|
) : (
|
||||||
<Group justify="center" py="lg">
|
'—'
|
||||||
<Loader />
|
)}
|
||||||
</Group>
|
</Table.Td>
|
||||||
) : visible.length === 0 ? (
|
</Table.Tr>
|
||||||
<Alert color="gray" variant="light">
|
))}
|
||||||
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
|
</Table.Tbody>
|
||||||
</Alert>
|
</Table>
|
||||||
) : (
|
<Group justify="space-between" align="center" mt="xs">
|
||||||
<Table.ScrollContainer minWidth={900}>
|
<Text size="xs" c="dimmed">
|
||||||
<Table striped highlightOnHover verticalSpacing="xs">
|
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
|
||||||
<Table.Thead>
|
</Text>
|
||||||
<Table.Tr>
|
<Button
|
||||||
<Table.Th>
|
size="compact-sm"
|
||||||
{tab === 'received' && (
|
color="edr-green"
|
||||||
<Checkbox
|
leftSection={<TrainFront size={14} />}
|
||||||
checked={allSelected}
|
disabled={selected.length === 0}
|
||||||
indeterminate={!allSelected && selected.length > 0}
|
loading={loadMutation.isPending}
|
||||||
onChange={toggleAll}
|
onClick={() => loadMutation.mutate()}
|
||||||
disabled={selectableVisible.length === 0}
|
>
|
||||||
/>
|
Load {selected.length || ''} onto train
|
||||||
)}
|
</Button>
|
||||||
</Table.Th>
|
</Group>
|
||||||
<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>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1282,6 +1282,17 @@ const LastMilePage = () => {
|
|||||||
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
|
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
|
||||||
return (
|
return (
|
||||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
|
{pastTransit && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<Receipt size={13} />}
|
||||||
|
onClick={() => setDetentionRecord(row.original)}
|
||||||
|
>
|
||||||
|
Detention
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Menu
|
<Menu
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
width={200}
|
width={200}
|
||||||
|
|||||||
@@ -391,6 +391,8 @@ function FeeRules() {
|
|||||||
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
||||||
// with day tiers. Uses "free hours" instead of "free days".
|
// with day tiers. Uses "free hours" instead of "free days".
|
||||||
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
||||||
|
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||||
|
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||||
|
|
||||||
const resetForm = () =>
|
const resetForm = () =>
|
||||||
setForm({
|
setForm({
|
||||||
@@ -464,7 +466,7 @@ function FeeRules() {
|
|||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
ruleType: form.ruleType,
|
ruleType: form.ruleType,
|
||||||
freightType: clean(form.freightType) ?? null,
|
freightType: clean(form.freightType) ?? null,
|
||||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
|
||||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||||
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||||
@@ -636,10 +638,12 @@ function FeeRules() {
|
|||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="Trade direction"
|
label="Trade direction"
|
||||||
|
description={isImportOnly ? 'Import only for this fee type' : undefined}
|
||||||
data={TRADE}
|
data={TRADE}
|
||||||
value={form.tradeDirection || null}
|
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
|
||||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||||
clearable
|
disabled={isImportOnly}
|
||||||
|
clearable={!isImportOnly}
|
||||||
/>
|
/>
|
||||||
{isTruckDetention && (
|
{isTruckDetention && (
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
Reference in New Issue
Block a user