feat(warehouses): load trains from the warehouse, mirroring the schedule

Ready to Load listed inventory and offered an auto-load picker, but the
warehouse floor works train by train: a train stands at the yard, its
bookings board wagon by wagon, it rolls. That view existed only on the
train schedule workspace, which the warehouse staff do not run.

Ready to Load now has an Items / By train switch. By train is a mirror
of the schedule's own column, placed where the loading actually happens.

Nothing in it has its own rules. Train position, the per-yard loading and
unloading windows, and the per-booking Load / Wagons / Unload actions all
come from the train-scheduling endpoints the schedule workspace already
calls, so a Load that would be refused there is disabled here with the
same reason and the two surfaces cannot disagree. No train-scheduling
code is touched.

What the warehouse adds is what the schedule cannot see: which of the
train's bookings are physically in the shed, with their GRN and
inspection state, laid out along the flow the staff follow -- receive and
GRN, inspect, ready, open the loading window, train at yard, load per
wagon, dispatch, unload at port.

Wagons
- The wagon modal calls the same per-wagon journey endpoints, so every
  server gate (train at the yard, window started, PAID, GRN) is the
  schedule's own.
- Wagons go one at a time in order: the server flips the booking to
  IN_TRANSIT or ARRIVED on whichever call clears the last wagon, so
  sequential is required, not merely tidy. 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: cancelling wagons that will not ride, and
  the direct truck-to-train handover. Both are commercial decisions
  (fees, credits, GRN waiver) that belong to the schedule workspace.

loadable-trains takes includeDispatched. Loading follows the train after
it rolls, since a mid-corridor warehouse boards its cargo when the train
stands at its yard, and the train-centric view needs the same set the
schedule offers Load on. The default stays pre-dispatch only, so the
existing auto-load picker is unchanged.

Also fixes the backoffice build: ReceiveInventoryModal used MultiSelect
without importing it, left behind by the self-haul assignment work.
This commit is contained in:
hager
2026-09-03 04:36:55 +00:00
parent 31cdf2ebcc
commit 076884623e
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 ?? [];
},