diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 947a99f58..14a3375c7 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -387,7 +387,9 @@ export class WarehouseFeeService { // PER_TON (tonnes) and PER_ITEM (piece count) both read the cargo total, // which is stored in the cargo's own unit of measure. 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 amount = ruleCurrency ? await this.convertAmount(sourceAmount, 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`); + // 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 // matching rule (rates differ by truck type). Falls back to one untyped group. const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx index 9e017d5fc..e16a29122 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/LoadToTrainPanel.tsx @@ -1,22 +1,26 @@ import { Alert, Badge, + Box, Button, Checkbox, Group, Loader, - Select, + Paper, Stack, Table, - Tabs, Text, } from '@mantine/core'; 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 { 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'; const STAGE_COLOR: Record = { @@ -29,224 +33,275 @@ const STAGE_COLOR: Record = { 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(); + 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 - * 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. + * Load to Train — a datatable of allocated EXPORT trains. Expand a train to see + * the bookings allocated to it; expand a booking to see its containers/cargoes + * and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an + * allocated wagon are selectable. */ export function LoadToTrainPanel() { - const { toast } = useToast(); - const queryClient = useQueryClient(); - const [scheduleId, setScheduleId] = useState(null); - const [tab, setTab] = useState('received'); - const [selected, setSelected] = useState([]); - - const trainsKey = ['loadable-trains']; - const { data: trains = [], isLoading: trainsLoading } = useQuery({ - queryKey: trainsKey, + const { data: trains = [], isLoading } = useQuery({ + queryKey: ['loadable-trains'], 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 [expanded, setExpanded] = useState>(new Set()); 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 ( + + + + ); + } + if (trains.length === 0) { + return ( + + No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation. + + ); + } + + return ( + + + + + + Train + Route + Ready + Loaded + + + + {trains.map((t) => ( + toggle(t.scheduleId)} + /> + ))} + +
+
+ ); +} + +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 ( + <> + + {expanded ? : } + + + + {train.trainNumber ?? train.scheduleId.slice(0, 8)} + + + {route} + + + {train.readyCount} + + + + + {train.loadedCount} + + + + {expanded && ( + + + + {isLoading ? ( + + + + ) : bookings.length === 0 ? ( + + No arrived containers/cargoes allocated to this train yet. + + ) : ( + + {bookings.map((b) => ( + + ))} + + )} + + + + )} + + ); +} + +function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + + 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])); const toggleAll = () => setSelected((s) => - allSelected - ? s.filter((id) => !selectableVisible.some((i) => i.id === id)) - : Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])), + allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id), ); const loadMutation = useMutation({ - mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected), + mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected), onSuccess: (r) => { - queryClient.invalidateQueries({ queryKey: itemsKey }); - queryClient.invalidateQueries({ queryKey: trainsKey }); + queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] }); + queryClient.invalidateQueries({ queryKey: ['loadable-trains'] }); setSelected([]); - toast({ - title: 'Loaded onto train', - description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`, - }); + toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` }); }, onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }), }); - const renderRow = (i: TrainLoadableItem) => ( - - - toggle(i.id)} - disabled={!i.loadable} - /> - - - {i.containerNumber ?? i.cargoType ?? '—'} - - {i.cargoType ?? '—'} - {weight(i.weight)} - - - {i.status.replace(/_/g, ' ')} - - - - {i.wagonNumber ? ( - - {i.wagonNumber} - - ) : ( - - Not allocated - - )} - - {i.bookingReference ?? '—'} - {i.customerName ?? '—'} - - {i.inspectionStatus ? ( - - {i.inspectionStatus} - - ) : ( - '—' - )} - - - ); - return ( - - - setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} - clearable + disabled={isImportOnly} + clearable={!isImportOnly} /> {isTruckDetention && (