mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17:43:39 +00:00
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:
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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'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 train’s 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? [];
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user