Merge pull request #1482 from Tria-plc/Emty-container-return

feat(warehouses): load trains from the warehouse, mirroring the schedule
This commit is contained in:
Hagernesh Tadesse
2026-09-03 07:41:28 +03:00
committed by GitHub
12 changed files with 1719 additions and 30 deletions

View File

@@ -378,6 +378,13 @@ export class LastMileRequestsService {
contractGeneratedAt: new Date(),
} as Partial<LastMileRequest>);
// Only now, with the request APPROVED, is an advance actually owed on the
// leg. The warehouse auto-accept (IMPORT inspection PASSED) may have already
// opened that leg at READY_TO_TRANSIT, so pull it back to PAYMENT_PENDING —
// otherwise this booking would be dispatchable before the customer has
// signed the contract or paid a birr. No-op for a leg this call just created.
await this.lastMileService.holdForAdvance(lastMile.id);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },

View File

@@ -0,0 +1,214 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { ADVANCE_UNPAID_MESSAGE, LastMileService } from './last-mile.service';
import type { LastMileStatus } from './entities/last-mile.entity';
import type { UpdateLastMileDto } from './dto/update-last-mile.dto';
/**
* The advance gate: a delivery becomes dispatchable (READY_TO_TRANSIT) or moves
* (IN_TRANSIT) only once the customer has paid the advance the Truck & Machinery
* chief approved.
*
* It used to leak both ways. The warehouse auto-accept (IMPORT inspection
* PASSED) opens the leg at READY_TO_TRANSIT and runs independently of the
* review, so whichever side acted second found the other already done: accept
* first and the leg was dispatchable before an advance was ever asked for;
* approve first and create() handed the existing dispatchable leg straight back
* untouched.
*/
function makeService(
opts: {
/** APPROVED requests on the booking carrying a positive advance. */
advancesDue?: number;
/** PAID LAST_MILE_ADVANCE invoices on the leg. */
advancesPaid?: number;
status?: LastMileStatus;
} = {},
) {
const leg = {
id: 'lm-1',
bookingId: 'b-1',
status: opts.status ?? 'READY_TO_TRANSIT',
vehicleId: 'v-1',
booking: { reference: 'BK-001' },
};
const query = jest.fn((sql: string) => {
if (sql.includes('customer_truck_assignments')) return Promise.resolve([]);
// The batched list enrichment, not the gate's own lookup.
if (sql.includes('FROM freight.last_mile lm')) return Promise.resolve([]);
if (sql.includes('freight.last_mile_requests')) {
return Promise.resolve([{ count: opts.advancesDue ?? 0 }]);
}
// Discriminated on the charge type, not the table: attachMileFinancials
// also queries freight.invoices (for the booking-invoice advance line).
if (sql.includes('LAST_MILE_ADVANCE')) {
return Promise.resolve([{ count: opts.advancesPaid ?? 0 }]);
}
if (sql.includes('FROM freight.bookings')) {
return Promise.resolve([
{ tradeDirection: 'IMPORT', firstMile: null, lastMile: 'Bole, Addis Ababa' },
]);
}
return Promise.resolve([]);
});
const lastMileRepository = {
findAll: jest.fn().mockResolvedValue([]),
findById: jest.fn().mockResolvedValue(leg),
create: jest.fn((row: unknown) => Promise.resolve({ id: 'lm-1', ...(row as object) })),
update: jest.fn((_id: string, patch: object) => Promise.resolve({ ...leg, ...patch })),
};
const service = new LastMileService(
lastMileRepository as never,
{} as never, // bookingsRepository
{
findById: jest.fn().mockResolvedValue({
id: 'v-1',
plateNumber: 'AA-123',
assignedDriverId: 'd-1',
assignedDriverName: 'Driver',
}),
setAvailability: jest.fn(),
releaseIfUnused: jest.fn(),
} as never, // vehiclesService
{} as never, // driversService
{} as never, // smsClient
{
query,
// DELIVERED frees the trucks this leg was holding.
manager: {
find: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
} as unknown as DataSource,
{ record: jest.fn() } as never, // history
{ findBySourceIds: jest.fn().mockResolvedValue([]) } as never, // billing
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
{} as never, // filesService
);
return { service, lastMileRepository, leg };
}
const createdStatus = (repo: { create: jest.Mock }) =>
(repo.create.mock.calls[0]?.[0] as { status?: string } | undefined)?.status;
describe('LastMileService - advance gate on creation', () => {
it('opens an auto-accepted leg at PAYMENT_PENDING when an advance is owed', async () => {
const { service, lastMileRepository } = makeService({ advancesDue: 1 });
// The warehouse path asks for no status at all - it used to get
// READY_TO_TRANSIT and hand the customer a dispatchable unpaid delivery.
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(createdStatus(lastMileRepository)).toBe('PAYMENT_PENDING');
});
it('still opens at READY_TO_TRANSIT when no approved request owes an advance', async () => {
const { service, lastMileRepository } = makeService({ advancesDue: 0 });
await service.create({ bookingId: 'b-1', advancedPayment: 0 } as never);
expect(createdStatus(lastMileRepository)).toBe('READY_TO_TRANSIT');
});
});
describe('LastMileService - advance gate on transitions', () => {
it('refuses IN_TRANSIT while the advance is unpaid', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 0 });
await expect(
service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto),
).rejects.toThrow(ADVANCE_UNPAID_MESSAGE);
});
it('refuses a leg being made dispatchable while the advance is unpaid', async () => {
const { service } = makeService({
advancesDue: 1,
advancesPaid: 0,
status: 'PAYMENT_PENDING',
});
await expect(
service.update('lm-1', { status: 'READY_TO_TRANSIT' } as UpdateLastMileDto),
).rejects.toBeInstanceOf(BadRequestException);
});
it('allows IN_TRANSIT once the advance invoice is paid', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 1 });
const updated = await service.update('lm-1', {
status: 'IN_TRANSIT',
} as UpdateLastMileDto);
expect(updated.status).toBe('IN_TRANSIT');
});
it('requires one paid advance per approved departure', async () => {
// Containers arriving across two departures get a request - and an advance
// - each. One paid advance does not release the second.
const { service } = makeService({ advancesDue: 2, advancesPaid: 1 });
await expect(
service.update('lm-1', { status: 'IN_TRANSIT' } as UpdateLastMileDto),
).rejects.toBeInstanceOf(BadRequestException);
});
it('lets the paid listener through before the invoice row is visible', async () => {
// Billing emits inline, pre-commit, when the transition joins a caller's
// transaction - so the invoice still reads unpaid here. The event is the
// proof of payment; re-reading the row would refuse the transition the
// payment just earned.
const { service } = makeService({
advancesDue: 1,
advancesPaid: 0,
status: 'PAYMENT_PENDING',
});
const updated = await service.update(
'lm-1',
{ status: 'READY_TO_TRANSIT' } as UpdateLastMileDto,
{ advanceSettled: true },
);
expect(updated.status).toBe('READY_TO_TRANSIT');
});
it('leaves states that are not transit alone', async () => {
const { service } = makeService({ advancesDue: 1, advancesPaid: 0 });
await expect(
service.update('lm-1', { status: 'DELIVERED' } as UpdateLastMileDto),
).resolves.toBeDefined();
});
});
describe('LastMileService.holdForAdvance', () => {
it('pulls an already-dispatchable leg back when approval imposes an advance', async () => {
const { service, lastMileRepository } = makeService({
advancesDue: 1,
status: 'READY_TO_TRANSIT',
});
await service.holdForAdvance('lm-1');
expect(lastMileRepository.update).toHaveBeenCalledWith(
'lm-1',
expect.objectContaining({ status: 'PAYMENT_PENDING' }),
);
});
it('never rewrites a leg that is already on the road', async () => {
const { service, lastMileRepository } = makeService({
advancesDue: 1,
status: 'IN_TRANSIT',
});
await service.holdForAdvance('lm-1');
expect(lastMileRepository.update).not.toHaveBeenCalled();
});
});

