Merge pull request #507 from Tria-plc/Truckdetantion

Truckdetantion
This commit is contained in:
Hagernesh Tadesse
2026-07-07 14:49:41 +03:00
committed by GitHub
10 changed files with 529 additions and 244 deletions

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE freight.warehouse_fee_rules ADD COLUMN IF NOT EXISTS vehicle_type varchar(20)`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE freight.warehouse_fee_rules DROP COLUMN IF EXISTS vehicle_type`);
}
}

View File

@@ -93,6 +93,11 @@ export class CreateFeeRuleDto {
@Min(0) @Min(0)
freeHours?: number; freeHours?: number;
@ApiPropertyOptional({ description: 'Truck detention only: scope by vehicle type (TRUCK | VAN | TRAILER | …). Null = any.' })
@IsOptional()
@IsString()
vehicleType?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: FEE_RULE_BASES, enum: FEE_RULE_BASES,
description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.', description: 'Double-handling charge basis: PER_CONTAINER | PER_TON | PER_ITEM.',

View File

@@ -55,6 +55,11 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true }) @Column({ name: 'container_type', type: 'varchar', length: 40, nullable: true })
containerType?: string | null; 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 }) @Column({ name: 'facility_id', type: 'uuid', nullable: true })
facilityId?: string | null; facilityId?: string | null;

View File

