From 35ccf1bc956b720a5c4415d3da06f4a30303da88 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 7 Jul 2026 10:02:43 +0000 Subject: [PATCH 1/2] truck type detantion --- .../2010000000000-AddFeeRuleVehicleType.ts | 20 +++ .../modules/warehouses/dto/fee-rule.dto.ts | 5 + .../entities/warehouse-fee-rule.entity.ts | 5 + .../warehouses/warehouse-fee.service.ts | 123 ++++++++++++++---- .../warehouses/warehouse-invoice.service.ts | 53 ++++++-- .../operations/TruckDetentionModal.tsx | 32 ++++- .../pages/warehouses/WarehouseRulesPage.tsx | 15 ++- .../backoffice/src/types/warehouse.ts | 16 +++ 8 files changed, 228 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts diff --git a/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts new file mode 100644 index 000000000..a71f3cb5b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2010000000000-AddFeeRuleVehicleType.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Truck-detention rules can be scoped by vehicle type (TRUCK / VAN / TRAILER / + * TANKER / FLATBED / …), so different truck types carry different detention + * rates. Null = applies to any truck type. + */ +export class AddFeeRuleVehicleType2010000000000 implements MigrationInterface { + name = 'AddFeeRuleVehicleType2010000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts index 680eb2591..a688f3e1a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/fee-rule.dto.ts @@ -93,6 +93,11 @@ export class CreateFeeRuleDto { @Min(0) freeHours?: number; + @ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' }) + @IsOptional() + @IsString() + vehicleType?: string; + @ApiPropertyOptional({ enum: FEE_RULE_BASES, description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts index f6ad7ec8f..a233143cf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-fee-rule.entity.ts @@ -55,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity { @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) containerType?: string | null; + // Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | TANKER + // | FLATBED | …). Null = any truck type. + @Column({ name: 'vehicle_type', type: 'varchar', length: 20, nullable: true }) + vehicleType?: string | null; + @Column({ name: 'facility_id', type: 'uuid', nullable: true }) facilityId?: string | null; 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 c322482b2..947a99f58 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 @@ -14,6 +14,8 @@ interface ItemAttributes { tradeDirection: string | null; cargoTypeCode: string | null; containerTypeCode: string | null; + /** Vehicle type of the truck (truck detention scoping); null otherwise. */ + vehicleType: string | null; inventoryQuantity: number; bookingContainerCount: number; /** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ @@ -52,6 +54,16 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; + /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -194,6 +206,7 @@ export class WarehouseFeeService { if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null; + if (!check(rule.vehicleType, item.vehicleType)) return null; if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.yardId, item.yardId)) return null; @@ -430,42 +443,102 @@ export class WarehouseFeeService { * tiers by detention day) until it is delivered/returned (or now, if open). */ async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise { - const [row] = await this.dataSource.query( + const [leg] = await this.dataSource.query( `SELECT lm.arrived_at AS "arrivedAt", lm.delivered_at AS "deliveredAt", b.freight_type AS "freightType", - b.trade_direction AS "tradeDirection", - (SELECT count(*) FROM freight.last_mile_vehicle_assignments va - WHERE va.last_mile_id = lm.id AND va.deleted_at IS NULL) AS "truckCount" + b.trade_direction AS "tradeDirection" FROM freight.last_mile lm LEFT JOIN freight.bookings b ON b.id = lm.booking_id WHERE lm.id = $1 AND lm.deleted_at IS NULL`, [lastMileId], ); - if (!row) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + if (!leg) throw new NotFoundException(`Last-mile record ${lastMileId} not found`); + + // 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 }> = + await this.dataSource.query( + `SELECT v.vehicle_type AS "vehicleType", count(*)::int AS "truckCount" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + GROUP BY v.vehicle_type`, + [lastMileId], + ); + const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; - const item: ItemAttributes = { - arrivedAt: null, - gateClearedAt: null, - releaseDate: null, - freightType: row.freightType ?? null, - tradeDirection: row.tradeDirection ?? null, - cargoTypeCode: null, - containerTypeCode: null, - inventoryQuantity: 1, - bookingContainerCount: 1, - cargoQuantity: 0, - facilityId: null, - warehouseId: null, - yardId: null, - zoneId: null, - }; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); - const rule = this.bestRule( - rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), - item, + const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); + const now = new Date(); + const targetCurrency = this.normalizeCurrency(billingCurrency); + + const computed = await Promise.all( + groups.map(async (g) => { + const item: ItemAttributes = { + arrivedAt: null, + gateClearedAt: null, + releaseDate: null, + freightType: leg.freightType ?? null, + tradeDirection: leg.tradeDirection ?? null, + cargoTypeCode: null, + containerTypeCode: null, + vehicleType: g.vehicleType ?? null, + inventoryQuantity: 1, + bookingContainerCount: 1, + cargoQuantity: 0, + facilityId: null, + warehouseId: null, + yardId: null, + zoneId: null, + }; + const rule = this.bestRule(detentionRules, item); + const c = await this.computeTruckDetention( + rule, + { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + now, + billingCurrency, + ); + return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + }), ); - return this.computeTruckDetention(rule, row, new Date(), billingCurrency); + + const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; + const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); + const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + const single = computed.length === 1 ? computed[0].c : null; + const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + + return { + ruleType: 'TRUCK_DETENTION_FEE', + basis: null, + ruleId: single?.ruleId ?? null, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + freeDays: 0, + ratePerDay: single?.ratePerDay ?? 0, + currency: targetCurrency, + ruleCurrency: single?.ruleCurrency ?? null, + billingCurrency: targetCurrency, + startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, + endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), + endIsOpen: !leg.deliveredAt, + elapsedDays: chargeableDays, + chargeableDays, + containerCount: totalTrucks, + billableUnits: totalBillable, + amount: totalAmount, + tiers: single ? single.tiers : [], + groups: computed.map((x) => ({ + vehicleType: x.vehicleType, + truckCount: x.truckCount, + chargeableDays: x.c.chargeableDays, + ratePerDay: x.c.ratePerDay, + amount: x.c.amount, + ruleId: x.c.ruleId, + ruleName: x.c.ruleName, + })), + }; } private async computeTruckDetention( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 4683b3bda..9ee1dc398 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -316,19 +316,44 @@ export class WarehouseInvoiceService { ); } - const truckCount = preview.containerCount; // reused as the per-truck count - const line: InvoiceLineInput = { - chargeType: "TRUCK_DETENTION", - description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, - quantity: preview.billableUnits, - unitRate: preview.ratePerDay, - amount: preview.amount, - currency: preview.currency, - metadata: { - feeRuleId: preview.ruleId ?? null, - chargeableDays: preview.chargeableDays ?? null, - }, - }; + // One line per truck-type group (each billed by its own matching rule). Groups + // with no matching rule bill 0 and are dropped. Falls back to a single line. + const groups = preview.groups && preview.groups.length ? preview.groups : null; + const lines: InvoiceLineInput[] = groups + ? groups + .filter((g) => g.amount > 0) + .map((g) => ({ + chargeType: "TRUCK_DETENTION", + description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`, + quantity: g.truckCount * g.chargeableDays, + unitRate: g.ratePerDay, + amount: g.amount, + currency: preview.currency, + metadata: { + feeRuleId: g.ruleId ?? null, + chargeableDays: g.chargeableDays, + vehicleType: g.vehicleType ?? null, + }, + })) + : [ + { + chargeType: "TRUCK_DETENTION", + description: `Truck detention - ${preview.chargeableDays} day(s) x ${preview.containerCount} truck(s)`, + quantity: preview.billableUnits, + unitRate: preview.ratePerDay, + amount: preview.amount, + currency: preview.currency, + metadata: { + feeRuleId: preview.ruleId ?? null, + chargeableDays: preview.chargeableDays ?? null, + }, + }, + ]; + if (lines.length === 0) { + throw new BadRequestException( + "No truck detention is currently payable for this last-mile leg.", + ); + } return this.billing.generateInvoice({ source: "last_mile" as Freight.InvoiceSource, @@ -337,7 +362,7 @@ export class WarehouseInvoiceService { companyId: lm.companyId, companyProfileId: lm.companyProfileId || "", currency: billingCurrency, - lines: [line], + lines, status: Freight.InvoiceStatus.Issued, }); } diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index 1a0d96f96..2088db32d 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -154,7 +154,37 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. )} - {preview.tiers && preview.tiers.length > 0 ? ( + {preview.groups && preview.groups.length > 1 ? ( + + + + Truck type + Trucks + Days + Rate / truck / day + Amount + + + + {preview.groups.map((g, i) => ( + + + {g.vehicleType ?? 'Unknown'} + {!g.ruleId && ( + + {' '}· no rule + + )} + + {g.truckCount} + {g.chargeableDays} + {money(g.ratePerDay, preview.currency)} + {money(g.amount, preview.currency)} + + ))} + +
+ ) : preview.tiers && preview.tiers.length > 0 ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index 0e91407dc..49e79252e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -37,6 +37,7 @@ import { FEE_RULE_BASIS_LABELS, FEE_RULE_TYPES, FEE_RULE_TYPE_LABELS, + VEHICLE_TYPES, type FeeRuleBasis, type FeeRuleType, } from '@/types/warehouse'; @@ -372,6 +373,7 @@ function FeeRules() { tradeDirection: '', cargoTypeCode: '', containerType: '', + vehicleType: '', freeDays: 3, freeHours: 3, ratePerDay: 0, @@ -399,6 +401,7 @@ function FeeRules() { tradeDirection: '', cargoTypeCode: '', containerType: '', + vehicleType: '', freeDays: 3, freeHours: 3, ratePerDay: 0, @@ -469,7 +472,7 @@ function FeeRules() { ratePerDay: form.ratePerDay, currency: form.currency || 'USD', ...(isDoubleHandling ? { basis: form.basis } : {}), - ...(isTruckDetention ? { freeHours: form.freeHours } : {}), + ...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}), ...(!isDoubleHandling && tiers.length ? { tiers } : {}), }; @@ -638,6 +641,16 @@ function FeeRules() { onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} clearable /> + {isTruckDetention && ( + = { PER_ITEM: 'Per Item', }; +/** Vehicle types a Truck Detention rule can be scoped to (rates differ by truck type). */ +export const VEHICLE_TYPES = ['TRUCK', 'VAN', 'CAR', 'BUS', 'TRAILER', 'TANKER', 'FLATBED'] as const; +export type VehicleTypeCode = (typeof VEHICLE_TYPES)[number]; + export interface FeeRule { id: string; name: string; @@ -774,6 +778,8 @@ export interface FeeRule { tradeDirection?: string | null; cargoTypeCode?: string | null; containerType?: string | null; + /** Truck detention only: scope by vehicle type (null = any). */ + vehicleType?: string | null; facilityId?: string | null; warehouseId?: string | null; yardId?: string | null; @@ -820,6 +826,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; + /** Truck detention: per-vehicle-type breakdown. */ + groups?: Array<{ + vehicleType: string | null; + truckCount: number; + chargeableDays: number; + ratePerDay: number; + amount: number; + ruleId: string | null; + ruleName: string | null; + }>; } export interface AllocationPreviewResult { From 25c9bdeecde77ea3ce1745f2fdb6d319cb3575ac Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 7 Jul 2026 10:21:17 +0000 Subject: [PATCH 2/2] Export Load on train --- .../warehouses/warehouse-fee.service.ts | 30 +- .../warehouses/LoadToTrainPanel.tsx | 453 ++++++++++-------- .../src/pages/operations/LastMilePage.tsx | 11 + .../pages/warehouses/WarehouseRulesPage.tsx | 10 +- 4 files changed, 301 insertions(+), 203 deletions(-) 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 && (