View File

@@ -59,6 +59,13 @@ const SORTABLE_FIELDS: (keyof LastMile)[] = [
'createdAt',
];
/** The states that mean the delivery is dispatchable or already on the road. */
const TRANSIT_STATUSES: LastMileStatus[] = ['READY_TO_TRANSIT', 'IN_TRANSIT'];
export const ADVANCE_UNPAID_MESSAGE =
'The last-mile advance has not been paid yet — this delivery cannot become ' +
'dispatchable or move until the advance invoice is settled.';
@Injectable()
export class LastMileService {
private readonly logger = new Logger(LastMileService.name);
@@ -93,9 +100,47 @@ export class LastMileService {
for (const r of records) {
(r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null;
}
await this.attachAdvanceState(records);
await attachMileFinancials(this.dataSource, records, 'LAST_MILE');
}
/**
* Flag the legs whose advance is still owed, so the UI can disable the actions
* the API would refuse instead of firing them into a 400. Same rule as
* {@link advanceOutstanding}, batched over the whole page.
*/
private async attachAdvanceState(records: LastMile[]): Promise<void> {
const ids = records.map((r) => r.id).filter(Boolean);
if (!ids.length) return;
const rows: Array<{ lastMileId: string; due: number; paid: number }> =
await this.dataSource.query(
`SELECT lm.id AS "lastMileId",
(SELECT COUNT(*)
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = lm.booking_id
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED'
AND COALESCE(lmr.approved_advance_amount, 0) > 0)::int AS "due",
(SELECT COUNT(*)
FROM freight.invoices i
WHERE i.source = 'last_mile'
AND i.source_id = lm.id::text
AND i.type = 'LAST_MILE_ADVANCE'
AND i.status = 'PAID'
AND i.deleted_at IS NULL)::int AS "paid"
FROM freight.last_mile lm
WHERE lm.id = ANY($1::uuid[]) AND lm.deleted_at IS NULL`,
[ids],
);
const outstanding = new Map(
rows.map((r) => [r.lastMileId, Number(r.due) > Number(r.paid)]),
);
for (const r of records) {
(r as LastMile & { advanceOutstanding?: boolean }).advanceOutstanding =
outstanding.get(r.id) ?? false;
}
}
/** Resolve a vehicle's driver + human labels, for stamping mile events onto
* the driver's timeline and naming the vehicle. Best-effort — never throws. */
private async vehicleInfo(
@@ -185,6 +230,67 @@ export class LastMileService {
}
}
/**
* Whether this booking still owes an advance on its delivery.
*
* An advance is owed for every APPROVED last-mile request carrying a positive
* approved amount — a booking whose containers arrive across several
* departures gets a request, and therefore an advance, per departure. Each is
* settled by a PAID `LAST_MILE_ADVANCE` invoice raised on the leg when the
* customer signs that request's contract, so the leg is clear only once it has
* as many paid advance invoices as the booking has approved requests.
*
* A booking with no approved request owes nothing and is unaffected: legs that
* never went through the confirmation flow keep behaving exactly as before.
* `lastMileId` is null while the leg is still being created — no invoice can
* point at a row that does not exist yet, so nothing can have been settled.
*/
private async advanceOutstanding(
bookingId: string,
lastMileId: string | null,
): Promise<boolean> {
const [due] = await this.dataSource.query(
`SELECT COUNT(*)::int AS "count"
FROM freight.last_mile_requests lmr
WHERE lmr.booking_id = $1
AND lmr.deleted_at IS NULL
AND lmr.status = 'APPROVED'
AND COALESCE(lmr.approved_advance_amount, 0) > 0`,
[bookingId],
);
const owed = Number(due?.count ?? 0);
if (!owed) return false;
if (!lastMileId) return true;
const [paid] = await this.dataSource.query(
`SELECT COUNT(*)::int AS "count"
FROM freight.invoices i
WHERE i.source = 'last_mile'
AND i.source_id = $1
AND i.type = 'LAST_MILE_ADVANCE'
AND i.status = 'PAID'
AND i.deleted_at IS NULL`,
[lastMileId],
);
return Number(paid?.count ?? 0) < owed;
}
/**
* Hold a leg at PAYMENT_PENDING because an advance has just been imposed on it.
*
* The warehouse auto-accept (IMPORT inspection PASSED) opens the leg
* independently of the chief's review, and opens it at READY_TO_TRANSIT. When
* that happens first, approval has to pull the leg back — otherwise the advance
* gate never holds on that ordering and the delivery is dispatchable unpaid.
* A leg already IN_TRANSIT or DELIVERED is left alone: that is a record of what
* happened, not a plan that can still be changed.
*/
async holdForAdvance(id: string): Promise<void> {
const record = await this.findById(id);
if (record.status !== 'READY_TO_TRANSIT') return;
await this.update(id, { status: 'PAYMENT_PENDING' } as UpdateLastMileDto);
}
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
const booking = await this.bookingsRepository.findByReference(bookingReference);
@@ -425,9 +531,16 @@ export class LastMileService {
await this.assertEdrHaulsThisBooking(dto.bookingId);
// A leg that owes an advance is not dispatchable, whatever the caller asked
// for. The warehouse auto-accept path asks for no status at all and used to
// land straight in READY_TO_TRANSIT, which let an unpaid delivery go.
const status: LastMileStatus = (await this.advanceOutstanding(dto.bookingId, null))
? 'PAYMENT_PENDING'
: (dto.status ?? 'READY_TO_TRANSIT');
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',
status,
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')),
@@ -461,11 +574,16 @@ export class LastMileService {
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
if (payload.type === 'LAST_MILE_ADVANCE') {
// Advance paid → the leg becomes dispatchable, not delivered.
await this.update(payload.sourceId, {
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto);
// Advance paid → the leg becomes dispatchable, not delivered. This event
// IS the settlement, so it carries its own way past the advance gate.
await this.update(
payload.sourceId,
{
status: 'READY_TO_TRANSIT',
advancedPayment: payload.totalAmount,
} as unknown as UpdateLastMileDto,
{ advanceSettled: true },
);
this.logger.log(
`Last-mile ${payload.sourceId} READY_TO_TRANSIT on advance invoice ${payload.invoiceId} payment`,
);
@@ -484,9 +602,32 @@ export class LastMileService {
}
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
/**
* `opts.advanceSettled` is the paid listener's own bypass, and nothing else
* should pass it: the invoice event is itself the proof of payment, and it can
* reach us inline before the invoice row commits (billing emits before commit
* when the transition is enlisted in a caller-supplied manager), so re-reading
* the invoice here would still see it unpaid and refuse the very transition the
* payment just earned.
*/
async update(
id: string,
dto: UpdateLastMileDto,
opts: { advanceSettled?: boolean } = {},
): Promise<LastMile> {
const existing = await this.findById(id);
// Nothing becomes dispatchable, and nothing moves, until the advance is paid.
if (
!opts.advanceSettled &&
dto.status !== undefined &&
dto.status !== existing.status &&
TRANSIT_STATUSES.includes(dto.status) &&
(await this.advanceOutstanding(existing.bookingId, id))
) {
throw new BadRequestException(ADVANCE_UNPAID_MESSAGE);
}
// A leg can only go IN_TRANSIT once a vehicle is assigned (allowing a vehicle
// assigned in this same request).
if (dto.status === 'IN_TRANSIT' && existing.status !== 'IN_TRANSIT') {

View File

@@ -199,9 +199,14 @@ export class WarehouseInventoryController {
@Get('loadable-trains')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' })
loadableTrains() {
return this.inventoryService.loadableTrains();
@ApiOperation({
summary:
'EXPORT trains with inventory waiting to be loaded — pre-dispatch by default; `includeDispatched=true` adds rolling trains still picking cargo up along the corridor',
})
loadableTrains(@Query('includeDispatched') includeDispatched?: string) {
return this.inventoryService.loadableTrains({
includeDispatched: includeDispatched === 'true' || includeDispatched === '1',
});
}
@Get('train/:scheduleId/loadable-items')

View File

@@ -2091,7 +2091,17 @@ export class WarehouseInventoryService {
*/
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
async loadableTrains(): Promise<LoadableTrainRow[]> {
/**
* @param includeDispatched also list DISPATCHED trains. Loading follows the
* train after it rolls — a mid-corridor warehouse boards its cargo when the
* train stands at its yard — so the warehouse's train-centric loading view
* needs the same set the schedule workspace offers Load on. The default
* (pre-dispatch only) keeps the existing auto-load picker unchanged.
*/
async loadableTrains(opts: { includeDispatched?: boolean } = {}): Promise<LoadableTrainRow[]> {
const statuses = opts.includeDispatched
? ['DRAFT', 'SCHEDULED', 'DISPATCHED']
: ['DRAFT', 'SCHEDULED'];
const rows: Array<
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
@@ -2129,7 +2139,7 @@ export class WarehouseInventoryService {
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
)
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
[['DRAFT', 'SCHEDULED']],
[statuses],
);
return rows

View File

@@ -11,8 +11,10 @@ import {
Loader,
Menu,
Modal,
MultiSelect,
NumberInput,
ScrollArea,
SegmentedControl,
Select,
SimpleGrid,
Stack,
@@ -91,6 +93,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { TrainLoadingWorkspace } from './TrainLoadingWorkspace';
import { YardLoadingWindows } from './YardLoadingWindows';
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options';
import { openPdfBlob } from './pdf';
@@ -1861,6 +1864,9 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
);
const qc = useQueryClient();
// "Items" is the inventory list with the auto-load picker; "By train" mirrors
// the train schedule's per-booking Load / Wagons / Unload workspace here.
const [view, setView] = useState<'items' | 'train'>('items');
const [trainPickerOpen, setTrainPickerOpen] = useState(false);
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [targetScheduleId, setTargetScheduleId] = useState<string | null>(null);
@@ -1952,16 +1958,50 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
}
};
if (view === 'train') {
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between" wrap="wrap">
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as 'items' | 'train')}
data={[
{ value: 'items', label: 'Items' },
{ value: 'train', label: 'By train' },
]}
/>
<Text size="xs" c="dimmed">
Per-booking Load, wagon-by-wagon loading and unloading the train schedule&apos;s own
actions, run from the warehouse.
</Text>
</Group>
<TrainLoadingWorkspace enabled={enabled} onChanged={onChanged} />
</Stack>
);
}
return (
<Stack gap="sm" mt="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
<Group justify="space-between" wrap="wrap">
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={view}
onChange={(v) => setView(v as 'items' | 'train')}
data={[
{ value: 'items', label: 'Items' },
{ value: 'train', label: 'By train' },
]}
/>
<Text size="sm" c="dimmed">
{selected.size > 0 ? (
<><b>{selected.size}</b> of {controls.filteredRows.length} selected</>
) : (
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
)}
</Text>
</Group>
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
<Button
size="compact-sm"

View File

@@ -0,0 +1,927 @@
import { useEffect, useMemo, useState } from 'react';
import { isAxiosError } from 'axios';
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Modal,
Paper,
Select,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowRight,
CheckCircle2,
Circle,
ExternalLink,
Info,
MapPin,
PackageCheck,
PackageOpen,
Train,
TrainFront,
Weight,
XCircle,
} from 'lucide-react';
import { useAuth } from '@/auth/useAuth';
import { BookingStatusBadge } from '@/components/bookings/BookingStatusBadge';
import { EntityLink } from '@/components/detail';
import { StationWorkControls } from '@/components/trainScheduling/StationWorkControls';
import { useToast } from '@/hooks/use-toast';
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
import { api } from '@/services/api';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import type { YardWorkBookingRow } from '@/types/trainScheduling';
import { extractErrorMessage, formatDate } from './options';
import { TrainWagonLoadModal } from './TrainWagonLoadModal';
/**
* Train-centric loading workspace for the warehouse — a mirror of the train
* schedule's "On this train" column, placed where the warehouse actually
* loads.
*
* Nothing here has its own rules. The train position, the per-yard loading /
* unloading windows, and the per-booking Load / Wagons / Unload actions all
* come from the train-scheduling endpoints the schedule workspace uses
* (`yard-work`, `schedules/:id/bookings/:bookingId/load|unload`, the per-wagon
* variants). The warehouse only ADDS what the schedule cannot see: which of the
* train's bookings are physically in the shed, with GRN and inspection state.
* So the two screens can never disagree — a Load that would be refused on the
* schedule is disabled here with the same reason.
*
* International port-ops shape the layout follows: cargo is worked per stop,
* only where the train stands, inside an opened work window, wagon by wagon,
* with a pre-load checklist (paid → received/GRN → inspected → wagon pinned →
* window open → train here) visible before anyone presses Load.
*/
const PAID_OR_LATER = new Set([
'PAID',
'FULLY_EXECUTED',
'IN_TRANSIT',
'ARRIVED',
'COMPLETED',
'DELIVERED',
]);
interface WarehouseSummary {
items: TrainLoadableItem[];
loaded: number;
ready: number;
hasGrn: boolean;
inspection: string | null;
wagons: string[];
weight: number;
}
function summarise(items: TrainLoadableItem[]): WarehouseSummary {
const wagons = [
...new Set(items.map((i) => i.wagonNumber).filter((w): w is string => Boolean(w))),
];
const inspections = items.map((i) => i.inspectionStatus).filter(Boolean) as string[];
return {
items,
loaded: items.filter((i) => i.status === 'LOADED').length,
ready: items.filter((i) => i.status === 'READY_FOR_LOADING').length,
hasGrn: items.some((i) => Boolean(i.grnNumber)),
inspection: inspections.includes('FAILED')
? 'FAILED'
: inspections.length && inspections.every((s) => s === 'PASSED')
? 'PASSED'
: (inspections[0] ?? null),
wagons,
weight: items.reduce((sum, i) => sum + (Number(i.weight) || 0), 0),
};
}
/** One pre-load gate: green when met, red when blocking, grey when only informative. */
function Gate({
ok,
label,
neutral,
hint,
}: {
ok: boolean;
label: string;
neutral?: boolean;
hint?: string;
}) {
const color = ok ? 'edr-green' : neutral ? 'gray' : 'red';
const Icon = ok ? CheckCircle2 : neutral ? Circle : XCircle;
const badge = (
<Badge
size="xs"
radius="sm"
variant={ok ? 'light' : 'outline'}
color={color}
leftSection={<Icon size={10} />}
style={{ textTransform: 'none' }}
>
{label}
</Badge>
);
return hint ? (
<Tooltip label={hint} withArrow>
{badge}
</Tooltip>
) : (
badge
);
}
const FLOW_STEPS = [
'Receive · GRN',
'Inspect',
'Ready',
'Open loading window',
'Train at yard',
'Load per wagon',
'Dispatch',
'Unload at port',
];
export function TrainLoadingWorkspace({
enabled = true,
onChanged,
}: {
enabled?: boolean;
onChanged?: () => void;
}) {
const { toast } = useToast();
const { user } = useAuth();
const qc = useQueryClient();
const canView = hasPermission(user, FREIGHT_PERMS.trainScheduling.view);
const canLoad = hasPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasPermission(user, FREIGHT_PERMS.trainScheduling.unload);
// Trains with warehouse cargo, rolling ones included: loading follows the
// train along the corridor, exactly as the schedule workspace allows.
const trainsQuery = useQuery({
queryKey: ['loadable-trains', { includeDispatched: true }],
queryFn: () => warehouseService.getLoadableTrains({ includeDispatched: true }),
enabled,
});
const trains = trainsQuery.data ?? [];
const [scheduleId, setScheduleId] = useState<string | null>(null);
useEffect(() => {
if (!trains.length) return;
if (scheduleId && trains.some((t) => t.scheduleId === scheduleId)) return;
// Prefer a train that still has cargo waiting; otherwise the first one.
const pick = trains.find((t) => t.readyCount > 0) ?? trains[0];
setScheduleId(pick.scheduleId);
}, [trains, scheduleId]);
const train = trains.find((t) => t.scheduleId === scheduleId) ?? null;
const ready = enabled && canView && Boolean(scheduleId);
const detailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? '' },
enabled: ready,
}),
);
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId: scheduleId ?? '' },
enabled: ready,
refetchInterval: 60_000,
}),
);
const itemsQuery = useQuery({
queryKey: ['train-loadable-items', scheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId!),
enabled: enabled && Boolean(scheduleId),
});
const schedule = detailQuery.data ?? null;
const yardWork = yardWorkQuery.data ?? null;
const items = itemsQuery.data ?? [];
const [onlyInWarehouse, setOnlyInWarehouse] = useState(false);
// ── Corridor + position (same derivation as the schedule workspace) ───────
const stations = useMemo(() => {
const stops = schedule?.stops ?? [];
if (stops.length) return stops;
return [
{
yardId: schedule?.originStation?.id ?? train?.originStationId ?? 'origin',
label: schedule?.originStation?.label ?? train?.origin ?? 'Origin',
},
{
yardId: schedule?.destinationStation?.id ?? 'destination',
label: schedule?.destinationStation?.label ?? train?.destination ?? 'Destination',
},
];
}, [schedule, train]);
const stationIdx = useMemo(() => new Map(stations.map((s, i) => [s.yardId, i])), [stations]);
const trainAtYardId = yardWork?.trainAtYardId ?? null;
const trainIdx = trainAtYardId != null ? (stationIdx.get(trainAtYardId) ?? null) : null;
const trainAtLabel = trainIdx != null ? stations[trainIdx]?.label : null;
const workLogs = yardWork?.stationWorkLogs ?? schedule?.stationWorkLogs ?? {};
const journeyById = useMemo(() => {
const map = new Map<string, YardWorkBookingRow>();
for (const yard of yardWork?.yards ?? []) {
for (const row of [...yard.toLoad, ...yard.toUnload]) map.set(row.id, row);
}
return map;
}, [yardWork]);
const warehouseByBooking = useMemo(() => {
const groups = new Map<string, TrainLoadableItem[]>();
for (const item of items) {
if (!item.bookingId) continue;
const list = groups.get(item.bookingId) ?? [];
list.push(item);
groups.set(item.bookingId, list);
}
return new Map([...groups.entries()].map(([id, list]) => [id, summarise(list)]));
}, [items]);
const onTrain = useMemo(() => {
const all = schedule?.bookings ?? [];
return onlyInWarehouse ? all.filter((b) => warehouseByBooking.has(b.id)) : all;
}, [schedule, onlyInWarehouse, warehouseByBooking]);
const corridorGroups = useMemo(() => {
const groups = new Map<string, { yardId: string; label: string; rows: typeof onTrain }>();
for (const b of onTrain) {
const yardId =
b.originYardId && stationIdx.has(b.originYardId)
? b.originYardId
: (stations[0]?.yardId ?? 'origin');
let group = groups.get(yardId);
if (!group) {
group = {
yardId,
label: stations[stationIdx.get(yardId) ?? 0]?.label ?? b.origin ?? 'Origin',
rows: [],
};
groups.set(yardId, group);
}
group.rows.push(b);
}
return [...groups.values()].sort(
(a, b) => (stationIdx.get(a.yardId) ?? 0) - (stationIdx.get(b.yardId) ?? 0),
);
}, [onTrain, stationIdx, stations]);
// Bookings alighting where the train stands — the unload side of the mirror.
const alightingHere = useMemo(
() =>
trainAtYardId
? (schedule?.bookings ?? []).filter(
(b) => b.destinationYardId === trainAtYardId && b.status === 'IN_TRANSIT',
)
: [],
[schedule, trainAtYardId],
);
const canWork = ['DRAFT', 'SCHEDULED', 'DISPATCHED'].includes(schedule?.status ?? '');
// ── Actions — the schedule's own endpoints ────────────────────────────────
const loadJourney = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const unloadJourney = useMutation(api.trainScheduling.unloadScheduleBooking.mutationOptions());
const [confirmAction, setConfirmAction] = useState<{
kind: 'load' | 'unload';
bookingId: string;
ref: string;
} | null>(null);
const [wagonModal, setWagonModal] = useState<{
bookingId: string;
ref: string;
phase: 'load' | 'unload';
} | null>(null);
const afterChange = () => {
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['train-loadable-items'] });
void qc.invalidateQueries({ queryKey: ['loadable-trains'] });
void yardWorkQuery.refetch();
void detailQuery.refetch();
onChanged?.();
};
const runConfirmedAction = () => {
if (!confirmAction || !scheduleId) return;
const { kind, bookingId, ref } = confirmAction;
setConfirmAction(null);
const mutation = kind === 'load' ? loadJourney : unloadJourney;
mutation
.mutateAsync({ scheduleId, bookingId })
.then(() => {
toast({
title: kind === 'load' ? `${ref} loaded onto the train` : `${ref} unloaded at this yard`,
});
afterChange();
})
.catch((error) =>
toast({
variant: 'destructive',
title: kind === 'load' ? 'Could not load booking' : 'Could not unload booking',
description: extractErrorMessage(
error,
kind === 'load'
? 'The train must be at the boarding yard with its loading window started.'
: 'The train must be at the destination yard with its unloading window started.',
),
}),
);
};
// ── Empty / blocked states ────────────────────────────────────────────────
if (!canView) {
return (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
This view reads the train&apos;s position and journey from train scheduling. Ask for the
<b> train scheduling: view</b> permission to use it.
</Alert>
);
}
if (trainsQuery.isLoading) {
return (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
);
}
if (trains.length === 0) {
return (
<Alert color="gray" variant="light" icon={<Info size={16} />}>
No train is boarding warehouse cargo right now. A train appears here once it is scheduled
with wagons allocated to bookings whose goods have been received at the warehouse.
</Alert>
);
}
const forbidden =
(isAxiosError(detailQuery.error) && detailQuery.error.response?.status === 403) ||
(isAxiosError(yardWorkQuery.error) && yardWorkQuery.error.response?.status === 403);
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label: `${t.trainNumber ?? t.scheduleId.slice(0, 8)} · ${t.origin ?? '?'}${t.destination ?? '?'} · dep ${
t.departureTime ? formatDate(t.departureTime) : '—'
} · ${t.readyCount} to load · ${t.loadedCount} loaded${t.status === 'DISPATCHED' ? ' · rolling' : ''}`,
}));
return (
<Stack gap="md">
{/* Train picker + position */}
<Paper withBorder radius="lg" p="md">
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
<Select
label="Train"
description="Trains with cargo of this warehouse allocated to their wagons"
data={trainOptions}
value={scheduleId}
onChange={(v) => v && setScheduleId(v)}
searchable
allowDeselect={false}
miw={420}
maw={640}
style={{ flex: 1 }}
/>
<Group gap={8} wrap="wrap">
{schedule ? (
<Badge size="sm" radius="sm" variant="light" color={canWork ? 'edr-green' : 'gray'}>
{String(schedule.status).replace(/_/g, ' ')}
</Badge>
) : null}
<Badge
size="sm"
radius="sm"
variant={trainAtLabel ? 'filled' : 'outline'}
color={trainAtLabel ? 'edr-green' : 'gray'}
leftSection={<TrainFront size={11} />}
>
{trainAtLabel ? `Train at ${trainAtLabel}` : 'Position unknown'}
</Badge>
{scheduleId ? (
<EntityLink
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
label="Open schedule"
icon={ExternalLink}
size="xs"
/>
) : null}
</Group>
</Group>
{/* Port-ops flow the actions below follow */}
<Group gap={4} mt="sm" wrap="wrap" align="center">
{FLOW_STEPS.map((step, i) => (
<Group key={step} gap={4} wrap="nowrap" align="center">
<Text size="xs" c="dimmed">
{step}
</Text>
{i < FLOW_STEPS.length - 1 ? (
<ArrowRight size={11} color="var(--mantine-color-gray-5)" />
) : null}
</Group>
))}
</Group>
</Paper>
{forbidden ? (
<Alert color="yellow" variant="light" icon={<Info size={16} />}>
The train-scheduling API refused the journey read for this train. The
<b> train scheduling: view</b> permission is required to load from here.
</Alert>
) : detailQuery.isLoading || yardWorkQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : (
<Stack gap="sm">
<Group justify="space-between" wrap="wrap">
<Text size="xs" c="dimmed">
Load is only offered where the train actually stands, inside a started loading window
the same rule the train schedule enforces. Removing a booking from the train,
cancelling wagons and direct truck-to-train stay on the schedule workspace.
</Text>
<Checkbox
size="xs"
label="Only bookings with cargo in the warehouse"
checked={onlyInWarehouse}
onChange={(e) => setOnlyInWarehouse(e.currentTarget.checked)}
/>
</Group>
{corridorGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
{onlyInWarehouse
? 'None of this trains bookings have cargo in the warehouse yet.'
: 'No bookings allocated to this train yet.'}
</Text>
) : null}
{corridorGroups.map((group) => {
const groupIdx = stationIdx.get(group.yardId) ?? 0;
const trainHere = trainAtYardId === group.yardId;
const passed = trainIdx != null && groupIdx < trainIdx;
const loadLog = workLogs[group.yardId]?.loading;
return (
<Paper key={group.yardId} withBorder radius="md" p="sm">
<Stack gap={8}>
<Group gap={8} align="center" wrap="wrap">
<MapPin size={13} color="var(--mantine-color-gray-6)" />
<Text size="xs" fw={700}>
{group.label}
</Text>
{trainHere ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color="edr-green"
leftSection={<TrainFront size={11} />}
>
Train here
</Badge>
) : passed ? (
<Badge size="sm" radius="sm" variant="light" color="gray">
Passed
</Badge>
) : (
<Badge size="sm" radius="sm" variant="outline" color="gray">
Ahead
</Badge>
)}
<Badge size="sm" radius="sm" variant="light" color="gray">
{group.rows.length}
</Badge>
</Group>
{/* The yard's loading window — same store the schedule writes. */}
{scheduleId && (trainHere || loadLog?.startedAt) ? (
<Box
p="xs"
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
background: 'var(--mantine-color-gray-0)',
}}
>
<StationWorkControls
scheduleId={scheduleId}
yardId={group.yardId}
phase="loading"
log={loadLog}
/>
</Box>
) : null}
{group.rows.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
const wh = warehouseByBooking.get(b.id) ?? null;
const loadWindowStarted = Boolean(loadLog?.startedAt);
const riding = b.status === 'IN_TRANSIT';
const done = ['ARRIVED', 'COMPLETED', 'DELIVERED'].includes(b.status ?? '');
const boardHere = trainHere;
const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false);
const paid = PAID_OR_LATER.has(b.status ?? '');
const wagonPinned = Boolean(b.wagonAssigned) || Boolean(wh?.wagons.length);
const allLoaded =
riding ||
Boolean(b.loadedAt) ||
(wh != null && wh.loaded === wh.items.length && wh.items.length > 0);
return (
<Paper key={b.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Group gap={8} align="center" wrap="wrap">
<EntityLink
to={`/dashboard/booking-requests/${b.id}`}
label={ref}
size="sm"
fw={700}
/>
{b.status ? <BookingStatusBadge status={b.status} /> : null}
{b.tradeDirection === 'DOMESTIC' ? (
<Badge size="sm" radius="sm" variant="filled" color="indigo">
Intercity
</Badge>
) : null}
{riding || Boolean(b.loadedAt) || wagonPinned ? (
<Badge
size="sm"
radius="sm"
variant={allLoaded ? 'filled' : 'light'}
color={allLoaded ? 'edr-green' : 'gray'}
>
{allLoaded
? 'Loaded'
: wh && wh.loaded > 0
? `Partly loaded ${wh.loaded}/${wh.items.length}`
: 'Unloaded'}
</Badge>
) : null}
</Group>
<Group gap={10} align="center" wrap="wrap">
<Text size="xs" c="dimmed" truncate>
{b.customer ?? '—'}
</Text>
{b.weightTons != null ? (
<Group gap={3} align="center" wrap="nowrap">
<Weight size={11} color="var(--mantine-color-gray-5)" />
<Text size="xs" c="dimmed">
{Number(b.weightTons).toFixed(1)}T
</Text>
</Group>
) : null}
{b.origin &&
b.destination &&
(b.originYardId !== schedule?.originStation?.id ||
b.destinationYardId !== schedule?.destinationStation?.id) ? (
<Text
size="xs"
c="indigo.7"
fw={600}
style={{ whiteSpace: 'nowrap' }}
>
{b.origin} {b.destination}
</Text>
) : null}
{wh ? (
<Text size="xs" c="dimmed">
{wh.items.length} item
{wh.items.length === 1 ? '' : 's'} in warehouse
{wh.wagons.length ? ` · wagon ${wh.wagons.join(', ')}` : ''}
{wh.items[0]?.grnNumber ? ` · ${wh.items[0].grnNumber}` : ''}
</Text>
) : (
<Text size="xs" c="orange.7">
Not received at the warehouse
</Text>
)}
</Group>
{/* Pre-load checklist — every server gate, visible before Load. */}
{!riding && !done ? (
<Group gap={4} wrap="wrap">
<Gate ok={paid} label="Paid" hint="Only a paid booking may board" />
<Gate
ok={Boolean(wh?.hasGrn)}
label="Received · GRN"
hint="Export cargo rides only after it was received at the warehouse and a GRN was raised"
/>
<Gate
ok={wh?.inspection === 'PASSED'}
neutral={wh?.inspection !== 'FAILED'}
label={
wh?.inspection === 'PASSED'
? 'Inspected'
: wh?.inspection === 'FAILED'
? 'Inspection failed'
: 'Not inspected'
}
hint="Inspection is recorded on the goods; it does not block loading"
/>
<Gate
ok={wagonPinned}
label="Wagon"
hint="A wagon must be pinned to the booking on this train"
/>
<Gate
ok={loadWindowStarted}
label="Loading window"
hint={`Start loading at ${group.label} on this train first`}
/>
<Gate
ok={boardHere}
label="Train here"
neutral={!passed}
hint={
boardHere
? `The train stands at ${group.label}`
: passed
? `The train already passed ${group.label}`
: `The train is ${trainAtLabel ? `at ${trainAtLabel}` : 'not here yet'}`
}
/>
</Group>
) : null}
</Stack>
<Group gap={6} wrap="nowrap" justify="flex-end" style={{ flexShrink: 0 }}>
{showLoad ? (
<Tooltip
label={
!canLoad
? "You don't have permission to load cargo"
: boardHere && !loadWindowStarted
? `Start loading at ${group.label} first`
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
: `Loads at ${group.label} — train is ${
trainAtLabel ? `at ${trainAtLabel}` : 'not there yet'
}`
}
withArrow
>
<Button
size="compact-sm"
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere || !canLoad || !loadWindowStarted}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
loadJourney.variables?.bookingId === b.id
}
onClick={() =>
setConfirmAction({
kind: 'load',
bookingId: b.id,
ref,
})
}
>
Load
</Button>
</Tooltip>
) : null}
{showLoad && boardHere ? (
<Tooltip label="Load wagon by wagon" withArrow>
<Button
size="compact-sm"
variant="light"
color="edr-green"
radius="md"
disabled={!canLoad || !loadWindowStarted}
onClick={() =>
setWagonModal({
bookingId: b.id,
ref,
phase: 'load',
})
}
>
Wagons
</Button>
</Tooltip>
) : null}
</Group>
</Group>
</Paper>
);
})}
</Stack>
</Paper>
);
})}
{/* Unload side — bookings alighting where the train stands (port arrival). */}
{trainAtYardId && alightingHere.length > 0 ? (
<Paper withBorder radius="md" p="sm">
<Stack gap={8}>
<Group gap={8} align="center" wrap="wrap">
<PackageOpen size={13} color="var(--mantine-color-orange-6)" />
<Text size="xs" fw={700}>
Unload at {trainAtLabel ?? 'this yard'}
</Text>
<Badge size="sm" radius="sm" variant="light" color="orange">
{alightingHere.length}
</Badge>
</Group>
{scheduleId ? (
<Box
p="xs"
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
background: 'var(--mantine-color-gray-0)',
}}
>
<StationWorkControls
scheduleId={scheduleId}
yardId={trainAtYardId}
phase="unloading"
log={workLogs[trainAtYardId]?.unloading}
/>
</Box>
) : null}
{alightingHere.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
const unloadWindowStarted = Boolean(
workLogs[trainAtYardId]?.unloading?.startedAt,
);
const showUnload = canWork && (journey?.canUnload ?? false);
return (
<Paper key={b.id} withBorder radius="md" p="sm">
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Stack gap={3} style={{ minWidth: 0 }}>
<Group gap={8} align="center" wrap="nowrap">
<EntityLink
to={`/dashboard/booking-requests/${b.id}`}
label={ref}
size="sm"
fw={700}
/>
{b.status ? <BookingStatusBadge status={b.status} /> : null}
</Group>
<Group gap={10} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{b.customer ?? '—'}
</Text>
{b.weightTons != null ? (
<Text size="xs" c="dimmed">
{Number(b.weightTons).toFixed(1)}T
</Text>
) : null}
</Group>
</Stack>
<Group gap={6} wrap="nowrap" justify="flex-end">
{showUnload ? (
<>
<Tooltip
label={
!canUnload
? "You don't have permission to unload cargo"
: !unloadWindowStarted
? 'Start unloading at this yard first'
: "Unload at this yard — stamps the booking's arrival"
}
withArrow
>
<Button
size="compact-sm"
variant="light"
color="orange"
radius="md"
disabled={!canUnload || !unloadWindowStarted}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&
unloadJourney.variables?.bookingId === b.id
}
onClick={() =>
setConfirmAction({
kind: 'unload',
bookingId: b.id,
ref,
})
}
>
Unload
</Button>
</Tooltip>
<Tooltip label="Unload wagon by wagon" withArrow>
<Button
size="compact-sm"
variant="light"
color="orange"
radius="md"
disabled={!canUnload || !unloadWindowStarted}
onClick={() =>
setWagonModal({
bookingId: b.id,
ref,
phase: 'unload',
})
}
>
Wagons
</Button>
</Tooltip>
</>
) : null}
</Group>
</Group>
</Paper>
);
})}
</Stack>
</Paper>
) : null}
</Stack>
)}
{wagonModal && scheduleId ? (
<TrainWagonLoadModal
scheduleId={scheduleId}
bookingId={wagonModal.bookingId}
reference={wagonModal.ref}
phase={wagonModal.phase}
onClose={() => setWagonModal(null)}
onChanged={afterChange}
/>
) : null}
{/* Confirm load / unload — same wording as the schedule workspace */}
<Modal
opened={Boolean(confirmAction)}
onClose={() => setConfirmAction(null)}
centered
radius="lg"
size="md"
withCloseButton={false}
title={
confirmAction ? (
<Group gap={10} wrap="nowrap">
<ThemeIcon
size={40}
radius="md"
variant="light"
color={confirmAction.kind === 'unload' ? 'orange' : 'edr-green'}
>
{confirmAction.kind === 'unload' ? (
<PackageOpen size={21} />
) : (
<PackageCheck size={21} />
)}
</ThemeIcon>
<div>
<Text fw={800}>
{confirmAction.kind === 'unload'
? 'Unload cargo at this yard?'
: 'Load cargo onto the train?'}
</Text>
<Text size="xs" c="dimmed">
{confirmAction.ref}
</Text>
</div>
</Group>
) : null
}
>
{confirmAction ? (
<Stack gap="md">
<Text size="sm">
{confirmAction.kind === 'unload'
? "Stamps the booking's arrival at this yard and frees its wagons for reuse."
: 'Stamps the booking as loaded at this yard and moves its warehouse inventory to LOADED. The server checks the train is actually standing here.'}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
Cancel
</Button>
<Button
color={confirmAction.kind === 'unload' ? 'orange' : 'edr-green'}
radius="md"
leftSection={<Train size={14} />}
onClick={runConfirmedAction}
>
{confirmAction.kind === 'unload' ? 'Unload' : 'Load'}
</Button>
</Group>
</Stack>
) : null}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,320 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Modal,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { CheckCircle2, Info, PackageCheck, PackageOpen, Train } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { api } from '@/services/api';
import type { BookingWagonRow } from '@/types/trainScheduling';
import { extractErrorMessage } from './options';
/**
* Wagon-by-wagon load / unload of one booking on one train — the warehouse
* mirror of the train schedule's "Wagons" button.
*
* It calls the SAME per-wagon journey endpoints the schedule workspace calls
* (`schedules/:id/bookings/:bookingId/wagons/:allocationId/load|unload`), so
* every server gate — train at the yard, loading window started, PAID, GRN —
* is the schedule's own, and the two surfaces can never disagree on what got
* loaded. Wagons go one at a time in order: the server flips the booking to
* IN_TRANSIT / ARRIVED on whichever call clears the last wagon, so sequential
* is required, not just convenient. A failure stops the run; the wagons
* already sent stay done and the toast says how many, so a retry only resends
* the rest.
*
* Deliberately NOT mirrored here: cancelling wagons that will not ride and the
* direct truck-to-train handover. Both are commercial/allocation decisions
* (fees, credits, GRN waiver) that belong to the train schedule workspace, not
* the warehouse floor.
*/
export function TrainWagonLoadModal({
scheduleId,
bookingId,
reference,
phase,
onClose,
onChanged,
}: {
scheduleId: string;
bookingId: string;
reference: string;
phase: 'load' | 'unload';
onClose: () => void;
onChanged?: () => void;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [picked, setPicked] = useState<Set<string>>(new Set());
const [submitting, setSubmitting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const wagonsQuery = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons: BookingWagonRow[] = wagonsQuery.data ?? [];
const isDone = (w: BookingWagonRow) =>
phase === 'load' ? w.status === 'LOADED' || w.status === 'DEPARTED' : w.status === 'DEPARTED';
const doneCount = wagons.filter(isDone).length;
const pending = wagons.filter((w) => !isDone(w));
const pickedPending = pending.filter((w) => picked.has(w.allocationId));
const loadWagon = useMutation(api.trainScheduling.loadScheduleBookingWagon.mutationOptions());
const unloadWagon = useMutation(api.trainScheduling.unloadScheduleBookingWagon.mutationOptions());
const act = phase === 'load' ? loadWagon : unloadWagon;
const toggle = (allocationId: string) =>
setPicked((prev) => {
const next = new Set(prev);
if (next.has(allocationId)) next.delete(allocationId);
else next.add(allocationId);
return next;
});
const afterChange = () => {
void wagonsQuery.refetch();
// The warehouse queues read inventory status, which the journey load moves.
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
void qc.invalidateQueries({ queryKey: ['train-loadable-items'] });
void qc.invalidateQueries({ queryKey: ['loadable-trains'] });
onChanged?.();
};
const submit = async () => {
const targets = pickedPending;
if (!targets.length) return;
setConfirmOpen(false);
setSubmitting(true);
let done = 0;
let completed = false;
try {
for (const w of targets) {
const r = await act.mutateAsync({
scheduleId,
bookingId,
allocationId: w.allocationId,
});
done += 1;
if (r.completed) completed = true;
}
afterChange();
setPicked(new Set());
if (completed) {
toast({
title: phase === 'load' ? 'Booking fully loaded' : 'Booking fully unloaded',
description:
phase === 'load'
? `${reference}: every wagon is loaded — the booking is in transit.`
: `${reference}: every wagon is unloaded — the booking arrived.`,
});
onClose();
} else {
toast({
title: phase === 'load' ? 'Wagons loaded' : 'Wagons unloaded',
description: `${reference}: ${done} wagon${done === 1 ? '' : 's'} ${phase === 'load' ? 'loaded' : 'unloaded'}.`,
});
}
} catch (error) {
if (done > 0) afterChange();
toast({
variant: 'destructive',
title: phase === 'load' ? 'Wagon load failed' : 'Wagon unload failed',
description: done
? `${done} wagon(s) went through before this: ${extractErrorMessage(error)}`
: extractErrorMessage(error),
});
} finally {
setSubmitting(false);
}
};
const color = phase === 'load' ? 'edr-green' : 'orange';
const Icon = phase === 'load' ? PackageCheck : PackageOpen;
return (
<Modal
opened
onClose={onClose}
title={
<Group gap={8}>
<Train size={18} />
<Text fw={700}>
{phase === 'load' ? 'Load' : 'Unload'} {reference} wagon by wagon
</Text>
</Group>
}
centered
radius="lg"
size="lg"
>
<Stack gap="sm">
<Group gap={8}>
<Badge size="sm" radius="sm" variant="light" color={doneCount ? color : 'gray'}>
{doneCount}/{wagons.length} {phase === 'load' ? 'loaded' : 'unloaded'}
</Badge>
{phase === 'load' && doneCount > 0 && pending.length > 0 ? (
<Text size="xs" c="dimmed">
The train cannot dispatch until the rest are loaded or cancelled.
</Text>
) : null}
</Group>
{wagonsQuery.isLoading ? (
<Text size="sm" c="dimmed">
Loading wagons
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
No wagon allocations yet use the whole-booking button instead.
</Text>
) : (
wagons.map((w) => (
<Paper key={w.allocationId} withBorder radius="md" p="xs">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
{!isDone(w) ? (
<Checkbox
checked={picked.has(w.allocationId)}
onChange={() => toggle(w.allocationId)}
disabled={submitting}
color={color}
aria-label={`Select wagon ${w.sequenceNo ?? ''} to ${phase}`}
/>
) : null}
<Badge size="sm" radius="sm" variant="outline" color="gray">
{w.sequenceNo != null ? `#${w.sequenceNo}` : '—'}
</Badge>
<Text size="sm" fw={600} truncate>
{w.wagonNumber ?? w.wagonType ?? 'Wagon'}
</Text>
<Text size="xs" c="dimmed">
{w.wagonTypeCode ?? ''}
{w.allocatedWeightTons ? ` · ${Number(w.allocatedWeightTons).toFixed(1)}T` : ''}
{w.containers?.length ? ` · ${w.containers.length} ctr` : ''}
</Text>
</Group>
{isDone(w) ? (
<Badge
size="sm"
radius="sm"
variant="filled"
color={color}
leftSection={<CheckCircle2 size={11} />}
>
{phase === 'load' ? 'Loaded' : 'Unloaded'}
</Badge>
) : null}
</Group>
</Paper>
))
)}
{pending.length > 0 && !confirmOpen ? (
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap={8}>
<Button
size="compact-sm"
variant="subtle"
radius="md"
disabled={submitting}
onClick={() =>
setPicked(
picked.size === pending.length
? new Set()
: new Set(pending.map((w) => w.allocationId)),
)
}
>
{picked.size === pending.length ? 'Clear all' : 'Select all'}
</Button>
<Text size="xs" c="dimmed">
{pickedPending.length} of {pending.length} selected
</Text>
</Group>
<Tooltip
label={
phase === 'load'
? 'Load the selected wagons — export cargo must already be received at the warehouse with a GRN.'
: 'Unload the selected wagons.'
}
withArrow
>
<Button
color={color}
radius="md"
leftSection={<Icon size={14} />}
disabled={!pickedPending.length || submitting}
loading={submitting}
onClick={() => setConfirmOpen(true)}
>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length || ''}
</Button>
</Tooltip>
</Group>
) : null}
{confirmOpen ? (
<Paper withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap={10} wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color={color}>
<Icon size={21} />
</ThemeIcon>
<div>
<Text fw={800}>
{phase === 'load' ? 'Load' : 'Unload'} {pickedPending.length} wagon
{pickedPending.length === 1 ? '' : 's'}?
</Text>
<Text size="xs" c="dimmed">
{reference}
</Text>
</div>
</Group>
<Text size="sm">
{phase === 'load'
? 'Stamps the selected wagons as loaded at this yard. Export cargo must already be received at the warehouse with a GRN.'
: 'Stamps the selected wagons as unloaded and frees them for reuse.'}
</Text>
{pickedPending.length < pending.length ? (
<Text size="xs" c="dimmed">
{pending.length - pickedPending.length} wagon
{pending.length - pickedPending.length === 1 ? '' : 's'} left un
{phase === 'load' ? 'loaded' : 'unloaded'} the train cannot dispatch until they
are {phase === 'load' ? 'loaded' : 'unloaded'} or cancelled.
</Text>
) : null}
<Group justify="flex-end" gap="sm">
<Button variant="default" radius="md" onClick={() => setConfirmOpen(false)}>
Back
</Button>
<Button color={color} radius="md" leftSection={<Icon size={14} />} onClick={submit}>
{phase === 'load' ? 'Load' : 'Unload'}
</Button>
</Group>
</Stack>
</Paper>
) : null}
{phase === 'load' && pending.length > 0 ? (
<Alert color="gray" variant="light" icon={<Info size={14} />} p="xs">
<Text size="xs">
A wagon that will not ride (cancel with fee / EDR fault) and direct truck-to-train
loading are decided on the train schedule workspace, not here.
</Text>
</Alert>
) : null}
</Stack>
</Modal>
);
}

View File

@@ -38,3 +38,5 @@ export { AccrualDashboard } from './AccrualDashboard';
export { DwellAgingCard } from './DwellAgingCard';
export { CycleTimeCard } from './CycleTimeCard';
export { GateThroughputCard } from './GateThroughputCard';
export { TrainLoadingWorkspace } from './TrainLoadingWorkspace';
export { TrainWagonLoadModal } from './TrainWagonLoadModal';

View File

@@ -1226,7 +1226,9 @@ const LastMilePage = () => {
// (same as "Mark In Transit") alongside the warehouse exit-weighing flow.
const handleTruckLeaving = (record: LastMileRecord) => {
openTruckArrival(record);
if (record.status === "READY_TO_TRANSIT") {
// Legs recorded before the advance gate can still sit at READY_TO_TRANSIT
// with the advance unpaid; the API would refuse the hop, so don't fire it.
if (record.status === "READY_TO_TRANSIT" && !record.advanceOutstanding) {
updateMutation.mutate({ id: record.id, data: { status: "IN_TRANSIT" } });
}
};
@@ -1414,10 +1416,17 @@ const LastMilePage = () => {
const hasDistance = row.original.exactKm != null;
// Advance: PAYMENT_PENDING→Ready, READY_TO_TRANSIT→In-transit (needs a
// vehicle), IN_TRANSIT→Delivered (needs distance/invoice).
// Mirrors the server's advance gate: nothing becomes dispatchable and
// nothing moves until the approved advance is paid. Delivery (the
// IN_TRANSIT step) is not gated, so only the two transit hops are.
const advanceBlocked =
Boolean(row.original.advanceOutstanding) &&
(nextStatus === "READY_TO_TRANSIT" || nextStatus === "IN_TRANSIT");
const canAdvance =
status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance);
!advanceBlocked &&
(status === "PAYMENT_PENDING" ||
(status === "READY_TO_TRANSIT" && assigned) ||
(status === "IN_TRANSIT" && hasDistance));
// Assign stays active until the whole load has trucks: container
// bookings until every container is on a truck; bulk until the
// tonnage is drawn down (trucks depart one by one). Already-departed
@@ -1470,9 +1479,11 @@ const LastMilePage = () => {
disabled={!nextStatus || !canAdvance}
onClick={() => handleAdvanceStatus(row.original)}
>
{nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
{advanceBlocked
? "Awaiting advance payment"
: nextStatus
? `Mark ${STATUS_META[nextStatus].label}`
: STATUS_META[row.original.status].label}
</Menu.Item>
<Menu.Divider />
<Menu.Item

View File

@@ -84,6 +84,12 @@ export interface LastMileRecord {
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
/**
* The approved last-mile advance has not been paid yet. While true the API
* refuses to make this leg dispatchable or move it, so the UI must not offer
* those transitions.
*/
advanceOutstanding?: boolean;
/** Truck-detention clock: vehicle arrival + delivery/return times. */
arrivedAt?: string | null;
deliveredAt?: string | null;

View File

@@ -235,9 +235,15 @@ export const warehouseService = {
},
// ── Load to Train ─────────────────────────────────────────────────────────
/** Pre-dispatch EXPORT trains with inventory waiting to be loaded. */
getLoadableTrains: async (): Promise<LoadableTrain[]> => {
const { data } = await apiClient.get('/warehouse-inventory/loadable-trains');
/**
* EXPORT trains with inventory waiting to be loaded. Pre-dispatch only by
* default; `includeDispatched` adds rolling trains still boarding cargo at
* corridor yards (what the train-centric loading workspace lists).
*/
getLoadableTrains: async (opts: { includeDispatched?: boolean } = {}): Promise<LoadableTrain[]> => {
const { data } = await apiClient.get('/warehouse-inventory/loadable-trains', {
params: opts.includeDispatched ? { includeDispatched: 'true' } : undefined,
});
return data?.data ?? data ?? [];
},