@@ -14,6 +14,8 @@ interface ItemAttributes {
tradeDirection: string | null; tradeDirection: string | null;
cargoTypeCode: string | null; cargoTypeCode: string | null;
containerTypeCode: string | null; containerTypeCode: string | null;
/** Vehicle type of the truck (truck detention scoping); null otherwise. */
vehicleType: string | null;
inventoryQuantity: number; inventoryQuantity: number;
bookingContainerCount: number; bookingContainerCount: number;
/** Booking cargo total in the cargo's unit of measure: tonnes (PER_TON) or item count (PER_ITEM). */ /** 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; ratePerDay: number;
amount: 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; 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.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) 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.facilityId, item.facilityId)) return null;
if (!check(rule.warehouseId, item.warehouseId)) return null; if (!check(rule.warehouseId, item.warehouseId)) return null;
if (!check(rule.yardId, item.yardId)) return null; if (!check(rule.yardId, item.yardId)) return null;
@@ -374,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;
@@ -430,42 +445,128 @@ export class WarehouseFeeService {
* tiers by detention day) until it is delivered/returned (or now, if open). * tiers by detention day) until it is delivered/returned (or now, if open).
*/ */
async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> { async previewTruckDetention(lastMileId: string, billingCurrency = 'USD'): Promise<FeePreview> {
const [row] = await this.dataSource.query( const [leg] = await this.dataSource.query(
`SELECT lm.arrived_at AS "arrivedAt", `SELECT lm.arrived_at AS "arrivedAt",
lm.delivered_at AS "deliveredAt", lm.delivered_at AS "deliveredAt",
b.freight_type AS "freightType", b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection", 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"
FROM freight.last_mile lm FROM freight.last_mile lm
LEFT JOIN freight.bookings b ON b.id = lm.booking_id LEFT JOIN freight.bookings b ON b.id = lm.booking_id
WHERE lm.id = $1 AND lm.deleted_at IS NULL`, WHERE lm.id = $1 AND lm.deleted_at IS NULL`,
[lastMileId], [lastMileId],
); );
if (!row) 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
// 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 rules = await this.feeRuleRepository.findAll({ where: { isActive: true } });
const rule = this.bestRule( const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE');
rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'), const now = new Date();
item, 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( private async computeTruckDetention(

View File

@@ -316,19 +316,44 @@ export class WarehouseInvoiceService {
); );
} }
const truckCount = preview.containerCount; // reused as the per-truck count // One line per truck-type group (each billed by its own matching rule). Groups
const line: InvoiceLineInput = { // with no matching rule bill 0 and are dropped. Falls back to a single line.
chargeType: "TRUCK_DETENTION", const groups = preview.groups && preview.groups.length ? preview.groups : null;
description: `Truck detention - ${preview.chargeableDays} day(s) x ${truckCount} truck(s)${preview.tiers.length ? " using tiered tariff" : ""}`, const lines: InvoiceLineInput[] = groups
quantity: preview.billableUnits, ? groups
unitRate: preview.ratePerDay, .filter((g) => g.amount > 0)
amount: preview.amount, .map((g) => ({
currency: preview.currency, chargeType: "TRUCK_DETENTION",
metadata: { description: `Truck detention${g.vehicleType ? ` (${g.vehicleType})` : ""} - ${g.chargeableDays} day(s) x ${g.truckCount} truck(s)`,
feeRuleId: preview.ruleId ?? null, quantity: g.truckCount * g.chargeableDays,
chargeableDays: preview.chargeableDays ?? null, 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({ return this.billing.generateInvoice({
source: "last_mile" as Freight.InvoiceSource, source: "last_mile" as Freight.InvoiceSource,
@@ -337,7 +362,7 @@ export class WarehouseInvoiceService {
companyId: lm.companyId, companyId: lm.companyId,
companyProfileId: lm.companyProfileId || "", companyProfileId: lm.companyProfileId || "",
currency: billingCurrency, currency: billingCurrency,
lines: [line], lines,
status: Freight.InvoiceStatus.Issued, status: Freight.InvoiceStatus.Issued,
}); });
} }

View File

@@ -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. Still accruing no delivery/return time yet. The amount grows until the vehicle is returned.
</Text> </Text>
)} )}
{preview.tiers && preview.tiers.length > 0 ? ( {preview.groups && preview.groups.length > 1 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</Table.Th>
<Table.Th>Days</Table.Th>
<Table.Th ta="right">Rate / truck / day</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{preview.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : preview.tiers && preview.tiers.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs"> <Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>

View File

@@ -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>
); );
} }

View File

@@ -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}

View File

@@ -37,6 +37,7 @@ import {
FEE_RULE_BASIS_LABELS, FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES, FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS, FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type FeeRuleBasis, type FeeRuleBasis,
type FeeRuleType, type FeeRuleType,
} from '@/types/warehouse'; } from '@/types/warehouse';
@@ -372,6 +373,7 @@ function FeeRules() {
tradeDirection: '', tradeDirection: '',
cargoTypeCode: '', cargoTypeCode: '',
containerType: '', containerType: '',
vehicleType: '',
freeDays: 3, freeDays: 3,
freeHours: 3, freeHours: 3,
ratePerDay: 0, ratePerDay: 0,
@@ -389,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({
@@ -399,6 +403,7 @@ function FeeRules() {
tradeDirection: '', tradeDirection: '',
cargoTypeCode: '', cargoTypeCode: '',
containerType: '', containerType: '',
vehicleType: '',
freeDays: 3, freeDays: 3,
freeHours: 3, freeHours: 3,
ratePerDay: 0, ratePerDay: 0,
@@ -461,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.
@@ -469,7 +474,7 @@ function FeeRules() {
ratePerDay: form.ratePerDay, ratePerDay: form.ratePerDay,
currency: form.currency || 'USD', currency: form.currency || 'USD',
...(isDoubleHandling ? { basis: form.basis } : {}), ...(isDoubleHandling ? { basis: form.basis } : {}),
...(isTruckDetention ? { freeHours: form.freeHours } : {}), ...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}), ...(!isDoubleHandling && tiers.length ? { tiers } : {}),
}; };
@@ -633,11 +638,23 @@ 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 && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable
/>
)}
{isBulkRule && ( {isBulkRule && (
<Select <Select
label="Cargo type" label="Cargo type"

View File

@@ -763,6 +763,10 @@ export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
PER_ITEM: 'Per Item', 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 { export interface FeeRule {
id: string; id: string;
name: string; name: string;
@@ -774,6 +778,8 @@ export interface FeeRule {
tradeDirection?: string | null; tradeDirection?: string | null;
cargoTypeCode?: string | null; cargoTypeCode?: string | null;
containerType?: string | null; containerType?: string | null;
/** Truck detention only: scope by vehicle type (null = any). */
vehicleType?: string | null;
facilityId?: string | null; facilityId?: string | null;
warehouseId?: string | null; warehouseId?: string | null;
yardId?: string | null; yardId?: string | null;
@@ -820,6 +826,16 @@ export interface FeePreview {
billableUnits: number; billableUnits: number;
amount: number; amount: number;
tiers?: FeePreviewTier[]; 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 { export interface AllocationPreviewResult {