mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -75,7 +75,7 @@ export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
// Granular train-scheduling actions replace the retired coarse manage:
|
||||
// create a schedule, update (assign/consist/loading/finalize/dispatch/arrive…),
|
||||
// create a schedule, update (assign/consist/finalize/dispatch/arrive…),
|
||||
// cancel a schedule, reschedule (+ maintenance), and manage global rules.
|
||||
export const TrainSchedulingCreate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.create);
|
||||
@@ -83,6 +83,18 @@ export const TrainSchedulingCreate = () =>
|
||||
export const TrainSchedulingUpdate = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.update);
|
||||
|
||||
/**
|
||||
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
|
||||
* coarse `update` so it can be granted independently of general schedule
|
||||
* editing. Same two keys gate import, export, and intercity movements alike:
|
||||
* the generic per-booking route and the intercity-specific one both use them.
|
||||
*/
|
||||
export const TrainSchedulingLoad = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.load);
|
||||
|
||||
export const TrainSchedulingUnload = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
|
||||
|
||||
export const TrainSchedulingCancel = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-schedule consist-change plan, executed automatically as the trip
|
||||
* proceeds (dispatch / checkpoint logs):
|
||||
*
|
||||
* - `planned_wagon_couples` `{ wagonId: pickupYardId }` — LOOSE wagons this
|
||||
* departure couples onto the train at a route stop. They join the built
|
||||
* train permanently when the train reaches that stop.
|
||||
* - `planned_wagon_real_cuts` `[wagonId, ...]` — cut wagons (see
|
||||
* planned_wagon_cut_yards) flagged as REAL cuts: the built train
|
||||
* permanently loses the wagon at its cut yard, instead of the default
|
||||
* soft cut where it stays in the build and only sits out this trip.
|
||||
*/
|
||||
export class SchedulePlannedWagonCouples3660000000000 implements MigrationInterface {
|
||||
name = 'SchedulePlannedWagonCouples3660000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS planned_wagon_couples jsonb
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules
|
||||
ADD COLUMN IF NOT EXISTS planned_wagon_real_cuts jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_couples
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS planned_wagon_real_cuts
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* A consist adjustment made from the TRAIN BUILDER on a train with no live
|
||||
* schedule still belongs in the wagon adjustment history — it just has no
|
||||
* schedule to point at. Relax the NOT NULL so builder detaches/attaches can
|
||||
* be recorded; every existing reader filters BY train_schedule_id or
|
||||
* train_id, so nullable rows are invisible to them.
|
||||
*/
|
||||
export class AdjustmentLogNullableSchedule3670000000000 implements MigrationInterface {
|
||||
name = 'AdjustmentLogNullableSchedule3670000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
ALTER COLUMN train_schedule_id DROP NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(_queryRunner: QueryRunner): Promise<void> {
|
||||
// No-op: restoring NOT NULL would fail on any builder-origin rows written
|
||||
// while this migration was live, re-introducing the outage it fixed.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Every slot → allocations lookup (allocator, journey load/unload, settle,
|
||||
* per-leg weight guard) filters wagon_booking_allocations by
|
||||
* train_set_wagon_id, which had no index — only booking_id and the pkey.
|
||||
* Sequential scans grow with every allocation ever written.
|
||||
*/
|
||||
export class WagonAllocationSlotIndex3680000000000 implements MigrationInterface {
|
||||
name = 'WagonAllocationSlotIndex3680000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_slot
|
||||
ON freight.wagon_booking_allocations (train_set_wagon_id)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.idx_wagon_booking_allocations_slot
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -698,8 +698,8 @@ export class BookingWagonCancellationService {
|
||||
booking,
|
||||
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
||||
whole
|
||||
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
|
||||
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -759,16 +759,6 @@ export class BookingWagonCancellationService {
|
||||
if (!source.contractId) {
|
||||
throw new BadRequestException('The original booking has no contract to rebook under.');
|
||||
}
|
||||
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
|
||||
if (
|
||||
source.contractValidUntil &&
|
||||
new Date(source.contractValidUntil).getTime() < Date.now()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
|
||||
);
|
||||
}
|
||||
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
@@ -779,6 +769,9 @@ export class BookingWagonCancellationService {
|
||||
// System actor: carries the create-booking key so the GL gate passes on
|
||||
// Path B (customs-clearance) contracts; harmless on Path A.
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
// The freight was paid while the contract was live — the credit stays
|
||||
// redeemable even after the contract's validity lapses.
|
||||
{ allowExpiredContract: true },
|
||||
);
|
||||
const newBookingId = created.booking.id;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
ConsolidationApprovalService,
|
||||
CONSOLIDATION_APPROVAL_PENDING,
|
||||
} from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
} from "./consolidation-approval.service";
|
||||
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||
@@ -14,37 +14,55 @@ import { Booking } from './entities/booking.entity';
|
||||
* decision on one side of a shared wagon is meaningless without the other), and
|
||||
* a decided pairing cannot be decided twice.
|
||||
*/
|
||||
describe('ConsolidationApprovalService', () => {
|
||||
describe("ConsolidationApprovalService", () => {
|
||||
const PENDING = {
|
||||
id: 'ap-1',
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
id: "ap-1",
|
||||
bookingId: "b-1",
|
||||
partnerBookingId: "b-2",
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedBy: 'gl-user',
|
||||
requestedBy: "gl-user",
|
||||
};
|
||||
|
||||
function makeService(overrides: {
|
||||
function makeService(
|
||||
overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
} = {}) {
|
||||
bookingsService?: Partial<Record<string, jest.Mock>>;
|
||||
/** Contract rows the id→reference lookup should return. */
|
||||
contracts?: { id: string; reference: string }[];
|
||||
/** Yard ids the caller is scoped to; null = unrestricted. */
|
||||
yardScope?: string[] | null;
|
||||
} = {},
|
||||
) {
|
||||
const approvals = {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||
findById: jest.fn().mockResolvedValue(PENDING),
|
||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
|
||||
decide: jest.fn().mockResolvedValue(true),
|
||||
findQueue: jest.fn().mockResolvedValue([]),
|
||||
findQueuePage: jest.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
countByStatus: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ PENDING: 2, APPROVED: 4, REJECTED: 1 }),
|
||||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||
...overrides.approvals,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
({ id, reference: `BK-${id}` }) as Booking,
|
||||
findById: jest.fn(
|
||||
async (id: string) =>
|
||||
({
|
||||
id,
|
||||
reference: `BK-${id}`,
|
||||
originYardId: "mojo",
|
||||
destinationYardId: "djibouti",
|
||||
}) as Booking,
|
||||
),
|
||||
...overrides.bookingsService,
|
||||
};
|
||||
const notifier = {
|
||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||
@@ -52,8 +70,19 @@ describe('ConsolidationApprovalService', () => {
|
||||
consolidationRejectedToStaff: jest.fn(),
|
||||
operationRequestedToStaff: jest.fn(),
|
||||
};
|
||||
const contractRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(overrides.contracts ?? []),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
getRepository: jest.fn(() => contractRepo),
|
||||
};
|
||||
const yardScope = {
|
||||
getScopedYardIds: jest
|
||||
.fn()
|
||||
.mockResolvedValue(overrides.yardScope ?? null),
|
||||
};
|
||||
|
||||
const service = new ConsolidationApprovalService(
|
||||
@@ -62,27 +91,35 @@ describe('ConsolidationApprovalService', () => {
|
||||
bookingsService as never,
|
||||
notifier as never,
|
||||
dataSource as never,
|
||||
yardScope as never,
|
||||
);
|
||||
return { service, approvals, bookingsRepository, notifier };
|
||||
return {
|
||||
service,
|
||||
approvals,
|
||||
bookingsRepository,
|
||||
notifier,
|
||||
yardScope,
|
||||
contractRepo,
|
||||
};
|
||||
}
|
||||
|
||||
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||
it("holds BOTH halves at the gate when a pairing is created", async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
await service.requestApproval("b-1", "b-2", "gl-user");
|
||||
|
||||
expect(approvals.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
requestedBy: 'gl-user',
|
||||
bookingId: "b-1",
|
||||
partnerBookingId: "b-2",
|
||||
requestedBy: "gl-user",
|
||||
}),
|
||||
);
|
||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(
|
||||
@@ -90,94 +127,102 @@ describe('ConsolidationApprovalService', () => {
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open a second review for a pairing already pending', async () => {
|
||||
it("does not open a second review for a pairing already pending", async () => {
|
||||
const { service, approvals } = makeService({
|
||||
approvals: {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
const result = await service.requestApproval("b-1", "b-2", "gl-user");
|
||||
|
||||
expect(result).toBe(PENDING);
|
||||
expect(approvals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||
it("releases BOTH halves to Operations on approval, logging who decided", async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||
await service.approve("ap-1", "approver-1", "looks fine");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'approver-1',
|
||||
'looks fine',
|
||||
"approver-1",
|
||||
"looks fine",
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
// Operations only learns about the pair now — the gate is what kept it out.
|
||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||
it("sends BOTH halves back to GL on rejection, with the reason on each", async () => {
|
||||
const { service, approvals, bookingsRepository } = makeService();
|
||||
|
||||
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||
await service.reject("ap-1", "approver-1", "partner cargo is wrong");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
'approver-1',
|
||||
'partner cargo is wrong',
|
||||
"approver-1",
|
||||
"partner cargo is wrong",
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
"b-1",
|
||||
"partner cargo is wrong",
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
"b-2",
|
||||
"partner cargo is wrong",
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_CHANGES_REQUESTED",
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the requester approve their own pairing', async () => {
|
||||
it("lets the requester approve their own pairing", async () => {
|
||||
// No maker-checker separation: the permission alone decides who may approve,
|
||||
// and the audit trail still records requester and approver separately.
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'gl-user');
|
||||
await service.approve("ap-1", "gl-user");
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
"ap-1",
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'gl-user',
|
||||
"gl-user",
|
||||
undefined,
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason to reject', async () => {
|
||||
it("requires a reason to reject", async () => {
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||
await expect(service.reject("ap-1", "approver-1", " ")).rejects.toThrow(
|
||||
/reason is required/i,
|
||||
);
|
||||
expect(approvals.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pairing that was already decided', async () => {
|
||||
it("refuses a pairing that was already decided", async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
@@ -187,21 +232,250 @@ describe('ConsolidationApprovalService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
|
||||
/already approved/i,
|
||||
);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||
it("loses cleanly when another approver decides the same pairing first", async () => {
|
||||
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||
// affects nothing and must not move the bookings.
|
||||
const { service } = makeService({
|
||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
await expect(service.approve("ap-1", "approver-1")).rejects.toThrow(
|
||||
/already decided by someone else/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("approves a pairing that was rejected earlier, releasing both halves", async () => {
|
||||
// A rejection is not final: the reviewer may change their mind, or GL may
|
||||
// argue the case. Only an already-approved pairing is closed.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: "approver-1",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await service.approve("ap-1", "approver-2", "resolved with GL");
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to reject a pairing that was already rejected", async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.reject("ap-1", "approver-1", "still wrong"),
|
||||
).rejects.toThrow(/already rejected/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the requester and the decider on every queue row", async () => {
|
||||
// The stored ids mean nothing to a reviewer reading the history.
|
||||
const { service } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Approved,
|
||||
decidedBy: "approver-1",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
bookingsRepository: {
|
||||
resolveStaffNames: jest.fn().mockResolvedValue(
|
||||
new Map([
|
||||
["gl-user", "Selam GL"],
|
||||
["approver-1", "Abebe Approver"],
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const { items, meta, counts } = await service.queue({ pageSize: 10 });
|
||||
|
||||
expect(items[0].requestedByName).toBe("Selam GL");
|
||||
expect(items[0].decidedByName).toBe("Abebe Approver");
|
||||
// Badges count the whole queue, not the page that happened to load.
|
||||
expect(counts.APPROVED).toBe(4);
|
||||
expect(meta).toMatchObject({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("pages the queue in SQL and reports the page meta", async () => {
|
||||
// The page must be cut in the query, not sliced out of a full fetch —
|
||||
// otherwise ordering only holds within whatever page loaded.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 25 });
|
||||
const { service } = makeService({ approvals: { findQueuePage } });
|
||||
|
||||
const { meta } = await service.queue({
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith({
|
||||
status: ConsolidationApprovalStatus.Rejected,
|
||||
page: 2,
|
||||
pageSize: 10,
|
||||
});
|
||||
expect(meta).toMatchObject({
|
||||
page: 2,
|
||||
totalPages: 3,
|
||||
hasNextPage: true,
|
||||
hasPreviousPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows the queue and the badges to the caller's yards", async () => {
|
||||
// A Mojo + Adama desk sees both yards' pairings, and nothing else. The
|
||||
// badges must be narrowed too, or they promise rows the caller cannot open.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||||
const countByStatus = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ PENDING: 1, APPROVED: 0, REJECTED: 0 });
|
||||
const { service } = makeService({
|
||||
approvals: { findQueuePage, countByStatus },
|
||||
yardScope: ["mojo", "adama"],
|
||||
});
|
||||
|
||||
await service.queue({ user: { id: "u-1" }, page: 1, pageSize: 10 });
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ yardIds: ["mojo", "adama"] }),
|
||||
);
|
||||
expect(countByStatus).toHaveBeenCalledWith(["mojo", "adama"]);
|
||||
});
|
||||
|
||||
it("leaves the queue unnarrowed for an unrestricted caller", async () => {
|
||||
// Super admin, `yards:view_all`, or a desk with no yard mapping at all —
|
||||
// the mapping narrows access, it never grants it.
|
||||
const findQueuePage = jest.fn().mockResolvedValue({ items: [], total: 0 });
|
||||
const { service } = makeService({
|
||||
approvals: { findQueuePage },
|
||||
yardScope: null,
|
||||
});
|
||||
|
||||
await service.queue({ user: { id: "u-1" } });
|
||||
|
||||
expect(findQueuePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ yardIds: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to decide a pairing outside the caller's yards", async () => {
|
||||
// Hiding the row is not enough — the id is guessable from a shared link,
|
||||
// and deciding moves two other yards' bookings.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
yardScope: ["adama"],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approve("ap-1", "approver-1", undefined, { id: "u-1" }),
|
||||
).rejects.toThrow(/outside your assigned yards/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a decision when only the PARTNER half touches the caller's yard", async () => {
|
||||
// The pair is one decision, so seeing one side is seeing the pairing.
|
||||
const { service, bookingsRepository } = makeService({
|
||||
yardScope: ["dire-dawa"],
|
||||
bookingsService: {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
id === "b-2"
|
||||
? ({
|
||||
id,
|
||||
reference: "BK-b-2",
|
||||
originYardId: "djibouti",
|
||||
destinationYardId: "dire-dawa",
|
||||
} as Booking)
|
||||
: ({
|
||||
id,
|
||||
reference: "BK-b-1",
|
||||
originYardId: "mojo",
|
||||
destinationYardId: "djibouti",
|
||||
} as Booking),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await service.approve("ap-1", "approver-1", undefined, { id: "u-1" });
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches each half's contract reference for the queue link", async () => {
|
||||
// Booking has no contract relation (contract–booking split), so the
|
||||
// references are batch-loaded by id — one query for the whole page.
|
||||
const { service, contractRepo } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...PENDING,
|
||||
booking: { id: "b-1", contractId: "c-1" },
|
||||
partnerBooking: { id: "b-2", contractId: "c-2" },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
contracts: [
|
||||
{ id: "c-1", reference: "CT-001" },
|
||||
{ id: "c-2", reference: "CT-002" },
|
||||
],
|
||||
});
|
||||
|
||||
const { items } = await service.queue();
|
||||
|
||||
expect(items[0].contractReference).toBe("CT-001");
|
||||
expect(items[0].partnerContractReference).toBe("CT-002");
|
||||
expect(contractRepo.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves the contract reference null when a half has no contract", async () => {
|
||||
const { service, contractRepo } = makeService({
|
||||
approvals: {
|
||||
findQueuePage: jest.fn().mockResolvedValue({
|
||||
items: [{ ...PENDING, booking: { id: "b-1" }, partnerBooking: null }],
|
||||
total: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { items } = await service.queue();
|
||||
|
||||
expect(items[0].contractReference).toBeNull();
|
||||
expect(items[0].partnerContractReference).toBeNull();
|
||||
// Nothing to look up — no query at all.
|
||||
expect(contractRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { DataSource, In } from "typeorm";
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import {
|
||||
@@ -18,6 +19,8 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
||||
import { Contract } from "../contracts/entities/contract.entity";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
@@ -25,6 +28,15 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
||||
|
||||
/** An approval row with the requester's and decider's names resolved. */
|
||||
export type ConsolidationApprovalView = ConsolidationApproval & {
|
||||
requestedByName: string | null;
|
||||
decidedByName: string | null;
|
||||
/** Contract the booking half was created under — reviewers work by contract. */
|
||||
contractReference: string | null;
|
||||
partnerContractReference: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate.
|
||||
*
|
||||
@@ -53,6 +65,7 @@ export class ConsolidationApprovalService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,8 +129,16 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
note?: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const approval = await this.loadPending(approvalId);
|
||||
// A pairing that was rejected can still be approved later — the reviewer
|
||||
// changed their mind, or GL argued the case. Only an already-approved one
|
||||
// is final, since both halves have moved on to Operations by then.
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -125,6 +146,10 @@ export class ConsolidationApprovalService {
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
@@ -162,13 +187,17 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A reason is required to reject a consolidation.",
|
||||
);
|
||||
}
|
||||
const approval = await this.loadPending(approvalId);
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -212,9 +241,120 @@ export class ConsolidationApprovalService {
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
/**
|
||||
* One page of the review queue, or of its history: pending pairings first,
|
||||
* then the decided ones, each carrying the display name of whoever requested
|
||||
* and whoever decided it — the stored ids tell a reviewer nothing.
|
||||
*
|
||||
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
|
||||
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
|
||||
* both yards' pairings. The counts behind the tabs are narrowed the same way,
|
||||
* so a badge never promises rows the caller cannot open.
|
||||
*/
|
||||
async queue(options?: {
|
||||
status?: ConsolidationApprovalStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
|
||||
user?: unknown;
|
||||
}): Promise<{
|
||||
items: ConsolidationApprovalView[];
|
||||
total: number;
|
||||
/** Counts per status within the caller's scope — the tab badges. */
|
||||
counts: Record<ConsolidationApprovalStatus, number>;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}> {
|
||||
const page = Math.max(1, options?.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
|
||||
const yardIds = await this.scopedYardIds(options?.user);
|
||||
|
||||
const { items: rows, total } = await this.approvals.findQueuePage({
|
||||
status: options?.status,
|
||||
yardIds,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
const counts = await this.approvals.countByStatus(yardIds);
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
|
||||
);
|
||||
const contractRefs = await this.contractReferences(rows);
|
||||
const refOf = (contractId?: string | null) =>
|
||||
contractId ? (contractRefs.get(contractId) ?? null) : null;
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
...row,
|
||||
requestedByName: row.requestedBy
|
||||
? (names.get(row.requestedBy) ?? null)
|
||||
: null,
|
||||
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
|
||||
contractReference: refOf(row.booking?.contractId),
|
||||
partnerContractReference: refOf(row.partnerBooking?.contractId),
|
||||
}));
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contract id → reference for the bookings on this page.
|
||||
*
|
||||
* Booking has no contract relation (contract–booking split), so the
|
||||
* references are batch-loaded by id rather than joined — one query per page,
|
||||
* not one per row.
|
||||
*/
|
||||
private async contractReferences(
|
||||
rows: ConsolidationApproval[],
|
||||
): Promise<Map<string, string>> {
|
||||
const ids = [
|
||||
...new Set(
|
||||
rows
|
||||
.flatMap((r) => [r.booking?.contractId, r.partnerBooking?.contractId])
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return new Map();
|
||||
|
||||
const contracts = await this.dataSource.getRepository(Contract).find({
|
||||
where: { id: In(ids) },
|
||||
select: { id: true, reference: true },
|
||||
});
|
||||
return new Map(contracts.map((c) => [c.id, c.reference]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Yard ids the caller may see, or undefined for unrestricted.
|
||||
*
|
||||
* Scope comes from the desk they are logged in as: `yard_positions` maps a
|
||||
* position to its yards, so a Mojo CEO resolves to [Mojo]. A super admin, a
|
||||
* `yards:view_all` holder, and a desk with NO yard mapping all resolve to
|
||||
* unrestricted — the mapping narrows access, it never grants it.
|
||||
*
|
||||
* Called with no user only from internal paths, which are unscoped.
|
||||
*/
|
||||
private async scopedYardIds(user: unknown): Promise<string[] | undefined> {
|
||||
if (!user) return undefined;
|
||||
const scope = await this.yardScope.getScopedYardIds(user as never);
|
||||
return scope ?? undefined;
|
||||
}
|
||||
|
||||
/** Full decision history for one booking — who decided what, and when. */
|
||||
@@ -227,12 +367,46 @@ export class ConsolidationApprovalService {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
/**
|
||||
* Refuse a decision on a pairing outside the caller's yards.
|
||||
*
|
||||
* Hiding the row from the list is not enough on its own: the id is guessable
|
||||
* from a shared link, and deciding a pairing moves two other yards' bookings.
|
||||
* Same rule as the list — either half's origin or destination is enough.
|
||||
*/
|
||||
private async assertInScope(
|
||||
approval: ConsolidationApproval,
|
||||
user: unknown,
|
||||
): Promise<void> {
|
||||
const yardIds = await this.scopedYardIds(user);
|
||||
if (!yardIds) return;
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
const touches = (b: Booking | null | undefined) =>
|
||||
!!b &&
|
||||
(yardIds.includes(b.originYardId) ||
|
||||
yardIds.includes(b.destinationYardId));
|
||||
|
||||
if (!touches(booking) && !touches(partner)) {
|
||||
throw new ForbiddenException(
|
||||
"This shared wagon is outside your assigned yards.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load a row and refuse it unless it is in one of the decidable states. */
|
||||
private async loadDecidable(
|
||||
approvalId: string,
|
||||
allowed: ConsolidationApprovalStatus[],
|
||||
): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
if (!allowed.includes(approval.status)) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource, In, Repository } from "typeorm";
|
||||
import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
|
||||
/**
|
||||
* Narrow a queue query to the caller's yards.
|
||||
*
|
||||
* A shared wagon is visible when EITHER half of it starts or ends at one of
|
||||
* those yards — the pairing is one decision, so seeing one side is seeing the
|
||||
* pairing. Yards the train merely passes through do not count: only the two
|
||||
* bookings' own endpoints do.
|
||||
*
|
||||
* `undefined` means unrestricted and adds no predicate. An EMPTY array means
|
||||
* scoped-to-nothing and must match no rows — `IN ()` is not valid SQL, so it
|
||||
* gets an explicit false instead of being skipped.
|
||||
*/
|
||||
function applyYardScope(
|
||||
qb: SelectQueryBuilder<ConsolidationApproval>,
|
||||
yardIds: string[] | undefined,
|
||||
): void {
|
||||
if (!yardIds) return;
|
||||
if (!yardIds.length) {
|
||||
qb.andWhere("1 = 0");
|
||||
return;
|
||||
}
|
||||
qb.andWhere(
|
||||
`(booking.originYardId IN (:...yardIds)
|
||||
OR booking.destinationYardId IN (:...yardIds)
|
||||
OR partnerBooking.originYardId IN (:...yardIds)
|
||||
OR partnerBooking.destinationYardId IN (:...yardIds))`,
|
||||
{ yardIds },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||
* decided rows are the audit trail of who approved which pairing and when.
|
||||
@@ -48,16 +78,91 @@ export class ConsolidationApprovalsRepository {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||
findQueue(): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: { status: ConsolidationApprovalStatus.Pending },
|
||||
relations: {
|
||||
booking: { company: true },
|
||||
partnerBooking: { company: true },
|
||||
},
|
||||
order: { requestedAt: "ASC" },
|
||||
});
|
||||
/**
|
||||
* One page of review-queue rows, with both bookings loaded.
|
||||
*
|
||||
* Pending rows are work still to do, so they come oldest first (FIFO) and
|
||||
* ahead of everything else. Decided rows are history, so they come
|
||||
* newest-decision-first. Ordering is done in SQL, not after the fact — a page
|
||||
* sorted in memory would only be sorted within itself.
|
||||
*
|
||||
* `yardIds` narrows to the caller's yards (see YardScopeService); pass
|
||||
* undefined for an unrestricted caller. The narrowing is a WHERE, not a
|
||||
* post-filter, so the page and the total both count only visible rows.
|
||||
*/
|
||||
async findQueuePage(options: {
|
||||
status?: ConsolidationApprovalStatus;
|
||||
yardIds?: string[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}): Promise<{ items: ConsolidationApproval[]; total: number }> {
|
||||
const { status, yardIds, page, pageSize } = options;
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("approval")
|
||||
.leftJoinAndSelect("approval.booking", "booking")
|
||||
.leftJoinAndSelect("booking.company", "company")
|
||||
.leftJoinAndSelect("approval.partnerBooking", "partnerBooking")
|
||||
.leftJoinAndSelect("partnerBooking.company", "partnerCompany");
|
||||
|
||||
if (status) {
|
||||
qb.andWhere("approval.status = :status", { status });
|
||||
} else {
|
||||
qb.addOrderBy(
|
||||
`CASE WHEN approval.status = '${ConsolidationApprovalStatus.Pending}' THEN 0 ELSE 1 END`,
|
||||
"ASC",
|
||||
);
|
||||
}
|
||||
|
||||
applyYardScope(qb, yardIds);
|
||||
|
||||
// Pending has no decidedAt, decided rows all do — one pair of keys orders
|
||||
// both groups correctly whichever tab asked.
|
||||
const [items, total] = await qb
|
||||
.addOrderBy("approval.decidedAt", "DESC", "NULLS FIRST")
|
||||
.addOrderBy("approval.requestedAt", "ASC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row count per status, for the tab badges — those must show the whole
|
||||
* queue, not just the page currently loaded. Narrowed by the same yard scope
|
||||
* as the list, so a badge never promises rows the caller cannot open.
|
||||
*/
|
||||
async countByStatus(
|
||||
yardIds?: string[],
|
||||
): Promise<Record<ConsolidationApprovalStatus, number>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder("approval")
|
||||
.select("approval.status", "status")
|
||||
.addSelect("COUNT(*)", "count")
|
||||
.groupBy("approval.status");
|
||||
|
||||
// The scope predicate reads both bookings, so it needs them joined even
|
||||
// though the count itself selects no columns from them.
|
||||
if (yardIds) {
|
||||
qb.leftJoin("approval.booking", "booking").leftJoin(
|
||||
"approval.partnerBooking",
|
||||
"partnerBooking",
|
||||
);
|
||||
}
|
||||
applyYardScope(qb, yardIds);
|
||||
|
||||
const rows = await qb.getRawMany<{
|
||||
status: ConsolidationApprovalStatus;
|
||||
count: string;
|
||||
}>();
|
||||
|
||||
const counts = {
|
||||
[ConsolidationApprovalStatus.Pending]: 0,
|
||||
[ConsolidationApprovalStatus.Approved]: 0,
|
||||
[ConsolidationApprovalStatus.Rejected]: 0,
|
||||
};
|
||||
for (const row of rows) counts[row.status] = Number(row.count);
|
||||
return counts;
|
||||
}
|
||||
|
||||
create(input: {
|
||||
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
|
||||
| ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: string | null,
|
||||
decisionNote?: string | null,
|
||||
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
|
||||
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
|
||||
): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||
{ id, status: In(from) },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
|
||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
bookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
{
|
||||
partnerBookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
|
||||
@@ -140,6 +140,14 @@ export class ContractBookingService {
|
||||
dto: CreateBookingUnderContractDto,
|
||||
user?: { id?: string } | null,
|
||||
actorPermissions?: unknown,
|
||||
opts?: {
|
||||
/**
|
||||
* Wagon-cancellation credit rebook only: the freight was paid while the
|
||||
* contract was live, so redeeming the credit is allowed even after the
|
||||
* contract's validity lapsed. Never set for a genuinely new booking.
|
||||
*/
|
||||
allowExpiredContract?: boolean;
|
||||
},
|
||||
): Promise<CreateBookingUnderContractResult> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
@@ -180,8 +188,13 @@ export class ContractBookingService {
|
||||
actorPermissions != null &&
|
||||
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(contract, isGlActor);
|
||||
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
|
||||
const createdByRole = await this.assertGate(
|
||||
contract,
|
||||
isGlActor,
|
||||
false,
|
||||
opts?.allowExpiredContract,
|
||||
);
|
||||
|
||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
|
||||
contract: Contract,
|
||||
isGlActor: boolean,
|
||||
isInitiate = false,
|
||||
allowExpired = false,
|
||||
): Promise<string> {
|
||||
// Suspended contracts are frozen for everyone, GL included — say so instead
|
||||
// of letting the executed-status check below give a misleading reason.
|
||||
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
|
||||
}
|
||||
// No contract clearance cycle exists on either kind now — clearance runs
|
||||
// on the booking, so an executed/active contract is the only gate here.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
if (
|
||||
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
|
||||
!(allowExpired && contract.status === 'EXPIRED')
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
// Path A — customer (or staff) once the contract is executed.
|
||||
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
|
||||
if (
|
||||
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
|
||||
!(allowExpired && contract.status === 'EXPIRED')
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Contract must be fully executed before booking a shipment.',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
|
||||
/**
|
||||
* Wagon-cancellation credit rebook must work after the contract lapses (the
|
||||
* freight was paid while it was live), while every other create path stays
|
||||
* blocked. assertGate is the status gate createUnderContract runs; this pins
|
||||
* the EXPIRED carve-out to the allowExpired flag.
|
||||
*/
|
||||
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
|
||||
// assertGate only reads contract fields — no constructor deps needed.
|
||||
const service = Object.create(
|
||||
ContractBookingService.prototype,
|
||||
) as ContractBookingService;
|
||||
const gate = (
|
||||
contract: Record<string, unknown>,
|
||||
allowExpired: boolean,
|
||||
): Promise<string> =>
|
||||
(
|
||||
service as unknown as {
|
||||
assertGate: (
|
||||
c: unknown,
|
||||
gl: boolean,
|
||||
init: boolean,
|
||||
allowExpired: boolean,
|
||||
) => Promise<string>;
|
||||
}
|
||||
).assertGate(contract, true, false, allowExpired);
|
||||
|
||||
it('refuses an EXPIRED contract on the normal create path', async () => {
|
||||
await expect(
|
||||
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
|
||||
).rejects.toThrow(/fully executed/i);
|
||||
});
|
||||
|
||||
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
|
||||
await expect(
|
||||
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
|
||||
).resolves.toBe('STAFF');
|
||||
});
|
||||
|
||||
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
|
||||
await expect(
|
||||
gate(
|
||||
{
|
||||
status: 'EXPIRED',
|
||||
contractKind: 'GENERAL',
|
||||
customsClearingEnabled: true,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).resolves.toBe('GL_ET');
|
||||
});
|
||||
|
||||
it('still refuses a SUSPENDED contract even for a rebook', async () => {
|
||||
await expect(
|
||||
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
|
||||
).rejects.toThrow(/suspended/i);
|
||||
});
|
||||
|
||||
it('does not open the gate for other non-executed statuses', async () => {
|
||||
await expect(
|
||||
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
|
||||
).rejects.toThrow(/fully executed/i);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,9 @@ export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
@Index(['trainScheduleId'])
|
||||
@Index(['trainId'])
|
||||
export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
/** Null when the change was made from the train builder with no live schedule. */
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId!: string | null;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid' })
|
||||
trainId!: string;
|
||||
|
||||
@@ -139,6 +139,24 @@ export class TrainSchedule extends BaseEntity {
|
||||
@Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true })
|
||||
plannedWagonCutYards?: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* LOOSE wagons this departure plans to COUPLE onto the train at a route
|
||||
* stop: `{ wagonId: pickupYardId }`. They join the built train permanently
|
||||
* when the trip reaches that stop (dispatch for the origin, checkpoint log
|
||||
* for mid-route stops).
|
||||
*/
|
||||
@Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true })
|
||||
plannedWagonCouples?: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built
|
||||
* train permanently loses the wagon at its cut yard. Absent from this list,
|
||||
* a cut is soft — the wagon sits out the rest of this trip but stays in
|
||||
* the build.
|
||||
*/
|
||||
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
|
||||
plannedWagonRealCuts?: string[] | null;
|
||||
|
||||
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
|
||||
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
|
||||
bookingWindowStatus!: string;
|
||||
|
||||
@@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
return manager ? manager.getRepository(TrainSchedule) : this.repository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim consist view for read paths that only need the route stops, the
|
||||
* built train, and slot→allocation existence (e.g. the schedule-yards tab):
|
||||
* skips the booking/company/container branches of the full graph, which
|
||||
* dominate its cost and go unused there.
|
||||
*/
|
||||
findByIdWithConsistLite(id: string): Promise<TrainSchedule | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
route: { milestones: { yard: true } },
|
||||
trainSet: {
|
||||
train: true,
|
||||
locomotive: true,
|
||||
locomotives: { locomotive: true },
|
||||
wagons: { allocations: true },
|
||||
},
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||
return this.repo(manager).findOne({
|
||||
where: { id },
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
CorridorBudget,
|
||||
CorridorLeg,
|
||||
OverageTolerance,
|
||||
addCoupledWagons,
|
||||
stopYardsFor,
|
||||
subtractCutWagons,
|
||||
} from './corridor-capacity.util';
|
||||
@@ -5064,6 +5065,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
stock.byYardId,
|
||||
budget.stops,
|
||||
);
|
||||
// Wagons staff cut mid-route are not stock past their cut stop.
|
||||
ledger.debitCutWagons(stock.cutWagons ?? []);
|
||||
// Debit what is already committed, per boarding yard and wagon type — the
|
||||
// same bookings the corridor budget subtracted. A booking with no resolvable
|
||||
// wagon type still occupies steel, so it drains any type at its yard.
|
||||
@@ -5385,6 +5388,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
|
||||
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
|
||||
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
||||
// Planned couples add a slot from their couple stop onward.
|
||||
addCoupledWagons(budget, schedule.plannedWagonCouples);
|
||||
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonDims),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
* smartBulkNeed math in isolation: the private helpers it touches
|
||||
* (allowedDimsWithTypes) read only their arguments, so a bare prototype
|
||||
* instance is enough — no Nest wiring.
|
||||
*/
|
||||
*///
|
||||
describe('BookingBatchService.smartBulkNeed', () => {
|
||||
const service = Object.create(BookingBatchService.prototype) as BookingBatchService;
|
||||
const call = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
|
||||
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
|
||||
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
|
||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
TrainSchedulingCancel,
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingEditTrainNumber,
|
||||
TrainSchedulingLoad,
|
||||
TrainSchedulingReschedule,
|
||||
TrainSchedulingUnload,
|
||||
TrainSchedulingRulesManage,
|
||||
TrainSchedulingUpdate,
|
||||
TrainSchedulingView,
|
||||
@@ -222,7 +225,7 @@ export class TrainSchedulingController {
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleWagonYardsDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto.moves);
|
||||
return this.trainSchedulingService.updateScheduleWagonYards(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/adjust-consist")
|
||||
@@ -243,14 +246,27 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/phase")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Lightweight polling heartbeat: the schedule's status, booking-window phase and deadlines plus its updated_at — one row, no joins, so clients can poll cheaply and refetch the full detail only when something actually changed",
|
||||
})
|
||||
getSchedulePhase(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getSchedulePhase(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/history")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
|
||||
})
|
||||
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleHistory(id);
|
||||
getScheduleHistory(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query() query: PaginationQueryDto,
|
||||
) {
|
||||
return this.trainSchedulingService.getScheduleHistory(id, query);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
@@ -598,7 +614,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingUpdate()
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||
@@ -611,7 +627,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||
@TrainSchedulingUpdate()
|
||||
@TrainSchedulingUnload()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||
@@ -624,7 +640,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingUpdate()
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
|
||||
})
|
||||
@@ -636,7 +652,7 @@ export class TrainSchedulingController {
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/unload")
|
||||
@TrainSchedulingUpdate()
|
||||
@TrainSchedulingUnload()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
addCoupledWagons,
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
orientStopsToSchedule,
|
||||
@@ -220,3 +221,39 @@ describe('corridor-capacity.util — stop orientation and fallback', () => {
|
||||
expect(stopYardsFor(null, 'a', 'c')).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('corridor-capacity.util — addCoupledWagons', () => {
|
||||
const stops = ['a', 'b', 'c', 'd'];
|
||||
const wagonsOnly: Capacity = {
|
||||
wagons: 10,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const remaining = (budget: CorridorBudget, from: string, to: string): number =>
|
||||
budget.remainingFor(budget.legOf(from, to)!).wagons;
|
||||
|
||||
it('credits every edge at/after the couple stop', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
addCoupledWagons(budget, { 'w-1': 'a', 'w-2': 'c' });
|
||||
expect(remaining(budget, 'a', 'b')).toBe(11); // origin couple rides everything
|
||||
expect(remaining(budget, 'b', 'c')).toBe(11);
|
||||
expect(remaining(budget, 'c', 'd')).toBe(12); // + the c-coupled wagon
|
||||
});
|
||||
|
||||
it('nets against cuts on the same budget', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
subtractCutWagons(budget, { 'w-cut': 'c' });
|
||||
addCoupledWagons(budget, { 'w-new': 'c' });
|
||||
expect(remaining(budget, 'a', 'c')).toBe(10);
|
||||
expect(remaining(budget, 'c', 'd')).toBe(10); // cut −1, couple +1
|
||||
expect(remaining(budget, 'a', 'd')).toBe(10);
|
||||
});
|
||||
|
||||
it('ignores off-corridor and destination couple yards, and a missing plan', () => {
|
||||
const budget = new CorridorBudget(stops, wagonsOnly);
|
||||
addCoupledWagons(budget, { 'w-1': 'elsewhere', 'w-2': 'd' });
|
||||
addCoupledWagons(budget, null);
|
||||
addCoupledWagons(budget, undefined);
|
||||
expect(remaining(budget, 'a', 'd')).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,25 @@ export function subtractCutWagons(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit the corridor for LOOSE wagons the schedule plans to COUPLE onto the
|
||||
* train mid-route: each coupled wagon adds a slot on every edge at/after its
|
||||
* couple stop ([couple, destination)). A couple yard not on the corridor —
|
||||
* or equal to the destination — is ignored; updateScheduleWagonYards owns
|
||||
* rejecting it.
|
||||
*/
|
||||
export function addCoupledWagons(
|
||||
budget: CorridorBudget,
|
||||
couplePlan: Record<string, string> | null | undefined,
|
||||
): void {
|
||||
if (!couplePlan) return;
|
||||
const destination = budget.stops[budget.stops.length - 1];
|
||||
for (const coupleYardId of Object.values(couplePlan)) {
|
||||
const leg = budget.legOf(coupleYardId, destination);
|
||||
if (leg) budget.add({ wagons: 1, weightTons: 0, lengthMeters: 0 }, leg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Overage a locomotive may absorb beyond its base caps. */
|
||||
export interface OverageTolerance {
|
||||
weightTons: number;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMaxSize, IsArray, IsOptional, IsUUID, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ScheduleWagonYardMoveDto {
|
||||
@ApiProperty({ format: 'uuid', description: "Wagon coupled to the schedule's built train." })
|
||||
@@ -25,6 +33,27 @@ export class ScheduleWagonYardMoveDto {
|
||||
@ValidateIf((o: ScheduleWagonYardMoveDto) => o.cutYardId !== null)
|
||||
@IsUUID()
|
||||
cutYardId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'true: REAL cut — the built train permanently loses the wagon at its cut yard. false: soft cut (default) — the wagon sits out this trip but stays in the build. Requires a cut yard.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
realCut?: boolean;
|
||||
}
|
||||
|
||||
export class ScheduleWagonCoupleDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'Loose wagon (no built train) to couple.' })
|
||||
@IsUUID()
|
||||
wagonId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
format: 'uuid',
|
||||
description: 'Pickup stop the wagon joins the train at. It must physically stand there.',
|
||||
})
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
}
|
||||
|
||||
export class UpdateScheduleWagonYardsDto {
|
||||
@@ -33,9 +62,32 @@ export class UpdateScheduleWagonYardsDto {
|
||||
description:
|
||||
'Wagon → planned boarding yard for THIS schedule only. Physical wagon yards are untouched; dispatch requires both to agree.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScheduleWagonYardMoveDto)
|
||||
moves!: ScheduleWagonYardMoveDto[];
|
||||
moves?: ScheduleWagonYardMoveDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [ScheduleWagonCoupleDto],
|
||||
description:
|
||||
'Loose wagons to plan-couple onto the train at a pickup stop. They join the built train permanently when the trip reaches that stop.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ScheduleWagonCoupleDto)
|
||||
couple?: ScheduleWagonCoupleDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
description: 'Wagon ids to remove from the couple plan (before execution).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
@IsUUID('all', { each: true })
|
||||
uncouple?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { computeEdgeLoads } from './edge-load.util';
|
||||
|
||||
describe('edge-load.util — computeEdgeLoads', () => {
|
||||
// gmp -> lebu -> mojo -> adama -> dct: 4 edges.
|
||||
const EDGES = 4;
|
||||
const wagon = (fromEdge: number, toEdge: number) => ({
|
||||
fromEdge,
|
||||
toEdge,
|
||||
tareTons: 25,
|
||||
lengthMeters: 17,
|
||||
});
|
||||
|
||||
it('an uncut whole-route consist loads every edge flat', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 4)], []);
|
||||
for (const e of loads) {
|
||||
expect(e.weightTons).toBe(50);
|
||||
expect(e.lengthMeters).toBe(34);
|
||||
}
|
||||
});
|
||||
|
||||
it('a cut frees tare and length on the edges past the cut', () => {
|
||||
// One wagon cut at mojo (edge index 2): rides edges 0-1 only.
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(0, 2)], []);
|
||||
expect(loads[1]).toEqual({ weightTons: 50, lengthMeters: 34 });
|
||||
expect(loads[2]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
expect(loads[3]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
});
|
||||
|
||||
it('a couple adds tare and length only from its couple stop', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 4), wagon(2, 4)], []);
|
||||
expect(loads[1]).toEqual({ weightTons: 25, lengthMeters: 17 });
|
||||
expect(loads[2]).toEqual({ weightTons: 50, lengthMeters: 34 });
|
||||
});
|
||||
|
||||
it('cut-then-couple at the same stop nets to a flat load', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(0, 2), wagon(2, 4)], []);
|
||||
for (const e of loads) {
|
||||
expect(e.weightTons).toBe(25);
|
||||
expect(e.lengthMeters).toBe(17);
|
||||
}
|
||||
});
|
||||
|
||||
it('cargo weighs only the edges of its own leg', () => {
|
||||
const loads = computeEdgeLoads(
|
||||
EDGES,
|
||||
[wagon(0, 4)],
|
||||
[{ fromEdge: 1, toEdge: 3, weightTons: 60 }],
|
||||
);
|
||||
expect(loads[0].weightTons).toBe(25);
|
||||
expect(loads[1].weightTons).toBe(85);
|
||||
expect(loads[2].weightTons).toBe(85);
|
||||
expect(loads[3].weightTons).toBe(25);
|
||||
});
|
||||
|
||||
it('clamps out-of-range spans instead of throwing', () => {
|
||||
const loads = computeEdgeLoads(EDGES, [wagon(-2, 99)], []);
|
||||
for (const e of loads) expect(e.weightTons).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Per-corridor-edge physical load of a train: tare + length of the wagons
|
||||
* spanning each edge, plus the cargo weight riding it. Used to validate that
|
||||
* a planned mid-route COUPLE keeps every leg within the locomotives' pull
|
||||
* weight and train length limits — a wagon cut at Mojo frees its tare/length
|
||||
* on the edges past Mojo, a wagon coupled there adds its own only from there.
|
||||
*/
|
||||
|
||||
export interface EdgeLoad {
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export interface EdgeWagonSpan {
|
||||
/** Half-open edge span [fromEdge, toEdge) the wagon physically rides. */
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
tareTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
export interface EdgeCargoLeg {
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
weightTons: number;
|
||||
}
|
||||
|
||||
export function computeEdgeLoads(
|
||||
edgeCount: number,
|
||||
wagonSpans: readonly EdgeWagonSpan[],
|
||||
cargoLegs: readonly EdgeCargoLeg[],
|
||||
): EdgeLoad[] {
|
||||
const loads: EdgeLoad[] = Array.from({ length: Math.max(1, edgeCount) }, () => ({
|
||||
weightTons: 0,
|
||||
lengthMeters: 0,
|
||||
}));
|
||||
const clamp = (edge: number) => Math.min(Math.max(edge, 0), loads.length);
|
||||
for (const span of wagonSpans) {
|
||||
for (let e = clamp(span.fromEdge); e < clamp(span.toEdge); e += 1) {
|
||||
loads[e].weightTons += span.tareTons;
|
||||
loads[e].lengthMeters += span.lengthMeters;
|
||||
}
|
||||
}
|
||||
for (const cargo of cargoLegs) {
|
||||
for (let e = clamp(cargo.fromEdge); e < clamp(cargo.toEdge); e += 1) {
|
||||
loads[e].weightTons += cargo.weightTons;
|
||||
}
|
||||
}
|
||||
return loads;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
validateContainerPlacements,
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
validateWagonCargoExclusivity,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
@@ -412,4 +413,91 @@ describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', ()
|
||||
loadedWagonCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('with a legs map, shared-slot cargo weighs only its own edges (the S-2026-00045 shape)', () => {
|
||||
// One wagon reused across legs: booking X rides a→b (40T), booking Y
|
||||
// boards at b with 30T. The slot spans the whole route, but edge a→b
|
||||
// must weigh 24 + 40 = 64T — not 24 + 70. Tare rides both edges.
|
||||
const shared = {
|
||||
tareWeightTons: 24,
|
||||
assignedWeightTons: 70,
|
||||
lengthMeters: 14,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [
|
||||
{ bookingId: 'X', allocatedWeightTons: 40 },
|
||||
{ bookingId: 'Y', allocatedWeightTons: 30 },
|
||||
],
|
||||
} as never;
|
||||
const legs = new Map([
|
||||
['X', { from: 0, to: 1 }],
|
||||
['Y', { from: 1, to: 2 }],
|
||||
]);
|
||||
// Without legs: whole-span scalar on both edges (94T binding edge).
|
||||
expect(maxEdgeConsistUsage([shared], stops).grossWeightTons).toBe(94);
|
||||
// With legs: heaviest edge is a→b at 64T (b→c is 54T).
|
||||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(64);
|
||||
});
|
||||
|
||||
it('falls back to the whole-span scalar when an allocation has no readable weight', () => {
|
||||
const shared = {
|
||||
tareWeightTons: 24,
|
||||
assignedWeightTons: 70,
|
||||
lengthMeters: 14,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [{ bookingId: 'X' }],
|
||||
} as never;
|
||||
const legs = new Map([['X', { from: 0, to: 1 }]]);
|
||||
expect(maxEdgeConsistUsage([shared], stops, legs).grossWeightTons).toBe(94);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMixedTrainLimitsPerEdge — leg-aware cargo weighing', () => {
|
||||
it('does not flag a leg whose overweight is only later-boarding cargo (S-2026-00045)', () => {
|
||||
// 2 shared wagons, 100T cap. Booking X rides a→b with 30T/wagon, booking Y
|
||||
// boards at b with 25T/wagon. Whole-span scalars read every edge as
|
||||
// 2×(20 + 55) = 150T > 100T; the cargo actually aboard is 100T (a→b) and
|
||||
// 90T (b→c) — both fit.
|
||||
const slot = (seq: number) => ({
|
||||
sequenceNo: seq,
|
||||
wagonTypeId: 'wt-nw5',
|
||||
wagonTypeCode: 'NW5',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
tareWeightTons: 20,
|
||||
assignedWeightTons: 55,
|
||||
boardYardId: null,
|
||||
alightYardId: null,
|
||||
allocations: [
|
||||
{
|
||||
bookingId: 'X',
|
||||
bookingReference: 'X',
|
||||
allocatedWeightTons: 30,
|
||||
loadType: AllocationLoadType.Container,
|
||||
},
|
||||
{
|
||||
bookingId: 'Y',
|
||||
bookingReference: 'Y',
|
||||
allocatedWeightTons: 25,
|
||||
loadType: AllocationLoadType.Container,
|
||||
},
|
||||
],
|
||||
});
|
||||
const legs = new Map([
|
||||
['X', { from: 0, to: 1 }],
|
||||
['Y', { from: 1, to: 2 }],
|
||||
]);
|
||||
const run = (withLegs?: typeof legs) =>
|
||||
validateMixedTrainLimitsPerEdge(
|
||||
[slot(1), slot(2)] as never,
|
||||
[{ lengthMeters: 14 }],
|
||||
{ maxWeightTons: 100 },
|
||||
['a', 'b', 'c'],
|
||||
undefined,
|
||||
withLegs,
|
||||
);
|
||||
expect(run()).toHaveLength(2); // both edges falsely overweight without legs
|
||||
expect(run(legs)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -654,9 +654,16 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
const label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
(_, i) => spans[i].from <= edge && edge < spans[i].to,
|
||||
);
|
||||
// A shared slot rides the UNION of its cargo legs, but only carries each
|
||||
// booking's cargo on that booking's own edges — weigh the edge with the
|
||||
// cargo actually aboard there, not the slot's whole-route scalar, or a
|
||||
// container boarding at Dire Dawa reads as hauled from Djibouti.
|
||||
const active = wagonPlan
|
||||
.filter((_, i) => spans[i].from <= edge && edge < spans[i].to)
|
||||
.map((slot) => ({
|
||||
...slot,
|
||||
assignedWeightTons: slotCargoOnEdge(slot, edge, edges, legs),
|
||||
}));
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(
|
||||
active,
|
||||
@@ -684,6 +691,40 @@ export type EdgeUsageSlot = Pick<
|
||||
allocations?: unknown[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Cargo tons a slot actually carries on one edge. With a legs map and readable
|
||||
* allocation records, each booking's cargo counts only on the edges that
|
||||
* booking rides (an unmapped booking stays on the slot's whole span). Without
|
||||
* either — or when any allocation lacks a numeric weight, e.g. persisted rows
|
||||
* fed through {@link EdgeUsageSlot} — falls back to the slot's whole-span
|
||||
* `assignedWeightTons`, the pre-existing reading.
|
||||
*/
|
||||
function slotCargoOnEdge(
|
||||
slot: EdgeUsageSlot,
|
||||
edge: number,
|
||||
edgeCount: number,
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): number {
|
||||
const wholeSpanCargo = Number(slot.assignedWeightTons ?? 0);
|
||||
const allocations = (slot.allocations ?? []) as Array<{
|
||||
bookingId?: string;
|
||||
allocatedWeightTons?: number | string;
|
||||
}>;
|
||||
if (!legs?.size || !allocations.length) return wholeSpanCargo;
|
||||
let cargo = 0;
|
||||
for (const allocation of allocations) {
|
||||
const weight = Number(allocation?.allocatedWeightTons);
|
||||
if (!Number.isFinite(weight)) return wholeSpanCargo;
|
||||
const leg = allocation.bookingId ? legs.get(allocation.bookingId) : undefined;
|
||||
const rides =
|
||||
!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to
|
||||
? true
|
||||
: leg.from <= edge && edge < leg.to;
|
||||
if (rides) cargo += weight;
|
||||
}
|
||||
return cargo;
|
||||
}
|
||||
|
||||
/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */
|
||||
function slotSpans(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
@@ -708,8 +749,10 @@ function slotSpans(
|
||||
export function maxEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
/** Booking id → stop-index span; cargo then weighs only its own edges. */
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||||
return perEdgeConsistUsage(wagonPlan, stops).reduce(
|
||||
return perEdgeConsistUsage(wagonPlan, stops, legs).reduce(
|
||||
(max, e) => ({
|
||||
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
@@ -737,12 +780,19 @@ export type EdgeConsistUsage = {
|
||||
export function perEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
/**
|
||||
* Booking id → stop-index span. When given, a shared slot's cargo weighs
|
||||
* only the edges its booking rides (tare still rides the slot's whole
|
||||
* span) — without it a slot's full cargo counts on every edge it spans.
|
||||
*/
|
||||
legs?: Map<string, { from: number; to: number }>,
|
||||
): EdgeConsistUsage[] {
|
||||
const edgeCount = Math.max(1, stops.length - 1);
|
||||
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
||||
edge,
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
sum + Number(w.tareWeightTons ?? 0) + slotCargoOnEdge(w, edge, edgeCount, legs),
|
||||
0,
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
|
||||
@@ -54,6 +54,13 @@ export type WagonStock = {
|
||||
* math.
|
||||
*/
|
||||
byYardId?: Map<string, Map<string, number>>;
|
||||
/**
|
||||
* Wagons the schedule CUTS mid-route (staff plan): each is stock only up to
|
||||
* its cut stop. Consumers debit it from its pool on every edge at/after the
|
||||
* cut, so a leg riding past the cut never counts it. Absent = no cuts.
|
||||
* `poolYardId` is the wagon's boarding pool ('' on a single-yard consist).
|
||||
*/
|
||||
cutWagons?: Array<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>;
|
||||
};
|
||||
|
||||
export type FlexPlanResult = {
|
||||
@@ -324,6 +331,16 @@ export function planWagonsWithStock(params: {
|
||||
}
|
||||
return row;
|
||||
};
|
||||
// Cut wagons are pre-consumed on every edge at/after their cut stop: they
|
||||
// are steel for gmp→lebu but not for gmp→dct. Unknown cut yard (no stops
|
||||
// given / off-corridor) is skipped — conservative, same as before cuts.
|
||||
for (const cut of stock.cutWagons ?? []) {
|
||||
const fromEdge = stops.indexOf(cut.cutYardId);
|
||||
if (fromEdge < 0) continue;
|
||||
const pool = stock.byYardId ? cut.poolYardId : '';
|
||||
const row = usedRow(rowKeyFor(cut.wagonTypeId, pool));
|
||||
for (let e = fromEdge; e < edgeCount; e += 1) row[e] += 1;
|
||||
}
|
||||
const availableFor = (wagonTypeId: string, leg: BookingLeg): number => {
|
||||
const pool = poolOf(leg);
|
||||
const total = totalFor(wagonTypeId, pool);
|
||||
|
||||
@@ -159,3 +159,52 @@ describe('WagonStockLedger — multi-yard consist', () => {
|
||||
expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WagonStockLedger — cut wagons (S-2026-00050 shape)', () => {
|
||||
// gmp -> lebu -> mojo -> adama -> dct. 3 NW5 + 2 PW2: two NW5 board at gmp
|
||||
// (one cut at lebu), one NW5 boards at mojo; both PW2 board at gmp.
|
||||
const stops = ['gmp', 'lebu', 'mojo', 'adama', 'dct'];
|
||||
const makeLedger = () => {
|
||||
const ledger = new WagonStockLedger(
|
||||
new Map([
|
||||
['nw5', 3],
|
||||
['pw2', 2],
|
||||
]),
|
||||
stops.length - 1,
|
||||
new Map([
|
||||
['gmp', new Map([['nw5', 2], ['pw2', 2]])],
|
||||
['mojo', new Map([['nw5', 1]])],
|
||||
]),
|
||||
stops,
|
||||
);
|
||||
ledger.debitCutWagons([{ wagonTypeId: 'nw5', poolYardId: 'gmp', cutYardId: 'lebu' }]);
|
||||
return ledger;
|
||||
};
|
||||
const leg = (from: number, to: number) => ({ fromEdge: from, toEdge: to });
|
||||
|
||||
it('a leg past the cut sees only the wagons that reach it', () => {
|
||||
const ledger = makeLedger();
|
||||
// gmp -> dct: 2 NW5 stand at gmp but one is cut at lebu — only 1 rides through.
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(1);
|
||||
// gmp -> lebu: both gmp NW5 serve the short leg.
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(2);
|
||||
// PW2 uncut — both ride anywhere from gmp.
|
||||
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
|
||||
// mojo -> dct: the mojo pool's own NW5, untouched by the gmp cut.
|
||||
expect(ledger.availableFor(['nw5'], leg(2, 4))).toBe(1);
|
||||
});
|
||||
|
||||
it('cut debit and booking consumption stack', () => {
|
||||
const ledger = makeLedger();
|
||||
expect(ledger.consume(['nw5'], 1, leg(0, 4))).toBe(1);
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 4))).toBe(0);
|
||||
// Short leg still has the cut wagon (1 = 2 total − 1 consumed through-rider).
|
||||
expect(ledger.availableFor(['nw5'], leg(0, 1))).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores a cut yard that is not on the stops', () => {
|
||||
const ledger = makeLedger();
|
||||
ledger.debitCutWagons([{ wagonTypeId: 'pw2', poolYardId: 'gmp', cutYardId: 'elsewhere' }]);
|
||||
expect(ledger.availableFor(['pw2'], leg(0, 4))).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,6 +75,32 @@ export class WagonStockLedger {
|
||||
return Math.max(0, total - busiest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-debit wagons the schedule CUTS mid-route: each cut wagon occupies its
|
||||
* pool's stock on every edge at/after its cut stop, so a leg riding past the
|
||||
* cut never counts it ("2 NW5 free from gmp" reads 1 when one cuts at Lebu).
|
||||
* A cut yard not on this ledger's stops is skipped — conservative, matches
|
||||
* the pre-cut behavior.
|
||||
*/
|
||||
debitCutWagons(
|
||||
cuts: ReadonlyArray<{ wagonTypeId: string; poolYardId: string; cutYardId: string }>,
|
||||
): void {
|
||||
for (const cut of cuts) {
|
||||
const fromEdge = this.stops.indexOf(cut.cutYardId);
|
||||
if (fromEdge < 0) continue;
|
||||
const pool = this.byYardId ? cut.poolYardId : '';
|
||||
const key = pool ? `${pool}\u0000${cut.wagonTypeId}` : cut.wagonTypeId;
|
||||
let row = this.usedPerEdge.get(key);
|
||||
if (!row) {
|
||||
row = new Array<number>(this.edgeCount).fill(0);
|
||||
this.usedPerEdge.set(key, row);
|
||||
}
|
||||
for (let edge = fromEdge; edge < this.edgeCount; edge += 1) {
|
||||
row[edge] = (row[edge] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free wagons across every type a booking may ride. A cargo/container type
|
||||
* mapped to several wagon types can use any of them, so they add up.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
|
||||
import { FleetManage, FleetView } from '../../common/booking-guards';
|
||||
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
|
||||
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
@@ -78,6 +79,24 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.getComposition(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Wagon adjustment history of this built train: who attached/detached/switched which wagon, when and where — builder edits and trip events alike",
|
||||
})
|
||||
history(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
|
||||
return this.trainBuilderService.getTrainHistory(id, query);
|
||||
}
|
||||
|
||||
@Get(':id/detached-wagons')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Wagons previously detached from this train that are still loose — with when/where/by whom they were last detached, ready to re-attach',
|
||||
})
|
||||
detachedWagons(@Param('id', ParseUUIDPipe) id: string, @Query() query: PaginationQueryDto) {
|
||||
return this.trainBuilderService.getDetachedWagons(id, query);
|
||||
}
|
||||
|
||||
@Put(':id/locomotives')
|
||||
@FleetManage(FREIGHT_PERMS.trains.changeLocomotives)
|
||||
@ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' })
|
||||
|
||||
@@ -231,6 +231,117 @@ export class TrainBuilderService {
|
||||
return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon adjustment history of one built train, newest first: builder
|
||||
* attaches/detaches (no schedule) and trip events (real cuts, couples,
|
||||
* consist adjustments — carrying their schedule reference) alike.
|
||||
*/
|
||||
async getTrainHistory(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const [countRows, rows]: [
|
||||
Array<{ total: string }>,
|
||||
Array<{
|
||||
id: string;
|
||||
action: string;
|
||||
subject: string;
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*) AS total
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
WHERE l.train_id = $1
|
||||
AND l.deleted_at IS NULL`,
|
||||
[trainId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT l.id,
|
||||
l.action,
|
||||
l.wagon_number AS "subject",
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
LEFT JOIN freight.train_schedules ts ON ts.id = l.train_schedule_id
|
||||
WHERE l.train_id = $1
|
||||
AND l.deleted_at IS NULL
|
||||
ORDER BY l.occurred_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[trainId, take, skip],
|
||||
),
|
||||
]);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons last detached from THIS train that are still loose (no train,
|
||||
* AVAILABLE) — the re-attach shortlist, with when/where/by whom each was
|
||||
* last detached. Derived from the adjustment log, no denormalized column.
|
||||
*/
|
||||
async getDetachedWagons(trainId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||
const lastRemovalSql = `
|
||||
SELECT DISTINCT ON (l.wagon_id)
|
||||
l.wagon_id AS "wagonId",
|
||||
l.occurred_at AS "detachedAt",
|
||||
COALESCE(y.label, y.code) AS "detachedYardLabel",
|
||||
COALESCE(u.username, u.email) AS "detachedBy"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
||||
WHERE l.train_id = $1
|
||||
AND l.action = 'REMOVE'
|
||||
AND l.deleted_at IS NULL
|
||||
ORDER BY l.wagon_id, l.occurred_at DESC`;
|
||||
const stillLoose = `w.deleted_at IS NULL AND w.train_id IS NULL AND w.status = 'AVAILABLE'`;
|
||||
const [countRows, rows]: [
|
||||
Array<{ total: string }>,
|
||||
Array<{
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
wagonTypeCode: string | null;
|
||||
currentYardLabel: string | null;
|
||||
detachedAt: Date;
|
||||
detachedYardLabel: string | null;
|
||||
detachedBy: string | null;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT count(*) AS total
|
||||
FROM (${lastRemovalSql}) last_removal
|
||||
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
||||
WHERE ${stillLoose}`,
|
||||
[trainId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT last_removal."wagonId",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
wt.code AS "wagonTypeCode",
|
||||
COALESCE(cy.label, cy.code) AS "currentYardLabel",
|
||||
last_removal."detachedAt",
|
||||
last_removal."detachedYardLabel",
|
||||
last_removal."detachedBy"
|
||||
FROM (${lastRemovalSql}) last_removal
|
||||
JOIN freight.wagons w ON w.id = last_removal."wagonId"
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
LEFT JOIN freight.yards cy ON cy.id = w.current_yard_id
|
||||
WHERE ${stillLoose}
|
||||
ORDER BY last_removal."detachedAt" DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[trainId, take, skip],
|
||||
),
|
||||
]);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */
|
||||
async getComposition(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({
|
||||
@@ -721,6 +832,7 @@ export class TrainBuilderService {
|
||||
toYardId: yardId,
|
||||
kind: WagonMovementKind.Maintenance,
|
||||
note: notes.movementNote,
|
||||
movedByUserId: userId,
|
||||
occurredAt: new Date(),
|
||||
}),
|
||||
);
|
||||
@@ -1097,15 +1209,14 @@ export class TrainBuilderService {
|
||||
.getRepository(TrainSet)
|
||||
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
|
||||
}
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
// Log the consist change even when the train has no live schedule — the
|
||||
// builder's own detach/attach is the train's history too (who removed
|
||||
// which wagon, when, where), and the detached-wagons tab reads it back.
|
||||
const now = new Date();
|
||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
||||
changes.map((c) =>
|
||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||
trainScheduleId: schedule.id,
|
||||
trainScheduleId: schedule?.id ?? null,
|
||||
trainId,
|
||||
action: c.action,
|
||||
wagonId: c.wagonId,
|
||||
@@ -1117,6 +1228,10 @@ export class TrainBuilderService {
|
||||
),
|
||||
);
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
|
||||
|
||||
// The FULL/reopen decision must run AFTER the transaction commits — see
|
||||
// reconcileWindowAfterConsistChange.
|
||||
return { scheduleId: schedule.id, wasFull: schedule.bookingWindowStatus === 'FULL' };
|
||||
|
||||
@@ -1469,6 +1469,20 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:train_scheduling:rules_manage",
|
||||
"Manage global scheduling rules",
|
||||
),
|
||||
// Carved out of the coarse `update` — confirming a booking's cargo loaded/
|
||||
// unloaded at a yard, across import, export, and intercity movements alike
|
||||
// (the same schedules/:id/bookings/:bookingId/{load,unload} + intercity
|
||||
// routes serve all three directions).
|
||||
perm(
|
||||
"a2a00001-0001-4000-8000-000000000006",
|
||||
"edr_freight_app:train_scheduling:load",
|
||||
"Confirm cargo loaded (import, export, intercity)",
|
||||
),
|
||||
perm(
|
||||
"a2a00001-0001-4000-8000-000000000007",
|
||||
"edr_freight_app:train_scheduling:unload",
|
||||
"Confirm cargo unloaded (import, export, intercity)",
|
||||
),
|
||||
];
|
||||
|
||||
// L. Administration & settings (split from the coarse admin umbrella)
|
||||
@@ -1996,6 +2010,15 @@ export const FREIGHT_PERMS = {
|
||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||
reschedule: "edr_freight_app:train_scheduling:reschedule",
|
||||
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
|
||||
/**
|
||||
* Confirm a booking's cargo loaded/unloaded at a yard — carved out of the
|
||||
* coarse `update` so load/unload can be granted independently of general
|
||||
* schedule editing. Covers import, export, and intercity alike: the
|
||||
* generic per-booking route and the intercity-specific one both gate on
|
||||
* these same two keys.
|
||||
*/
|
||||
load: "edr_freight_app:train_scheduling:load",
|
||||
unload: "edr_freight_app:train_scheduling:unload",
|
||||
dispatch: "edr_freight_app:train_scheduling:dispatch",
|
||||
markPaid: "edr_freight_app:train_scheduling:mark_paid",
|
||||
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
|
||||
@@ -2602,6 +2625,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.create,
|
||||
FREIGHT_PERMS.trainScheduling.update,
|
||||
FREIGHT_PERMS.trainScheduling.load,
|
||||
FREIGHT_PERMS.trainScheduling.unload,
|
||||
FREIGHT_PERMS.trainScheduling.cancel,
|
||||
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
@@ -2822,6 +2847,8 @@ export const POSITION_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.create,
|
||||
FREIGHT_PERMS.trainScheduling.update,
|
||||
FREIGHT_PERMS.trainScheduling.load,
|
||||
FREIGHT_PERMS.trainScheduling.unload,
|
||||
FREIGHT_PERMS.trainScheduling.cancel,
|
||||
FREIGHT_PERMS.trainScheduling.reschedule,
|
||||
FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
|
||||
interface Props {
|
||||
trainId: string;
|
||||
/** Staff may attach and the train is editable (not out on a run). */
|
||||
canAttach: boolean;
|
||||
attachPending: boolean;
|
||||
onAttach: (wagonIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Detached wagons" tab: wagons last detached from THIS train that are still
|
||||
* loose — with when, where and by whom they were detached — so staff can pick
|
||||
* them straight back onto the consist without hunting through the global pool.
|
||||
*/
|
||||
export default function DetachedWagonsPanel({
|
||||
trainId,
|
||||
canAttach,
|
||||
attachPending,
|
||||
onAttach,
|
||||
}: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const query = useQuery(
|
||||
api.trainBuilder.detachedWagons.queryOptions({
|
||||
input: { id: trainId, page, pageSize: 20 },
|
||||
enabled: Boolean(trainId),
|
||||
// Keep the previous page on screen while the next one loads.
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
const rows = query.data?.items ?? [];
|
||||
const totalPages = Math.max(1, query.data?.meta.totalPages ?? 1);
|
||||
// Selection is page-scoped in the header checkbox but survives paging, so
|
||||
// staff can gather wagons across pages into one attach.
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
|
||||
|
||||
const toggle = (wagonId: string, checked: boolean) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(wagonId);
|
||||
else next.delete(wagonId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="orange">
|
||||
<PackageOpen size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} fz="lg">
|
||||
Detached wagons
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagons that left this train and are still loose — select and
|
||||
attach them back in one click.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{canAttach ? (
|
||||
<Button
|
||||
leftSection={<Link2 size={16} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={attachPending}
|
||||
onClick={() => {
|
||||
onAttach([...selected]);
|
||||
setSelected(new Set());
|
||||
}}
|
||||
>
|
||||
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{query.isLoading ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Loading detached wagons…
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No loose wagons were detached from this train — detach history starts
|
||||
being recorded from now on.
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{canAttach ? (
|
||||
<Table.Th w={36}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={selected.size > 0 && !allSelected}
|
||||
onChange={(e) =>
|
||||
setSelected(
|
||||
e.currentTarget.checked
|
||||
? new Set(rows.map((r) => r.wagonId))
|
||||
: new Set(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Table.Th>
|
||||
) : null}
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Now standing at</Table.Th>
|
||||
<Table.Th>Last detached</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.wagonId}>
|
||||
{canAttach ? (
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.has(r.wagonId)}
|
||||
onChange={(e) => toggle(r.wagonId, e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
) : null}
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm" ff="monospace">
|
||||
{r.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{r.wagonTypeCode ?? "—"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{r.currentYardLabel ?? "No yard"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="md" wrap="wrap">
|
||||
<Tooltip label={new Date(r.detachedAt).toLocaleString()}>
|
||||
<Text size="sm">{new Date(r.detachedAt).toLocaleDateString()}</Text>
|
||||
</Tooltip>
|
||||
{r.detachedYardLabel ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MapPin size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
at {r.detachedYardLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{r.detachedBy ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
by {r.detachedBy}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{query.data?.meta.total ?? 0} wagon(s) · selection carries across pages
|
||||
</Text>
|
||||
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Badge, Group, Pagination, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeftRight, History, MapPin, Minus, Plus, TrainFront, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { TrainHistoryEntry } from "@/services/trainBuilder.service";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const ACTION_META: Record<
|
||||
TrainHistoryEntry["action"],
|
||||
{ label: string; color: string; icon: typeof Plus }
|
||||
> = {
|
||||
ADD: { label: "Wagon attached", color: "edr-green", icon: Plus },
|
||||
REMOVE: { label: "Wagon detached", color: "red", icon: Minus },
|
||||
SWITCH: { label: "Wagon switched", color: "blue", icon: ArrowLeftRight },
|
||||
};
|
||||
|
||||
/**
|
||||
* "History" tab of the train-builder detail page: every wagon ever attached,
|
||||
* detached or switched on this built train — builder edits and trip events
|
||||
* (real cuts, mid-route couples, consist adjustments) alike, newest first.
|
||||
*/
|
||||
export default function TrainHistoryPanel({ trainId }: { trainId: string }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const historyQuery = useQuery(
|
||||
api.trainBuilder.history.queryOptions({
|
||||
input: { id: trainId, page, pageSize: PAGE_SIZE },
|
||||
enabled: Boolean(trainId),
|
||||
// Keep the previous page on screen while the next one loads.
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
const entries = historyQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
|
||||
const total = historyQuery.data?.meta.total ?? 0;
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="lg">
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={44} radius="md" variant="light" color="edr-green">
|
||||
<History size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} fz="lg">
|
||||
Wagon history
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Who attached, detached or switched which wagon on this train — from
|
||||
the builder and from its trips — newest first.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{historyQuery.isLoading ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Loading history…
|
||||
</Text>
|
||||
) : entries.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No wagon changes recorded yet for this train.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline bulletSize={26} lineWidth={2} color="edr-green">
|
||||
{entries.map((entry) => {
|
||||
const meta = ACTION_META[entry.action] ?? ACTION_META.ADD;
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={entry.id}
|
||||
bullet={<Icon size={13} />}
|
||||
color={meta.color}
|
||||
title={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{entry.subject ? (
|
||||
<Text size="sm" fw={600} ff="monospace">
|
||||
{entry.subject}
|
||||
</Text>
|
||||
) : null}
|
||||
{entry.scheduleReference ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
leftSection={<TrainFront size={10} />}
|
||||
>
|
||||
{entry.scheduleReference}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
Builder
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Group gap="md" mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.occurredAt).toLocaleString()}
|
||||
</Text>
|
||||
{entry.yardLabel ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<MapPin size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
at {entry.yardLabel}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
{entry.actor ? (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<User size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{entry.actor}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
</Group>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} change(s)
|
||||
</Text>
|
||||
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
@@ -130,6 +132,9 @@ export function IntercityRideAlongPanel({
|
||||
direction: string | null | undefined;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
|
||||
const queryClient = useQueryClient();
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
@@ -378,12 +383,19 @@ export function IntercityRideAlongPanel({
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{row.status === "PAID" && (
|
||||
<Tooltip label="Train must be at the booking's origin yard">
|
||||
<Tooltip
|
||||
label={
|
||||
canLoad
|
||||
? "Train must be at the booking's origin yard"
|
||||
: "You don't have permission to load cargo"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
disabled={!canLoad}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId, bookingId: row.id })
|
||||
}
|
||||
@@ -393,13 +405,20 @@ export function IntercityRideAlongPanel({
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.status === "IN_TRANSIT" && (
|
||||
<Tooltip label="Train must be at the booking's destination yard">
|
||||
<Tooltip
|
||||
label={
|
||||
canUnload
|
||||
? "Train must be at the booking's destination yard"
|
||||
: "You don't have permission to unload cargo"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
disabled={!canUnload}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId, bookingId: row.id })
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ import { useEffect, useState } from "react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { TrackStation, YardWorkBookingRow } from "@/types/trainScheduling";
|
||||
@@ -111,6 +113,8 @@ export function LogPassYardWorkModal({
|
||||
alreadyLogged: boolean;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const [justLogged, setJustLogged] = useState(false);
|
||||
// When the train was here — defaults to now, past allowed (recorded after the fact).
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
@@ -353,7 +357,9 @@ export function LogPassYardWorkModal({
|
||||
{!row.loadedAt ? (
|
||||
<Tooltip
|
||||
label={
|
||||
!logged
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: !logged
|
||||
? "Log the pass first — the train must be at this yard"
|
||||
: !row.canLoad
|
||||
? "Booking is not ready to load (payment pending)"
|
||||
@@ -364,7 +370,7 @@ export function LogPassYardWorkModal({
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!logged || !row.canLoad}
|
||||
disabled={!canLoad || !logged || !row.canLoad}
|
||||
loading={
|
||||
load.isPending && load.variables?.bookingId === row.id
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Timeline,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
History,
|
||||
@@ -41,13 +43,18 @@ const ACTION_META: Record<
|
||||
* bookings removed from the composition — newest first.
|
||||
*/
|
||||
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const historyQuery = useQuery(
|
||||
api.trainScheduling.scheduleHistory.queryOptions({
|
||||
input: { scheduleId },
|
||||
input: { scheduleId, page, pageSize: 20 },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Keep the previous page on screen while the next one loads.
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
const entries = historyQuery.data ?? [];
|
||||
const entries = historyQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
|
||||
const total = historyQuery.data?.meta.total ?? 0;
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg">
|
||||
@@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} change(s)
|
||||
</Text>
|
||||
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -2,20 +2,27 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Freight } from "@edr/types";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, Lock, MapPin } from "lucide-react";
|
||||
import { AlertTriangle, Link2, Lock, MapPin, Plus, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -56,6 +63,21 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const [pending, setPending] = useState<Record<string, string>>({});
|
||||
/** wagonId → cut yard queued but not yet saved; null = queued clear (rides to destination). */
|
||||
const [pendingCut, setPendingCut] = useState<Record<string, string | null>>({});
|
||||
/** wagonId → real-cut flag queued but not yet saved. */
|
||||
const [pendingRealCut, setPendingRealCut] = useState<Record<string, boolean>>({});
|
||||
/** Loose wagons queued to couple: wagonId → couple stop + display data. */
|
||||
const [pendingCouples, setPendingCouples] = useState<
|
||||
Record<string, { yardId: string; wagonNumber: string; typeCode: string }>
|
||||
>({});
|
||||
/** Already-planned couples queued for removal. */
|
||||
const [pendingUncouple, setPendingUncouple] = useState<string[]>([]);
|
||||
// "Add wagon" modal + its filters.
|
||||
const [coupleModalOpen, setCoupleModalOpen] = useState(false);
|
||||
const [coupleYardFilter, setCoupleYardFilter] = useState<string | null>(null);
|
||||
const [coupleType, setCoupleType] = useState<string | null>(null);
|
||||
const [coupleSearch, setCoupleSearch] = useState("");
|
||||
const [couplePage, setCouplePage] = useState(1);
|
||||
const [debouncedCoupleSearch] = useDebouncedValue(coupleSearch, 300);
|
||||
const [bulkType, setBulkType] = useState<string | null>(null);
|
||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
||||
@@ -63,6 +85,39 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
|
||||
const editable = Boolean(canEdit && data?.editable);
|
||||
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
||||
/** Mid-route stops only — wagons are coupled between the origin and the destination. */
|
||||
const intermediateStops = useMemo(() => {
|
||||
const stops = data?.stops ?? [];
|
||||
return stops.slice(1, -1).filter((s) => s.pickup);
|
||||
}, [data]);
|
||||
// Loose-wagon list for the "Add wagon" modal. A wagon can only be coupled
|
||||
// where it physically stands, and only at a pickup stop of this route — the
|
||||
// Add button carries that yard; off-route wagons render disabled.
|
||||
const coupleListQuery = useQuery(
|
||||
api.wagons.listPaged.queryOptions({
|
||||
input: {
|
||||
filters: {
|
||||
status: Freight.WagonStatus.Available,
|
||||
unassigned: true,
|
||||
currentYardId: coupleYardFilter ?? undefined,
|
||||
wagonTypeId: coupleType ?? undefined,
|
||||
search: debouncedCoupleSearch || undefined,
|
||||
page: couplePage,
|
||||
pageSize: 8,
|
||||
},
|
||||
},
|
||||
enabled: editable && coupleModalOpen,
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
const coupleCandidates = coupleListQuery.data?.items ?? [];
|
||||
const coupleTotalPages = Math.max(1, coupleListQuery.data?.meta.totalPages ?? 1);
|
||||
const yardsQuery = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
|
||||
);
|
||||
const wagonTypesQuery = useQuery(
|
||||
api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000, enabled: coupleModalOpen }),
|
||||
);
|
||||
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
||||
const yardLabel = (id: string | null) =>
|
||||
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
||||
@@ -74,6 +129,8 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
||||
const effectiveCut = (w: ScheduleWagonYardRow) =>
|
||||
w.id in pendingCut ? pendingCut[w.id] : w.cutYardId;
|
||||
const effectiveRealCut = (w: ScheduleWagonYardRow) =>
|
||||
(pendingRealCut[w.id] ?? w.realCut) && effectiveCut(w) != null;
|
||||
const stopIndexOf = (yardId: string | null) =>
|
||||
yardId == null ? -1 : (data?.stops ?? []).findIndex((s) => s.yardId === yardId);
|
||||
/** Drop stops a wagon boarding at `boardYardId` can be cut at — strictly after
|
||||
@@ -108,8 +165,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
cut: (data?.wagons ?? []).filter(
|
||||
(w) => (w.id in pendingCut ? pendingCut[w.id] : w.cutYardId) === s.yardId,
|
||||
).length,
|
||||
coupled:
|
||||
(data?.wagons ?? []).filter(
|
||||
(w) => w.coupledYardId === s.yardId && !pendingUncouple.includes(w.id),
|
||||
).length +
|
||||
Object.values(pendingCouples).filter((c) => c.yardId === s.yardId).length,
|
||||
})),
|
||||
[data, pending, pendingCut],
|
||||
[data, pending, pendingCut, pendingCouples, pendingUncouple],
|
||||
);
|
||||
const typeOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
@@ -117,7 +179,14 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [data]);
|
||||
|
||||
const pendingCount = new Set([...Object.keys(pending), ...Object.keys(pendingCut)]).size;
|
||||
const pendingCount =
|
||||
new Set([
|
||||
...Object.keys(pending),
|
||||
...Object.keys(pendingCut),
|
||||
...Object.keys(pendingRealCut),
|
||||
]).size +
|
||||
Object.keys(pendingCouples).length +
|
||||
pendingUncouple.length;
|
||||
|
||||
const queueBulk = () => {
|
||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||
@@ -152,7 +221,13 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const handleSave = async () => {
|
||||
if (!pendingCount) return;
|
||||
try {
|
||||
const wagonIds = [...new Set([...Object.keys(pending), ...Object.keys(pendingCut)])];
|
||||
const wagonIds = [
|
||||
...new Set([
|
||||
...Object.keys(pending),
|
||||
...Object.keys(pendingCut),
|
||||
...Object.keys(pendingRealCut),
|
||||
]),
|
||||
];
|
||||
const result = await save.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
@@ -160,11 +235,24 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
wagonId,
|
||||
...(wagonId in pending ? { yardId: pending[wagonId] } : {}),
|
||||
...(wagonId in pendingCut ? { cutYardId: pendingCut[wagonId] } : {}),
|
||||
...(wagonId in pendingRealCut ? { realCut: pendingRealCut[wagonId] } : {}),
|
||||
})),
|
||||
...(Object.keys(pendingCouples).length
|
||||
? {
|
||||
couple: Object.entries(pendingCouples).map(([wagonId, c]) => ({
|
||||
wagonId,
|
||||
yardId: c.yardId,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(pendingUncouple.length ? { uncouple: pendingUncouple } : {}),
|
||||
},
|
||||
});
|
||||
setPending({});
|
||||
setPendingCut({});
|
||||
setPendingRealCut({});
|
||||
setPendingCouples({});
|
||||
setPendingUncouple([]);
|
||||
toast({
|
||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
||||
@@ -197,8 +285,11 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
||||
origin). <b>Physical</b> = where the wagon stands now (train builder). <b>Cut at</b> ={" "}
|
||||
where this departure detaches the wagon and leaves it — blank means it rides to the
|
||||
destination; booking capacity past the cut shrinks accordingly. Dispatch is blocked until
|
||||
every wagon stands at its planned yard.
|
||||
destination; booking capacity past the cut shrinks accordingly. Tick <b>Real cut</b> to
|
||||
remove the wagon from the train build permanently at that yard (untick = it sits out this
|
||||
trip only). <b>Coupled</b> wagons are loose wagons joining the train at a stop — they
|
||||
become part of the build for good. Dispatch is blocked until every wagon stands at its
|
||||
planned yard.
|
||||
{data.misaligned > 0 ? (
|
||||
<Text component="span" c="orange" fw={600}>
|
||||
{" "}
|
||||
@@ -232,9 +323,19 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
Cut {s.cut}
|
||||
</Badge>
|
||||
) : null}
|
||||
{s.coupled > 0 ? (
|
||||
<Badge color="blue" variant="light">
|
||||
+{s.coupled} coupled
|
||||
</Badge>
|
||||
) : null}
|
||||
{!s.pickup ? (
|
||||
<Badge color="blue" variant="light">
|
||||
Through {data.wagons.length - perStop.reduce((sum, p) => sum + p.cut, 0)}
|
||||
Through{" "}
|
||||
{data.wagons.filter(
|
||||
(w) => !w.coupledYardId || !pendingUncouple.includes(w.id),
|
||||
).length +
|
||||
Object.keys(pendingCouples).length -
|
||||
perStop.reduce((sum, p) => sum + p.cut, 0)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
@@ -275,6 +376,214 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{editable ? (
|
||||
<Group justify="space-between">
|
||||
<Group gap={6}>
|
||||
<Link2 size={16} />
|
||||
<Text fw={600} size="sm">
|
||||
Consist plan for this trip
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
variant="light"
|
||||
onClick={() => setCoupleModalOpen(true)}
|
||||
>
|
||||
Add wagon
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
opened={coupleModalOpen}
|
||||
onClose={() => setCoupleModalOpen(false)}
|
||||
size="xl"
|
||||
radius="md"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Link2 size={18} />
|
||||
<Text fw={700}>Add wagons to this trip</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Alert color="blue" variant="light" p="xs">
|
||||
A wagon is coupled where it physically stands, so it must be waiting at one of this
|
||||
route's stops between the origin and the destination. Wagons elsewhere are listed
|
||||
but cannot be added until they are moved.
|
||||
</Alert>
|
||||
<Group align="end" gap="sm" wrap="wrap">
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder="All yards"
|
||||
clearable
|
||||
searchable
|
||||
data={(yardsQuery.data ?? [])
|
||||
.filter(
|
||||
(y) =>
|
||||
y.id !== data.stops[0]?.yardId &&
|
||||
y.id !== data.stops[data.stops.length - 1]?.yardId,
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((y) => ({
|
||||
value: y.id,
|
||||
label: intermediateStops.some((s) => s.yardId === y.id)
|
||||
? `${y.label} · route stop`
|
||||
: y.label,
|
||||
}))}
|
||||
value={coupleYardFilter}
|
||||
onChange={(v) => {
|
||||
setCoupleYardFilter(v);
|
||||
setCouplePage(1);
|
||||
}}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Any type"
|
||||
clearable
|
||||
data={(wagonTypesQuery.data ?? []).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.code ? `${t.name} (${t.code})` : t.name,
|
||||
}))}
|
||||
value={coupleType}
|
||||
onChange={(v) => {
|
||||
setCoupleType(v);
|
||||
setCouplePage(1);
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
<TextInput
|
||||
label="Search"
|
||||
placeholder="Wagon number…"
|
||||
leftSection={<Search size={14} />}
|
||||
value={coupleSearch}
|
||||
onChange={(e) => {
|
||||
setCoupleSearch(e.currentTarget.value);
|
||||
setCouplePage(1);
|
||||
}}
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
{coupleListQuery.isLoading ? (
|
||||
<Group justify="center" p="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah={380}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Standing at</Table.Th>
|
||||
<Table.Th ta="right">Couple</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{coupleCandidates.map((w) => {
|
||||
const onTrip = data.wagons.some((row) => row.id === w.id);
|
||||
const queued = w.id in pendingCouples;
|
||||
const stop = intermediateStops.find((s) => s.yardId === w.currentYardId);
|
||||
return (
|
||||
<Table.Tr key={w.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{w.wagonType?.code ?? w.wagonTypeId}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{w.currentYard?.label ?? "No yard"}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{onTrip ? (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
On this trip
|
||||
</Badge>
|
||||
) : queued ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
setPendingCouples((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[w.id];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
Queued — remove
|
||||
</Button>
|
||||
) : stop ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<Plus size={12} />}
|
||||
onClick={() =>
|
||||
setPendingCouples((prev) => ({
|
||||
...prev,
|
||||
[w.id]: {
|
||||
yardId: stop.yardId,
|
||||
wagonNumber: w.wagonNumber,
|
||||
typeCode: w.wagonType?.code ?? w.wagonTypeId,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
Couple at {stop.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="Not standing at a mid-route stop of this schedule (origin and destination excluded)">
|
||||
<Button size="compact-xs" variant="default" disabled>
|
||||
Off route
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
{coupleCandidates.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="sm">
|
||||
No loose wagons match the filters.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : null}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
{coupleTotalPages > 1 ? (
|
||||
<Pagination
|
||||
size="sm"
|
||||
value={couplePage}
|
||||
onChange={setCouplePage}
|
||||
total={coupleTotalPages}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Group gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{Object.keys(pendingCouples).length} wagon(s) queued — save the plan to apply
|
||||
</Text>
|
||||
<Button onClick={() => setCoupleModalOpen(false)}>Done</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -288,10 +597,12 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.wagons.map((w) => {
|
||||
{data.wagons
|
||||
.filter((w) => !w.coupledYardId)
|
||||
.map((w) => {
|
||||
const planned = effectiveYard(w);
|
||||
const cut = effectiveCut(w);
|
||||
const changed = w.id in pending || w.id in pendingCut;
|
||||
const changed = w.id in pending || w.id in pendingCut || w.id in pendingRealCut;
|
||||
return (
|
||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||
@@ -336,24 +647,55 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
{editable ? (
|
||||
// Locked wagons stay editable here — the server enforces the
|
||||
// cargo-destination floor and the toast explains a 409.
|
||||
<Stack gap={4}>
|
||||
<Select
|
||||
size="xs"
|
||||
clearable
|
||||
placeholder="Destination"
|
||||
data={cutOptionsFor(planned)}
|
||||
value={cut}
|
||||
onChange={(v) =>
|
||||
onChange={(v) => {
|
||||
setPendingCut((prev) => {
|
||||
const next = { ...prev };
|
||||
if ((v ?? null) === w.cutYardId) delete next[w.id];
|
||||
else next[w.id] = v ?? null;
|
||||
return next;
|
||||
})
|
||||
});
|
||||
if (!v) {
|
||||
// No cut → no real-cut flag to keep.
|
||||
setPendingRealCut((prev) => {
|
||||
const next = { ...prev };
|
||||
if (w.realCut) next[w.id] = false;
|
||||
else delete next[w.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
w={180}
|
||||
/>
|
||||
{cut ? (
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Real cut (train loses wagon)"
|
||||
checked={effectiveRealCut(w)}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.checked;
|
||||
setPendingRealCut((prev) => {
|
||||
const next = { ...prev };
|
||||
if (v === w.realCut) delete next[w.id];
|
||||
else next[w.id] = v;
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text size="sm">{cut ? yardLabel(cut) : "Destination"}</Text>
|
||||
<Text size="sm">
|
||||
{cut
|
||||
? `${yardLabel(cut)}${effectiveRealCut(w) ? " (real cut)" : ""}`
|
||||
: "Destination"}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -370,6 +712,103 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
{data.wagons
|
||||
.filter((w) => w.coupledYardId)
|
||||
.map((w) => {
|
||||
const queuedOff = pendingUncouple.includes(w.id);
|
||||
return (
|
||||
<Table.Tr
|
||||
key={w.id}
|
||||
bg={queuedOff ? "var(--mantine-color-yellow-light)" : undefined}
|
||||
opacity={queuedOff ? 0.5 : undefined}
|
||||
>
|
||||
<Table.Td>—</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{w.wagonType.code}</Table.Td>
|
||||
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
|
||||
Coupled at {w.coupledYardLabel ?? w.coupledYardId}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">Destination</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
{w.aligned ? (
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
At couple yard
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
Not at couple yard
|
||||
</Badge>
|
||||
)}
|
||||
{editable ? (
|
||||
<Tooltip
|
||||
label={w.locked ? w.lockReason ?? "Locked" : "Remove from couple plan"}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={w.locked}
|
||||
onClick={() =>
|
||||
setPendingUncouple((prev) =>
|
||||
queuedOff ? prev.filter((id) => id !== w.id) : [...prev, w.id],
|
||||
)
|
||||
}
|
||||
>
|
||||
{queuedOff ? "Keep" : "Uncouple"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
{Object.entries(pendingCouples).map(([wagonId, c]) => (
|
||||
<Table.Tr key={wagonId} bg="var(--mantine-color-yellow-light)">
|
||||
<Table.Td>—</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{c.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{c.typeCode}</Table.Td>
|
||||
<Table.Td>{yardLabel(c.yardId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="blue" leftSection={<Link2 size={12} />}>
|
||||
Coupled at {yardLabel(c.yardId)} (pending)
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">Destination</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() =>
|
||||
setPendingCouples((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[wagonId];
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
@@ -383,6 +822,9 @@ export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
onClick={() => {
|
||||
setPending({});
|
||||
setPendingCut({});
|
||||
setPendingRealCut({});
|
||||
setPendingCouples({});
|
||||
setPendingUncouple([]);
|
||||
}}
|
||||
disabled={!pendingCount}
|
||||
>
|
||||
|
||||
@@ -38,6 +38,8 @@ import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -170,6 +172,9 @@ export function ScheduleWorkspacePanel({
|
||||
onChanged,
|
||||
}: ScheduleWorkspacePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
|
||||
|
||||
const freightType: FreightType | undefined =
|
||||
schedule.freightType === "CONTAINER" || schedule.freightType === "BULK"
|
||||
@@ -347,6 +352,64 @@ export function ScheduleWorkspacePanel({
|
||||
const pct = capacity > 0 ? Math.min(100, Math.round((used / capacity) * 100)) : 0;
|
||||
const over = capacity > 0 && used > capacity;
|
||||
|
||||
// One confirmation dialog for every booking action; the action fires only
|
||||
// after staff confirm, and the existing toasts report the outcome.
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
kind: "add" | "load" | "truckToTrain" | "unload" | "remove";
|
||||
bookingId: string;
|
||||
ref: string;
|
||||
weightTons?: number;
|
||||
} | null>(null);
|
||||
const confirmMeta: Record<
|
||||
NonNullable<typeof confirmAction>["kind"],
|
||||
{ title: string; message: string; color: string; confirmLabel: string }
|
||||
> = {
|
||||
add: {
|
||||
title: "Add booking to this train?",
|
||||
message:
|
||||
"The booking is assigned to this departure and wagons are auto-pinned. Adding past the pull-weight limit is allowed but flagged for review.",
|
||||
color: "edr-green",
|
||||
confirmLabel: "Add to train",
|
||||
},
|
||||
load: {
|
||||
title: "Load cargo onto the train?",
|
||||
message:
|
||||
"Stamps the booking as loaded at this yard. The server checks the train is actually standing here.",
|
||||
color: "edr-green",
|
||||
confirmLabel: "Load",
|
||||
},
|
||||
truckToTrain: {
|
||||
title: "Load as direct truck-to-train?",
|
||||
message:
|
||||
"Sets direct truck-to-train handover (no warehouse receipt, no GRN — the carriage acceptance sheet becomes the handover document) and loads the cargo.",
|
||||
color: "blue",
|
||||
confirmLabel: "Load direct",
|
||||
},
|
||||
unload: {
|
||||
title: "Unload cargo at this yard?",
|
||||
message: "Stamps the booking's arrival at this yard and frees its wagons for reuse.",
|
||||
color: "orange",
|
||||
confirmLabel: "Unload",
|
||||
},
|
||||
remove: {
|
||||
title: "Remove booking from this train?",
|
||||
message:
|
||||
"Returns the booking to the unassigned pool, writes a removal log entry, and notifies the customer.",
|
||||
color: "red",
|
||||
confirmLabel: "Remove",
|
||||
},
|
||||
};
|
||||
const runConfirmedAction = () => {
|
||||
if (!confirmAction) return;
|
||||
const { kind, bookingId, ref, weightTons } = confirmAction;
|
||||
setConfirmAction(null);
|
||||
if (kind === "add") forceAdd(bookingId, ref, weightTons ?? 0);
|
||||
else if (kind === "load") doLoad(bookingId, ref);
|
||||
else if (kind === "truckToTrain") doTruckToTrain(bookingId, ref);
|
||||
else if (kind === "unload") doUnload(bookingId, ref);
|
||||
else removeFromTrain(bookingId, ref);
|
||||
};
|
||||
|
||||
const forceAdd = (bookingId: string, ref: string, weightTons: number) => {
|
||||
const wouldOverfill = capacity > 0 && used + (weightTons || 0) > capacity;
|
||||
assign
|
||||
@@ -649,7 +712,14 @@ export function ScheduleWorkspacePanel({
|
||||
radius="md"
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
loading={assign.isPending}
|
||||
onClick={() => forceAdd(b.id, b.reference, b.weightTons)}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
kind: "add",
|
||||
bookingId: b.id,
|
||||
ref: b.reference,
|
||||
weightTons: b.weightTons,
|
||||
})
|
||||
}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -773,7 +843,9 @@ export function ScheduleWorkspacePanel({
|
||||
{showLoad ? (
|
||||
<Tooltip
|
||||
label={
|
||||
boardHere
|
||||
!canLoad
|
||||
? "You don't have permission to load cargo"
|
||||
: boardHere
|
||||
? `Load cargo onto the train at ${group.label}`
|
||||
: passed
|
||||
? `Train already passed ${group.label} — this cargo missed its stop`
|
||||
@@ -788,13 +860,15 @@ export function ScheduleWorkspacePanel({
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!boardHere}
|
||||
disabled={!boardHere || !canLoad}
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={
|
||||
loadJourney.isPending &&
|
||||
loadJourney.variables?.bookingId === b.id
|
||||
}
|
||||
onClick={() => doLoad(b.id, ref)}
|
||||
onClick={() =>
|
||||
setConfirmAction({ kind: "load", bookingId: b.id, ref })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
@@ -802,7 +876,11 @@ export function ScheduleWorkspacePanel({
|
||||
) : null}
|
||||
{showTruckToTrain ? (
|
||||
<Tooltip
|
||||
label="Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
||||
label={
|
||||
canLoad
|
||||
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
|
||||
: "You don't have permission to load cargo"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
@@ -810,9 +888,16 @@ export function ScheduleWorkspacePanel({
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
disabled={!canLoad}
|
||||
leftSection={<Truck size={13} />}
|
||||
loading={truckToTrainPending === b.id}
|
||||
onClick={() => doTruckToTrain(b.id, ref)}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
kind: "truckToTrain",
|
||||
bookingId: b.id,
|
||||
ref,
|
||||
})
|
||||
}
|
||||
>
|
||||
Truck to Train
|
||||
</Button>
|
||||
@@ -821,7 +906,9 @@ export function ScheduleWorkspacePanel({
|
||||
{showUnload ? (
|
||||
<Tooltip
|
||||
label={
|
||||
alightHere
|
||||
!canUnload
|
||||
? "You don't have permission to unload cargo"
|
||||
: alightHere
|
||||
? "Unload at this yard — stamps the booking's arrival"
|
||||
: "Unloads when the train reaches its destination yard"
|
||||
}
|
||||
@@ -832,13 +919,15 @@ export function ScheduleWorkspacePanel({
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
disabled={!alightHere}
|
||||
disabled={!alightHere || !canUnload}
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={
|
||||
unloadJourney.isPending &&
|
||||
unloadJourney.variables?.bookingId === b.id
|
||||
}
|
||||
onClick={() => doUnload(b.id, ref)}
|
||||
onClick={() =>
|
||||
setConfirmAction({ kind: "unload", bookingId: b.id, ref })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
@@ -857,7 +946,9 @@ export function ScheduleWorkspacePanel({
|
||||
unassign.isPending &&
|
||||
unassign.variables?.bookingId === b.id
|
||||
}
|
||||
onClick={() => removeFromTrain(b.id, ref)}
|
||||
onClick={() =>
|
||||
setConfirmAction({ kind: "remove", bookingId: b.id, ref })
|
||||
}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
@@ -961,6 +1052,82 @@ export function ScheduleWorkspacePanel({
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Confirm add / load / unload / remove */}
|
||||
<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={confirmMeta[confirmAction.kind].color}
|
||||
>
|
||||
{confirmAction.kind === "remove" ? (
|
||||
<X size={21} />
|
||||
) : confirmAction.kind === "unload" ? (
|
||||
<PackageOpen size={21} />
|
||||
) : confirmAction.kind === "truckToTrain" ? (
|
||||
<Truck size={21} />
|
||||
) : (
|
||||
<PackageCheck size={21} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={800}>{confirmMeta[confirmAction.kind].title}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{confirmAction.ref}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{confirmAction ? (
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{confirmMeta[confirmAction.kind].message}</Text>
|
||||
{confirmAction.kind === "add" &&
|
||||
capacity > 0 &&
|
||||
used + (confirmAction.weightTons ?? 0) > capacity ? (
|
||||
<Group
|
||||
gap={8}
|
||||
p="xs"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: "var(--mantine-color-red-0)",
|
||||
border: "1px solid var(--mantine-color-red-2)",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={16} color="#B42318" />
|
||||
<Text size="xs" c="red.8" fw={500}>
|
||||
This add pushes the heaviest leg past the locomotive pull weight (
|
||||
{(used + (confirmAction.weightTons ?? 0)).toFixed(1)}T / {capacity.toFixed(0)}T).
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setConfirmAction(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={confirmMeta[confirmAction.kind].color}
|
||||
radius="md"
|
||||
onClick={runConfirmedAction}
|
||||
>
|
||||
{confirmMeta[confirmAction.kind].confirmLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ import {
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
@@ -1566,6 +1568,8 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
||||
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
||||
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.load);
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.readyToLoadExport.queryOptions({ enabled }),
|
||||
);
|
||||
@@ -1655,16 +1659,18 @@ function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?:
|
||||
<><b>{controls.filteredRows.length}</b> item{controls.filteredRows.length !== 1 ? 's' : ''} ready to load</>
|
||||
)}
|
||||
</Text>
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad} withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="filled"
|
||||
color="teal"
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={rows.length === 0}
|
||||
disabled={rows.length === 0 || !canLoad}
|
||||
onClick={() => setTrainPickerOpen(true)}
|
||||
>
|
||||
{selected.size > 0 ? `Load Selected (${selected.size})` : 'Auto Load Ready Items'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
@@ -2305,6 +2311,8 @@ export function ImportArriveQueueTab({
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.warehouseInventory.unload);
|
||||
const { data: trains = [], isLoading } = useQuery(
|
||||
api.warehouses.importArriveQueue.queryOptions({ enabled }),
|
||||
);
|
||||
@@ -2489,12 +2497,13 @@ export function ImportArriveQueueTab({
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload} withArrow>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color={fullyUnloaded ? 'gray' : 'indigo'}
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading || !canUnload}
|
||||
onClick={() =>
|
||||
setConfirmAction({
|
||||
title: 'Auto unload train',
|
||||
@@ -2506,6 +2515,7 @@ export function ImportArriveQueueTab({
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -104,6 +104,9 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:train_scheduling:view",
|
||||
create: "edr_freight_app:train_scheduling:create",
|
||||
update: "edr_freight_app:train_scheduling:update",
|
||||
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
|
||||
load: "edr_freight_app:train_scheduling:load",
|
||||
unload: "edr_freight_app:train_scheduling:unload",
|
||||
cancel: "edr_freight_app:train_scheduling:cancel",
|
||||
reschedule: "edr_freight_app:train_scheduling:reschedule",
|
||||
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Box, Button, Card, Group, Modal, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FileText,
|
||||
LayoutList,
|
||||
Link2,
|
||||
Package,
|
||||
@@ -23,13 +16,19 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { formatDate, humanize } from "@/lib/format";
|
||||
import { FilterBar, dateRangeParams, routeParams, useFilters, type FilterDef } from "@/components/filters";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
routeParams,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
@@ -150,36 +149,97 @@ export default function BookingRequestsPage() {
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{ key: "customerKind", label: "Booked by", type: "enum", multiple: false, options: CUSTOMER_KIND_OPTIONS },
|
||||
{ key: "bookingType", label: "Kind", type: "enum", multiple: false, options: BOOKING_KIND_OPTIONS },
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
key: "tradeDirection", label: "Direction", type: "enum", multiple: false,
|
||||
key: "customerKind",
|
||||
label: "Booked by",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: CUSTOMER_KIND_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "bookingType",
|
||||
label: "Kind",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: BOOKING_KIND_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "statuses",
|
||||
label: "Status",
|
||||
type: "enum",
|
||||
options: STATUS_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "tradeDirection",
|
||||
label: "Direction",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: filterOptions(TRADE_DIRECTION_OPTIONS),
|
||||
},
|
||||
{ key: "freightType", label: "Freight", type: "enum", multiple: false, options: FREIGHT_TYPE_OPTIONS },
|
||||
{ key: "serviceTypeId", label: "Service", type: "enum", multiple: false, options: serviceTypeOptions },
|
||||
{ key: "paymentStatus", label: "Payment", type: "enum", multiple: false, options: PAYMENT_STATUS_OPTIONS, secondary: true },
|
||||
{
|
||||
key: "freightType",
|
||||
label: "Freight",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: serviceTypeOptions,
|
||||
},
|
||||
{
|
||||
key: "paymentStatus",
|
||||
label: "Payment",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: PAYMENT_STATUS_OPTIONS,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
// Wins over the `paymentStatus` filter above — the queue is by
|
||||
// definition PAID — because it's later in this array: toApiParams
|
||||
// merges defs in order, so a later toParams overwrites an earlier one.
|
||||
key: "paidUnallocated", label: "Allocation", type: "boolean", secondary: true,
|
||||
key: "paidUnallocated",
|
||||
label: "Allocation",
|
||||
type: "boolean",
|
||||
secondary: true,
|
||||
trueLabel: "Paid, not allocated",
|
||||
toParams: (v) => (v.v[0] === "true" ? { paymentStatus: "PAID", assignedToSchedule: "false" } : {}),
|
||||
toParams: (v) =>
|
||||
v.v[0] === "true"
|
||||
? { paymentStatus: "PAID", assignedToSchedule: "false" }
|
||||
: {},
|
||||
},
|
||||
{ key: "isGovernment", label: "Ownership", type: "enum", multiple: false, options: OWNERSHIP_OPTIONS, secondary: true },
|
||||
{
|
||||
key: "route", label: "Route", type: "route", options: yardOptions,
|
||||
key: "isGovernment",
|
||||
label: "Ownership",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: OWNERSHIP_OPTIONS,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "Route",
|
||||
type: "route",
|
||||
options: yardOptions,
|
||||
toParams: routeParams("originYardId", "destinationYardId"),
|
||||
},
|
||||
{
|
||||
key: "created", label: "Created", type: "date", secondary: true,
|
||||
key: "created",
|
||||
label: "Created",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("createdFrom", "createdTo"),
|
||||
},
|
||||
{
|
||||
key: "scheduled", label: "Scheduled", type: "date", secondary: true,
|
||||
key: "scheduled",
|
||||
label: "Scheduled",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
@@ -187,19 +247,24 @@ export default function BookingRequestsPage() {
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
);
|
||||
|
||||
const controls = useFilters(bookingFilterDefs, { defaultSort: "createdAt:DESC", pageSize: 10 });
|
||||
const controls = useFilters(bookingFilterDefs, {
|
||||
defaultSort: "createdAt:DESC",
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
...(controls.params as unknown as BookingListFilter),
|
||||
// React Query cache key per kind selection ("ALL" when unfiltered) —
|
||||
// kept as a param the API ignores, matching the pre-migration cache key.
|
||||
tab: (controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||
tab:
|
||||
(controls.values.bookingType?.v[0] as BookingKind | undefined) ?? "ALL",
|
||||
}),
|
||||
[controls.params, controls.values.bookingType],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
const { data, isLoading, isError, refetch, isFetching } =
|
||||
useBookingList(filter);
|
||||
const primaryAllocateId = allocateIds[0];
|
||||
const { data: allocateBooking } = useBookingDetail(
|
||||
allocateOpen ? primaryAllocateId : undefined,
|
||||
@@ -262,8 +327,9 @@ export default function BookingRequestsPage() {
|
||||
async (row: BookingListRow) => {
|
||||
setAllocatingId(row.id);
|
||||
try {
|
||||
const candidates =
|
||||
await trainSchedulingService.getAllocationCandidates(row.id);
|
||||
const candidates = await trainSchedulingService.getAllocationCandidates(
|
||||
row.id,
|
||||
);
|
||||
if (candidates.sameDay.length > 0) {
|
||||
const target = candidates.sameDay[0];
|
||||
await trainSchedulingService.allocatePaidBooking(row.id, target.id);
|
||||
@@ -330,7 +396,9 @@ export default function BookingRequestsPage() {
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[220px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate font-medium text-foreground">{b.reference}</p>
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{b.reference}
|
||||
</p>
|
||||
<Badge
|
||||
variant={isGeneral ? "secondary" : "outline"}
|
||||
className="h-5 shrink-0 px-1.5 text-[10px] font-medium"
|
||||
@@ -338,6 +406,26 @@ export default function BookingRequestsPage() {
|
||||
{isGeneral ? "General" : "One-time"}
|
||||
</Badge>
|
||||
</div>
|
||||
{b.contractReference ? (
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs">
|
||||
<FileText className="size-3 shrink-0 text-muted-foreground opacity-70" />
|
||||
{b.contractId ? (
|
||||
<Link
|
||||
to={`/dashboard/contract-requests/${b.contractId}/view`}
|
||||
// The row itself opens the booking — without this the
|
||||
// contract link would never win the click.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate text-blue-600 hover:underline"
|
||||
>
|
||||
{b.contractReference}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">
|
||||
{b.contractReference}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
{b.isShippingLine ? (
|
||||
<Ship className="size-3 shrink-0 opacity-70" />
|
||||
@@ -346,7 +434,10 @@ export default function BookingRequestsPage() {
|
||||
)}
|
||||
{b.customerLabel}
|
||||
{b.isShippingLine ? (
|
||||
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 shrink-0 px-1 text-[9px] font-medium"
|
||||
>
|
||||
Shipping line
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -382,7 +473,9 @@ export default function BookingRequestsPage() {
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="max-w-[8rem] truncate">{b.originLabel}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[8rem] truncate">{b.destinationLabel}</span>
|
||||
<span className="max-w-[8rem] truncate">
|
||||
{b.destinationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Badge
|
||||
@@ -443,7 +536,8 @@ export default function BookingRequestsPage() {
|
||||
size: 140,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const needsAllocation = b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||
const needsAllocation =
|
||||
b.paymentStatus === "PAID" && !b.trainScheduleId;
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{needsAllocation ? (
|
||||
@@ -586,7 +680,10 @@ export default function BookingRequestsPage() {
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No train on {otherDayModal ? formatDate(otherDayModal.booking.scheduledDate) : "the booking's day"}{" "}
|
||||
No train on{" "}
|
||||
{otherDayModal
|
||||
? formatDate(otherDayModal.booking.scheduledDate)
|
||||
: "the booking's day"}{" "}
|
||||
fits booking {otherDayModal?.booking.reference}. These trains on
|
||||
other dates do — the customer will be notified of the date change.
|
||||
</Text>
|
||||
|
||||
@@ -10,13 +10,23 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Clock,
|
||||
FileText,
|
||||
Link2,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -28,6 +38,39 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type Status = ConsolidationApprovalRow["status"];
|
||||
|
||||
const TABS: { value: Status; label: string }[] = [
|
||||
{ value: "PENDING", label: "Awaiting approval" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<Status, string> = {
|
||||
PENDING: "yellow",
|
||||
APPROVED: "green",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
PENDING: "Awaiting approval",
|
||||
APPROVED: "Approved",
|
||||
REJECTED: "Rejected",
|
||||
};
|
||||
|
||||
const STATUS_VERB: Record<Status, string> = {
|
||||
PENDING: "",
|
||||
APPROVED: "Approved by",
|
||||
REJECTED: "Rejected by",
|
||||
};
|
||||
|
||||
const EMPTY_TEXT: Record<Status, string> = {
|
||||
PENDING: "Nothing waiting for approval.",
|
||||
APPROVED: "No shared wagon has been approved yet.",
|
||||
REJECTED: "No shared wagon has been rejected.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
@@ -37,6 +80,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*
|
||||
* Decided pairings stay on the page rather than vanishing: the decided tabs are
|
||||
* the record of who signed off on which wagon and why. A rejection is not final
|
||||
* either — a rejected pairing can still be approved from here once whatever
|
||||
* blocked it is settled.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
@@ -45,16 +93,31 @@ export default function ConsolidationApprovalsPage() {
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [tab, setTab] = useState<Status>("PENDING");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
const { data, isLoading, isError, isFetching } = useQuery({
|
||||
queryKey: [...QUEUE_KEY, tab, page],
|
||||
queryFn: () =>
|
||||
bookingsService.consolidationApprovalQueue({
|
||||
status: tab,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
// Keeping the last page on screen while the next one loads stops the list
|
||||
// from collapsing to a spinner on every page or tab click.
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const shown = data?.items ?? [];
|
||||
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
|
||||
const countOf = (status: Status) => data?.counts?.[status] ?? 0;
|
||||
|
||||
const goToTab = (next: Status) => {
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
@@ -64,7 +127,10 @@ export default function ConsolidationApprovalsPage() {
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
? bookingsService.approveConsolidation(
|
||||
decision.row.id,
|
||||
note.trim() || undefined,
|
||||
)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -73,6 +139,7 @@ export default function ConsolidationApprovalsPage() {
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
@@ -99,13 +166,40 @@ export default function ConsolidationApprovalsPage() {
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
) : (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
{TABS.map(({ value, label }) => (
|
||||
<Tabs.Tab
|
||||
key={value}
|
||||
value={value}
|
||||
rightSection={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[value]}
|
||||
radius="sm"
|
||||
>
|
||||
{countOf(value)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{!shown.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
{EMPTY_TEXT[tab]}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.map((row) => (
|
||||
{shown.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
@@ -113,25 +207,43 @@ export default function ConsolidationApprovalsPage() {
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
size={30}
|
||||
>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="sm">
|
||||
Awaiting approval
|
||||
<Badge
|
||||
color={STATUS_COLOR[row.status]}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{STATUS_LABEL[row.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={row.booking?.reference ?? row.bookingReference}
|
||||
reference={
|
||||
row.booking?.reference ?? row.bookingReference
|
||||
}
|
||||
company={row.booking?.company?.name}
|
||||
contractReference={row.contractReference}
|
||||
contractId={row.booking?.contractId}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
@@ -140,6 +252,8 @@ export default function ConsolidationApprovalsPage() {
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
contractReference={row.partnerContractReference}
|
||||
contractId={row.partnerBooking?.contractId}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -147,13 +261,37 @@ export default function ConsolidationApprovalsPage() {
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.requestedByName
|
||||
? ` by ${row.requestedByName}`
|
||||
: ""}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{row.status !== "PENDING" && (
|
||||
<Group gap={6} mt={6} c="dimmed" align="flex-start">
|
||||
<User size={13} style={{ marginTop: 2 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={12}>
|
||||
{STATUS_VERB[row.status]}{" "}
|
||||
{row.decidedByName ?? "an unknown user"}
|
||||
{row.decidedAt
|
||||
? ` on ${formatDateTime(row.decidedAt)}`
|
||||
: ""}
|
||||
</Text>
|
||||
{row.decisionNote && (
|
||||
<Text fz={12} fs="italic">
|
||||
“{row.decisionNote}”
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{row.status !== "APPROVED" && (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
@@ -164,8 +302,11 @@ export default function ConsolidationApprovalsPage() {
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
{row.status === "REJECTED"
|
||||
? "Approve anyway"
|
||||
: "Approve"}
|
||||
</Button>
|
||||
{row.status === "PENDING" && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
@@ -178,12 +319,42 @@ export default function ConsolidationApprovalsPage() {
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mt={4}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text fz={12} c="dimmed">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–
|
||||
{Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "}
|
||||
{data?.total ?? 0}
|
||||
</Text>
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
disabled={isFetching}
|
||||
siblings={1}
|
||||
boundaries={1}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(decision)}
|
||||
@@ -194,17 +365,21 @@ export default function ConsolidationApprovalsPage() {
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
{decision?.kind !== "approve"
|
||||
? "Reject this shared wagon?"
|
||||
: decision.row.status === "REJECTED"
|
||||
? "Approve this rejected shared wagon?"
|
||||
: "Approve this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
{decision?.kind !== "approve"
|
||||
? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."
|
||||
: decision.row.status === "REJECTED"
|
||||
? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations."
|
||||
: "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
@@ -254,15 +429,23 @@ export default function ConsolidationApprovalsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
||||
/**
|
||||
* One half of the wagon: its booking reference, the contract it was raised
|
||||
* under, and whose cargo it is. Both references link out — a reviewer deciding
|
||||
* a pairing usually wants the contract, not just the shipment.
|
||||
*/
|
||||
function BookingSide({
|
||||
id,
|
||||
reference,
|
||||
company,
|
||||
contractReference,
|
||||
contractId,
|
||||
}: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
company?: string | null;
|
||||
contractReference?: string | null;
|
||||
contractId?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
@@ -276,6 +459,32 @@ function BookingSide({
|
||||
>
|
||||
{reference ?? "—"}
|
||||
</Text>
|
||||
|
||||
{contractReference && (
|
||||
<Group gap={4} wrap="nowrap" mt={2}>
|
||||
<FileText
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
color="var(--mantine-color-dimmed)"
|
||||
/>
|
||||
{contractId ? (
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/contract-requests/${contractId}/view`}
|
||||
fz={12}
|
||||
c="blue.7"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{contractReference}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fz={12} c="dimmed">
|
||||
{contractReference}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Text fz={12.5} c="dimmed">
|
||||
{company ?? "—"}
|
||||
</Text>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Modal,
|
||||
Progress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
@@ -17,8 +18,10 @@ import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CalendarClock,
|
||||
History,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageOpen,
|
||||
Power,
|
||||
PowerOff,
|
||||
Replace,
|
||||
@@ -36,6 +39,8 @@ import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel
|
||||
import ChangeLocomotivesModal from "@/components/trainBuilder/ChangeLocomotivesModal";
|
||||
import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
|
||||
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
|
||||
import DetachedWagonsPanel from "@/components/trainBuilder/DetachedWagonsPanel";
|
||||
import TrainHistoryPanel from "@/components/trainBuilder/TrainHistoryPanel";
|
||||
import {
|
||||
directionColor,
|
||||
locomotiveStatusColor,
|
||||
@@ -383,6 +388,21 @@ export default function TrainBuilderDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="build" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="build" leftSection={<TrainIcon size={14} />}>
|
||||
Build
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="detached" leftSection={<PackageOpen size={14} />}>
|
||||
Detached wagons
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="build" pt="md">
|
||||
<Stack gap="lg">
|
||||
{composition.wagonYards.length > 1 ? (
|
||||
<Alert color="blue" icon={<MapPin size={16} />}>
|
||||
<Stack gap={4}>
|
||||
@@ -575,6 +595,22 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="detached" pt="md">
|
||||
<DetachedWagonsPanel
|
||||
trainId={composition.id}
|
||||
canAttach={composition.editable && canAssign}
|
||||
attachPending={assignWagons.isPending}
|
||||
onAttach={handleAssign}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<TrainHistoryPanel trainId={composition.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ChangeLocomotivesModal
|
||||
composition={composition}
|
||||
|
||||
@@ -147,13 +147,36 @@ export default function TrainScheduleV2DetailPage() {
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Live phase updates come from the booking-window socket (PHASE pushes
|
||||
// invalidate this query); 60s is the self-heal net for a missed emit so
|
||||
// the workspace countdown never freezes on an expired phase.
|
||||
// invalidate this query). The fast self-heal net is the one-row phase
|
||||
// heartbeat below — this long interval is only the last-resort refresh
|
||||
// for changes the schedule row itself never sees.
|
||||
refetchInterval: 300_000,
|
||||
}),
|
||||
);
|
||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||
// schedule row actually changed — same freshness as polling the detail
|
||||
// itself, at a fraction of the server cost.
|
||||
const phaseQuery = useQuery(
|
||||
api.trainScheduling.schedulePhase.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const lastPhaseSig = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!phaseQuery.data) return;
|
||||
const sig = JSON.stringify(phaseQuery.data);
|
||||
if (lastPhaseSig.current !== null && lastPhaseSig.current !== sig) {
|
||||
void detailQuery.refetch();
|
||||
}
|
||||
lastPhaseSig.current = sig;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phaseQuery.data]);
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const schedule = detailQuery.data;
|
||||
// Controlled so tab-scoped queries (eligible pool) pause on other tabs.
|
||||
const [activeTab, setActiveTab] = useState<string | null>("workflow");
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
@@ -221,7 +244,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const eligibleQuery = useQuery(
|
||||
api.trainScheduling.eligibleBookings.queryOptions({
|
||||
input: { filters: eligibleFilters, freightType: eligibleFreightType },
|
||||
enabled: Boolean(schedule),
|
||||
// The eligible pool feeds the Workflow tab's bookings step only — don't
|
||||
// fetch (or refetch on invalidation) while another tab is open.
|
||||
enabled: Boolean(schedule) && activeTab === "workflow",
|
||||
}),
|
||||
);
|
||||
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||
@@ -1276,7 +1301,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
Workflow
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, PackageCheck, PackageOpen, TrainFront, Warehouse } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
// Generic list footer — already shared by the fleet and train-scheduling lists
|
||||
// despite the ruleEngine path.
|
||||
@@ -93,6 +95,9 @@ const apiErrorMessage = (error: unknown) => {
|
||||
|
||||
function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
|
||||
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
|
||||
const queryClient = useQueryClient();
|
||||
const refresh = () =>
|
||||
queryClient.invalidateQueries({
|
||||
@@ -201,31 +206,37 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
loading={load.isPending}
|
||||
disabled={!canLoad}
|
||||
onClick={() =>
|
||||
load.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{r.trainScheduleId && atDestination(r) && isRiding(r) && (
|
||||
<Tooltip label={canUnload ? undefined : "You don't have permission to unload cargo"} disabled={canUnload}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
loading={unload.isPending}
|
||||
disabled={!canUnload}
|
||||
onClick={() =>
|
||||
unload.mutate({ scheduleId: r.trainScheduleId as string, bookingId: r.bookingId })
|
||||
}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
@@ -231,6 +231,8 @@ import {
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
type BuiltTrainListResponse,
|
||||
type DetachedWagonRow,
|
||||
type TrainHistoryEntry,
|
||||
type ScheduleConsist,
|
||||
type ScheduleWagonYards,
|
||||
type UpdateScheduleWagonYardsPayload,
|
||||
@@ -240,7 +242,10 @@ import {
|
||||
type UpdateTrainDetailsPayload,
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import {
|
||||
trainSchedulingService,
|
||||
type SchedulePhaseSnapshot,
|
||||
} from "./trainScheduling.service";
|
||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
@@ -380,6 +385,14 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id),
|
||||
),
|
||||
|
||||
// One-row heartbeat behind the detail page's 60s poll — the giant detail
|
||||
// payload refetches only when this snapshot changes.
|
||||
schedulePhase: endpoint<{ id: string }, SchedulePhaseSnapshot>(
|
||||
"train-scheduling",
|
||||
"schedule-phase",
|
||||
({ id }) => trainSchedulingService.getSchedulePhase(id),
|
||||
),
|
||||
|
||||
eligibleBookings: endpoint<
|
||||
{ filters?: TrainScheduleFilters; freightType?: FreightType },
|
||||
EligibleContainerBookingsResponse
|
||||
@@ -455,15 +468,20 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
|
||||
scheduleHistory: endpoint<
|
||||
{ scheduleId: string; page: number; pageSize: number },
|
||||
PaginatedResponse<ScheduleHistoryEntry>
|
||||
>(
|
||||
"train-scheduling",
|
||||
"schedule-history",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
({ scheduleId, page, pageSize }) =>
|
||||
trainBuilderService.scheduleHistory(scheduleId, page, pageSize).then((r) => r.data),
|
||||
({ scheduleId, page, pageSize }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"history",
|
||||
scheduleId,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2103,6 +2121,22 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
|
||||
),
|
||||
|
||||
// Keys derive to ["train-builder", "history"|"detachedWagons", input] — the
|
||||
// shared TRAIN_BUILDER.ROOT invalidation refreshes both after every edit.
|
||||
history: endpoint<
|
||||
{ id: string; page: number; pageSize: number },
|
||||
PaginatedResponse<TrainHistoryEntry>
|
||||
>("train-builder", "history", ({ id, page, pageSize }) =>
|
||||
trainBuilderService.getHistory(id, page, pageSize).then((r) => r.data),
|
||||
),
|
||||
|
||||
detachedWagons: endpoint<
|
||||
{ id: string; page: number; pageSize: number },
|
||||
PaginatedResponse<DetachedWagonRow>
|
||||
>("train-builder", "detachedWagons", ({ id, page, pageSize }) =>
|
||||
trainBuilderService.getDetachedWagons(id, page, pageSize).then((r) => r.data),
|
||||
),
|
||||
|
||||
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
|
||||
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
|
||||
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
|
||||
|
||||
@@ -18,23 +18,46 @@ export interface ConsolidationApprovalRow {
|
||||
requestedBy?: string | null;
|
||||
requestedAt: string;
|
||||
decidedBy?: string | null;
|
||||
/** Display name of the approver/rejecter — the id alone means nothing. */
|
||||
decidedByName?: string | null;
|
||||
requestedByName?: string | null;
|
||||
decidedAt?: string | null;
|
||||
decisionNote?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
/** Contract each half was created under — reviewers work by contract. */
|
||||
contractReference?: string | null;
|
||||
partnerContractReference?: string | null;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
contractId?: string | null;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
partnerBooking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
contractId?: string | null;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** One page of approval rows plus the whole-queue counts behind the tabs. */
|
||||
export interface ConsolidationApprovalPage {
|
||||
items: ConsolidationApprovalRow[];
|
||||
total: number;
|
||||
counts: Record<ConsolidationApprovalRow["status"], number>;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs */
|
||||
@@ -164,7 +187,9 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
export const bookingsService = {
|
||||
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
||||
getListSummary: async (
|
||||
filter?: BookingListFilter,
|
||||
): Promise<BookingListSummary> => {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (filter) {
|
||||
if (filter.statuses) params.statuses = filter.statuses;
|
||||
@@ -176,14 +201,16 @@ export const bookingsService = {
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentCurrency)
|
||||
params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.destinationYardId)
|
||||
params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
}
|
||||
@@ -203,22 +230,26 @@ export const bookingsService = {
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
||||
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.schedulingStatuses)
|
||||
params.schedulingStatuses = filter.schedulingStatuses;
|
||||
if (filter.assignedToSchedule)
|
||||
params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentCurrency)
|
||||
params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.destinationYardId)
|
||||
params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
if (filter.customsClearingEnabled)
|
||||
@@ -330,7 +361,9 @@ export const bookingsService = {
|
||||
getConsolidationDetails: async (
|
||||
id: string,
|
||||
): Promise<ConsolidationDetails> => {
|
||||
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
||||
const response = await client.get<ConsolidationDetails>(
|
||||
B.CONSOLIDATION(id),
|
||||
);
|
||||
return unwrap(response.data) as ConsolidationDetails;
|
||||
},
|
||||
|
||||
@@ -348,8 +381,7 @@ export const bookingsService = {
|
||||
|
||||
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
||||
|
||||
startTransit: (id: string) =>
|
||||
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||
startTransit: (id: string) => postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||
|
||||
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
||||
|
||||
@@ -358,10 +390,36 @@ export const bookingsService = {
|
||||
|
||||
// ── Shared-wagon approval gate ──────────────────────────────────────────
|
||||
|
||||
/** Pairings awaiting a decision, oldest first. */
|
||||
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
|
||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
||||
/**
|
||||
* One page of the gate. `status` picks the tab; the counts come back for all
|
||||
* three tabs regardless, so the badges show the whole queue and not the page.
|
||||
*/
|
||||
consolidationApprovalQueue: async (
|
||||
params: {
|
||||
status?: ConsolidationApprovalRow["status"];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<ConsolidationApprovalPage> => {
|
||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE, {
|
||||
params,
|
||||
});
|
||||
const data = unwrap(response.data) as ConsolidationApprovalPage | null;
|
||||
return (
|
||||
data ?? {
|
||||
items: [],
|
||||
total: 0,
|
||||
counts: { PENDING: 0, APPROVED: 0, REJECTED: 0 },
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/** Decision history for one booking's shared wagon — who, when, and why. */
|
||||
@@ -411,10 +469,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
||||
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
||||
B.BASE,
|
||||
payload,
|
||||
);
|
||||
const response = await client.post<
|
||||
{ booking: BookingDetail } | BookingDetail
|
||||
>(B.BASE, payload);
|
||||
const data = unwrap(response.data) as { booking?: BookingDetail };
|
||||
return (data.booking ?? data) as BookingDetail;
|
||||
},
|
||||
@@ -433,7 +490,10 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
/** GL asks the customer for additional clearance document(s). */
|
||||
requestAdditionalDocuments: async (id: string, note: string): Promise<void> => {
|
||||
requestAdditionalDocuments: async (
|
||||
id: string,
|
||||
note: string,
|
||||
): Promise<void> => {
|
||||
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
|
||||
},
|
||||
|
||||
@@ -446,7 +506,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
// ── Clearance charges (post-finalization customer billing) ──
|
||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||
getClearanceCharges: async (
|
||||
id: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
@@ -510,7 +572,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
// ── Additional charges (ad-hoc finance billing) ──
|
||||
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
|
||||
getAdditionalCharges: async (
|
||||
id: string,
|
||||
): Promise<Freight.AdditionalCharge[]> => {
|
||||
const response = await client.get(`/bookings/${id}/additional-charges`);
|
||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||
},
|
||||
@@ -535,9 +599,13 @@ export const bookingsService = {
|
||||
form.append("action", payload.action);
|
||||
if (payload.dueDate) form.append("dueDate", payload.dueDate);
|
||||
if (payload.file) form.append("file", payload.file);
|
||||
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/additional-charges`,
|
||||
form,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
);
|
||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||
},
|
||||
|
||||
@@ -622,12 +690,18 @@ export const bookingsService = {
|
||||
currency: string,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
|
||||
files.forEach((file, index) =>
|
||||
form.append(`draft_declaration_${index}`, file),
|
||||
);
|
||||
form.append("price", String(price));
|
||||
form.append("currency", currency);
|
||||
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
||||
const response = await client.post(
|
||||
B.CLEARANCE_DRAFT_DECLARATION(id),
|
||||
form,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
);
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { api as apiClient } from "../auth/http";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -300,6 +302,29 @@ export interface ScheduleHistoryEntry {
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
/** One wagon adjustment on a built train (History tab): builder edits and trip events alike. */
|
||||
export interface TrainHistoryEntry {
|
||||
id: string;
|
||||
action: "ADD" | "REMOVE" | "SWITCH";
|
||||
subject: string | null;
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
/** Set when the change came from a trip (schedule); null = train-builder edit. */
|
||||
scheduleReference: string | null;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/** Wagon last detached from this train and still loose — the re-attach shortlist. */
|
||||
export interface DetachedWagonRow {
|
||||
wagonId: string;
|
||||
wagonNumber: string;
|
||||
wagonTypeCode: string | null;
|
||||
currentYardLabel: string | null;
|
||||
detachedAt: string;
|
||||
detachedYardLabel: string | null;
|
||||
detachedBy: string | null;
|
||||
}
|
||||
|
||||
/** One consist wagon in the schedule-yards tab: where this departure plans it vs where it stands. */
|
||||
export interface ScheduleWagonYardRow {
|
||||
id: string;
|
||||
@@ -313,6 +338,11 @@ export interface ScheduleWagonYardRow {
|
||||
/** Drop stop this departure cuts the wagon at; null = rides to the destination. */
|
||||
cutYardId: string | null;
|
||||
cutYardLabel: string | null;
|
||||
/** true = REAL cut: the built train permanently loses the wagon at the cut yard. */
|
||||
realCut: boolean;
|
||||
/** Set on planned-couple rows: the pickup stop this loose wagon joins the train at. */
|
||||
coupledYardId: string | null;
|
||||
coupledYardLabel: string | null;
|
||||
aligned: boolean;
|
||||
locked: boolean;
|
||||
lockReason: string | null;
|
||||
@@ -327,6 +357,8 @@ export interface ScheduleWagonYardStop {
|
||||
physical: number;
|
||||
/** Wagons this departure cuts (detaches and leaves) at this stop. */
|
||||
cut: number;
|
||||
/** Loose wagons this departure couples onto the train at this stop. */
|
||||
coupled: number;
|
||||
}
|
||||
|
||||
export interface ScheduleWagonYards {
|
||||
@@ -340,7 +372,16 @@ export interface ScheduleWagonYards {
|
||||
|
||||
export interface UpdateScheduleWagonYardsPayload {
|
||||
/** Omit a field to leave it unchanged; cutYardId null clears the cut (rides to destination). */
|
||||
moves: Array<{ wagonId: string; yardId?: string; cutYardId?: string | null }>;
|
||||
moves?: Array<{
|
||||
wagonId: string;
|
||||
yardId?: string;
|
||||
cutYardId?: string | null;
|
||||
realCut?: boolean;
|
||||
}>;
|
||||
/** Loose wagons to plan-couple at a pickup stop (they must stand at that yard). */
|
||||
couple?: Array<{ wagonId: string; yardId: string }>;
|
||||
/** Wagon ids to drop from the couple plan. */
|
||||
uncouple?: string[];
|
||||
}
|
||||
|
||||
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
|
||||
@@ -349,6 +390,14 @@ export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
|
||||
getHistory: (id: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<TrainHistoryEntry>>(
|
||||
`${BASE}/${id}/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
getDetachedWagons: (id: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<DetachedWagonRow>>(
|
||||
`${BASE}/${id}/detached-wagons?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
/** Import/export run numbers already claimed by existing trains. */
|
||||
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
|
||||
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
|
||||
@@ -411,8 +460,8 @@ export const trainBuilderService = {
|
||||
payload,
|
||||
),
|
||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||
scheduleHistory: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleHistoryEntry[]>(
|
||||
`/train-scheduling/schedules/${scheduleId}/history`,
|
||||
scheduleHistory: (scheduleId: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<ScheduleHistoryEntry>>(
|
||||
`/train-scheduling/schedules/${scheduleId}/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -51,12 +51,30 @@ interface BookingReferenceDataResponse {
|
||||
yard?: Array<YardOption & { label?: string }>;
|
||||
}
|
||||
|
||||
/** Lightweight polling snapshot — refetch the full detail only when this changes. */
|
||||
export interface SchedulePhaseSnapshot {
|
||||
status: string;
|
||||
bookingWindowStatus: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const pathsFor = (freightType?: FreightType) =>
|
||||
freightType === "BULK"
|
||||
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
|
||||
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
|
||||
|
||||
export const trainSchedulingService = {
|
||||
getSchedulePhase: async (id: string): Promise<SchedulePhaseSnapshot> => {
|
||||
const response = await client.get<SchedulePhaseSnapshot>(
|
||||
`/train-scheduling/schedules/${id}/phase`,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
getEligibleBookings: async (
|
||||
filters?: TrainScheduleFilters,
|
||||
freightType?: FreightType,
|
||||
|
||||
@@ -568,7 +568,11 @@ export function ReadonlyBookingView({
|
||||
a credit you can rebook with once the fee is settled.
|
||||
{booking.consolidationPartnerId
|
||||
? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them."
|
||||
: ""}
|
||||
: ""}{" "}
|
||||
<Text span fw={700} c="#B3362C">
|
||||
This cannot be undone from the portal — only EDR staff can revert
|
||||
a cancellation request.
|
||||
</Text>
|
||||
</Text>
|
||||
{paidPreview.isLoading && <Skeleton height={64} radius={10} />}
|
||||
{paidPreview.data && (
|
||||
|
||||
@@ -205,19 +205,6 @@ export function WagonCancellationCard({
|
||||
),
|
||||
});
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
|
||||
onSuccess: () => {
|
||||
toast.success("Cancellation withdrawn — the fee invoice was voided.");
|
||||
void refetch();
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
|
||||
),
|
||||
});
|
||||
|
||||
const [rebookDate, setRebookDate] = useState("");
|
||||
// Non-customs: container number / seal / VGM may change at rebook. Customs
|
||||
// (Path B) credits are rebooked by GL from the backoffice instead.
|
||||
@@ -270,9 +257,8 @@ export function WagonCancellationCard({
|
||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||
</Text>
|
||||
. The cancelled wagons have left the train. Pay the fee to unlock
|
||||
the rebooking credit, or withdraw the request to get the wagons
|
||||
back — withdrawing works only while the train still has free space
|
||||
for them.
|
||||
the rebooking credit. The request cannot be withdrawn from here —
|
||||
if it was a mistake, contact EDR staff.
|
||||
</Alert>
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
@@ -283,14 +269,6 @@ export function WagonCancellationCard({
|
||||
>
|
||||
Pay cancellation fee
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
loading={withdrawMutation.isPending}
|
||||
onClick={() => withdrawMutation.mutate()}
|
||||
>
|
||||
Withdraw request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : creditRow ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
Container,
|
||||
Gauge,
|
||||
MapPin,
|
||||
@@ -717,8 +718,9 @@ export function WagonsTab({
|
||||
)}
|
||||
{cancellable && hasOpenCancellation && (
|
||||
<Alert color="yellow" variant="light">
|
||||
A wagon cancellation is already awaiting its fee — pay or withdraw it
|
||||
in the wagon cancellation card before requesting another.
|
||||
A wagon cancellation is already awaiting its fee — pay it in the
|
||||
wagon cancellation card before requesting another. Withdrawing a
|
||||
request is only possible through EDR staff.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -765,6 +767,13 @@ export function WagonsTab({
|
||||
paid freight for them becomes a credit you can rebook on another
|
||||
day while your contract is valid.
|
||||
</Text>
|
||||
<Alert color="red" variant="light" radius="md" icon={<AlertCircle size={16} />}>
|
||||
<Text fz={13} fw={600}>
|
||||
This cannot be undone from the portal. Once requested, the wagons
|
||||
leave the train and only EDR staff can revert the cancellation —
|
||||
make sure before you confirm.
|
||||
</Text>
|
||||
</Alert>
|
||||
{previewMutation.isPending && <Skeleton height={64} radius={10} />}
|
||||
{preview && (
|
||||
<Box
|
||||
|
||||
@@ -412,10 +412,9 @@ function NewShipmentBookingForm({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
// 20ft containers ride two per wagon. An odd total no longer blocks the
|
||||
// booking — the server auto-pairs it with another customer's odd booking, or
|
||||
// parks it as PENDING_CONSOLIDATION until one shows up (same consolidation
|
||||
// gate the direct-booking flow already uses).
|
||||
// 20ft containers ride two per wagon. On the COMPLETION page an odd total
|
||||
// hard-blocks submit — odd (consolidated) bookings are GL's job in the
|
||||
// backoffice. The direct-booking route keeps the consolidation notice.
|
||||
const watchedContainers = form.watch("containers");
|
||||
const ft20Total =
|
||||
contract.freightType === "CONTAINER"
|
||||
@@ -424,6 +423,7 @@ function NewShipmentBookingForm({
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0)
|
||||
: 0;
|
||||
const hasOdd20ft = ft20Total % 2 === 1;
|
||||
const blockOdd20ft = hasOdd20ft && Boolean(completeBookingId);
|
||||
|
||||
// COMPLETION mode: fetch the booking — a changes-requested resubmit prefills
|
||||
// the form from it and shows the operations note + uploaded documents.
|
||||
@@ -580,6 +580,9 @@ function NewShipmentBookingForm({
|
||||
// run it for every freight type; container contracts additionally get
|
||||
// overweight warnings + 20ft pairing hard-blocks surfaced in the modal.
|
||||
const handleReview = form.handleSubmit((values) => {
|
||||
// Completion: odd 20ft counts never reach review — the red alert next to
|
||||
// the button explains; odd (consolidated) bookings are GL's backoffice job.
|
||||
if (blockOdd20ft) return;
|
||||
setPendingValues(values);
|
||||
validateMutation.reset();
|
||||
validateMutation.mutate(buildDto(values));
|
||||
@@ -744,7 +747,17 @@ function NewShipmentBookingForm({
|
||||
Fix the highlighted fields before reviewing the price.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasOdd20ft ? (
|
||||
{blockOdd20ft ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
mb="sm"
|
||||
>
|
||||
{`${ft20Total} is an odd number of 20ft containers. 20ft containers travel two per wagon, so they must be booked in even numbers — add one more or remove one (e.g. ${ft20Total + 1} or ${ft20Total - 1}).`}
|
||||
</Alert>
|
||||
) : hasOdd20ft ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
|
||||
@@ -802,15 +802,8 @@ export const bookingsService = {
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
|
||||
withdrawWagonCancellation: async (
|
||||
cancellationId: string,
|
||||
): Promise<WagonCancellation> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
// Withdraw was removed from the portal on purpose: a customer's cancellation
|
||||
// request is final — only backoffice staff (void permission) can revert it.
|
||||
|
||||
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
|
||||
rebookWagonCancellation: async (
|
||||
|
||||
@@ -229,10 +229,11 @@ export class FleetController {
|
||||
}
|
||||
|
||||
@Get('coaches/utilization')
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach' })
|
||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
|
||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||
getCoachUtilization() {
|
||||
return this.service.getCoachUtilization();
|
||||
getCoachUtilization(@Query('scheduleId') scheduleId?: string) {
|
||||
return this.service.getCoachUtilization(scheduleId);
|
||||
}
|
||||
|
||||
@Get('coaches/:id')
|
||||
|
||||
@@ -822,12 +822,35 @@ export class FleetService {
|
||||
};
|
||||
}
|
||||
|
||||
async getCoachUtilization() {
|
||||
async getCoachUtilization(scheduleId?: string) {
|
||||
const where = scheduleId ? { scheduleId } : {};
|
||||
|
||||
const coaches = await this.prisma.coach.findMany({
|
||||
where: scheduleId
|
||||
? {
|
||||
assignments: {
|
||||
some: { scheduleId },
|
||||
},
|
||||
}
|
||||
: {},
|
||||
include: {
|
||||
coachType: true,
|
||||
seats: { select: { id: true, status: true } },
|
||||
seats: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
bookingSeats: {
|
||||
where,
|
||||
select: { id: true },
|
||||
},
|
||||
blocks: {
|
||||
where,
|
||||
select: { id: true, reasonCategory: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
assignments: {
|
||||
where,
|
||||
include: {
|
||||
schedule: {
|
||||
select: { id: true, departureAt: true, status: true, _count: { select: { bookings: true } } },
|
||||
@@ -842,10 +865,12 @@ export class FleetService {
|
||||
|
||||
return coaches.map((coach) => {
|
||||
const totalSeats = coach.seats.length;
|
||||
const bookedSeats = coach.seats.filter((s) => s.status === 'BOOKED').length;
|
||||
const blockedSeats = coach.seats.filter((s) => s.status === 'BLOCKED').length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.status as string) === 'UNDER_MAINTENANCE').length;
|
||||
const availableSeats = coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const bookedSeats = coach.seats.filter((s) => (s.bookingSeats?.length ?? 0) > 0).length;
|
||||
const blockedSeats = coach.seats.filter((s) => (s.blocks?.length ?? 0) > 0).length;
|
||||
const maintenanceSeats = coach.seats.filter((s) => (s.blocks ?? []).some((b) => b.reasonCategory === 'MAINTENANCE')).length;
|
||||
const availableSeats = scheduleId
|
||||
? Math.max(totalSeats - bookedSeats - blockedSeats - maintenanceSeats, 0)
|
||||
: coach.seats.filter((s) => s.status === 'AVAILABLE').length;
|
||||
const totalAssignments = coach.assignments.length;
|
||||
const totalBookings = coach.assignments.reduce((sum, a) => sum + ((a.schedule as any)._count?.bookings ?? 0), 0);
|
||||
const utilizationRate = totalSeats > 0 ? +((bookedSeats / totalSeats) * 100).toFixed(2) : 0;
|
||||
|
||||
@@ -200,7 +200,7 @@ export class SchedulesService {
|
||||
liveStatus: { select: { delayMinutes: true } },
|
||||
_count: { select: { coachAssignments: true, bookings: true } },
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
orderBy: { departureAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Search, Grid3x3, Edit, Trash2, Bed, Armchair, Download } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
@@ -13,7 +13,7 @@ import { usePagination } from '@/lib/use-pagination';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
|
||||
type Tab = 'types' | 'coaches' | 'utilization';
|
||||
type Tab = 'types' | 'coaches';
|
||||
|
||||
const getBedLabel = (bedPosition: string | null): string => {
|
||||
if (bedPosition === 'upper') return 'U';
|
||||
@@ -152,8 +152,6 @@ function CoachesPageContent() {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
const [isBedCoach, setIsBedCoach] = useState(false);
|
||||
const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false);
|
||||
const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -169,12 +167,6 @@ function CoachesPageContent() {
|
||||
queryFn: () => fleetApi.getCoaches({}),
|
||||
});
|
||||
|
||||
const { data: utilizationData, isLoading: utilizationLoading } = useQuery({
|
||||
queryKey: ['coach-utilization'],
|
||||
queryFn: () => apiClient.get<any[]>('/fleet/coaches/utilization'),
|
||||
enabled: activeTab === 'utilization',
|
||||
});
|
||||
|
||||
// Coach Type Mutations
|
||||
const createCoachTypeMutation = useMutation({
|
||||
mutationFn: (data: any) => apiClient.post('/fleet/coach-types', data),
|
||||
@@ -531,16 +523,6 @@ function CoachesPageContent() {
|
||||
>
|
||||
Coaches
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setActiveTab('utilization'); setSearch(''); }}
|
||||
className={`px-4 py-3 font-medium transition-colors ${
|
||||
activeTab === 'utilization'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Utilization Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Coach Types Tab */}
|
||||
@@ -591,105 +573,6 @@ function CoachesPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Utilization Tab */}
|
||||
{activeTab === 'utilization' && (() => {
|
||||
const rows = Array.isArray(utilizationData) ? utilizationData : (utilizationData as any)?.data || [];
|
||||
|
||||
const UTIL_COLS = [
|
||||
{ key: 'number', label: 'Coach' },
|
||||
{ key: 'coachType', label: 'Type' },
|
||||
{ key: 'totalSeats', label: 'Total Seats' },
|
||||
{ key: 'availableSeats', label: 'Available' },
|
||||
{ key: 'bookedSeats', label: 'Booked' },
|
||||
{ key: 'blockedSeats', label: 'Blocked' },
|
||||
{ key: 'maintenanceSeats', label: 'Maintenance' },
|
||||
{ key: 'utilizationRate', label: 'Utilization %' },
|
||||
{ key: 'totalAssignments', label: 'Assignments' },
|
||||
{ key: 'totalBookings', label: 'Total Bookings' },
|
||||
];
|
||||
|
||||
const doExport = () => {
|
||||
if (!rows.length) { alert('No data to export'); return; }
|
||||
const headers = UTIL_COLS.map(c => c.label);
|
||||
const exportRows = rows.map((r: any) => UTIL_COLS.map(({ key }) => String(r[key] ?? '')));
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
if (exportUtilFormat === 'pdf') {
|
||||
const w = window.open('', '_blank')!;
|
||||
w.document.write(`<!DOCTYPE html><html><head><title>Coach Utilization Report</title><style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>`);
|
||||
w.document.write(`<h2>Coach Utilization Report — ${dateStr}</h2><table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
|
||||
exportRows.forEach((r: string[]) => { w.document.write(`<tr>${r.map((v: string) => `<td>${v}</td>`).join('')}</tr>`); });
|
||||
w.document.write('</tbody></table></body></html>');
|
||||
w.document.close(); w.print();
|
||||
} else if (exportUtilFormat === 'excel') {
|
||||
const tsv = [headers.join('\t'), ...exportRows.map((r: string[]) => r.join('\t'))].join('\n');
|
||||
const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url);
|
||||
} else {
|
||||
const csv = [headers.map(h => `"${h}"`).join(','), ...exportRows.map((r: string[]) => r.map((v: string) => `"${v.replace(/"/g, '""')}"`).join(','))].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a'); a.href = url; a.download = `coach-utilization-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url);
|
||||
}
|
||||
setExportUtilModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-6 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<ActionButton icon={Download} variant="secondary" onClick={() => setExportUtilModalOpen(true)}>Export</ActionButton>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'number', label: 'Coach', render: (r: any) => <span className="font-medium">{r.number}</span> },
|
||||
{ key: 'coachType', label: 'Type', render: (r: any) => <span className="text-sm">{r.coachType || 'N/A'}</span> },
|
||||
{ key: 'totalSeats', label: 'Total Seats', render: (r: any) => <span className="font-mono">{r.totalSeats}</span> },
|
||||
{ key: 'availableSeats', label: 'Available', render: (r: any) => <span className="font-mono text-green-600">{r.availableSeats}</span> },
|
||||
{ key: 'bookedSeats', label: 'Booked', render: (r: any) => <span className="font-mono text-red-600">{r.bookedSeats}</span> },
|
||||
{ key: 'blockedSeats', label: 'Blocked', render: (r: any) => <span className="font-mono text-gray-500">{r.blockedSeats}</span> },
|
||||
{ key: 'maintenanceSeats', label: 'Maintenance', render: (r: any) => <span className="font-mono text-orange-500">{r.maintenanceSeats}</span> },
|
||||
{
|
||||
key: 'utilizationRate', label: 'Utilization',
|
||||
render: (r: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${r.utilizationRate}%` }} />
|
||||
</div>
|
||||
<span className="font-mono text-sm">{r.utilizationRate}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'totalAssignments', label: 'Assignments', render: (r: any) => <span className="font-mono">{r.totalAssignments}</span> },
|
||||
{ key: 'totalBookings', label: 'Total Bookings', render: (r: any) => <span className="font-mono font-semibold">{r.totalBookings}</span> },
|
||||
]}
|
||||
data={rows}
|
||||
actions={[]}
|
||||
loading={utilizationLoading}
|
||||
emptyMessage="No coach utilization data available"
|
||||
/>
|
||||
|
||||
<Modal isOpen={exportUtilModalOpen} onClose={() => setExportUtilModalOpen(false)} title="Export Utilization Report" size="sm">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Export Format</p>
|
||||
<div className="flex gap-3">
|
||||
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="utilExportFormat" value={fmt} checked={exportUtilFormat === fmt} onChange={() => setExportUtilFormat(fmt)} className="w-4 h-4" />
|
||||
<span className="text-sm font-medium capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportUtilModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={doExport}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, BarChart3, Download, Search } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { usePagination } from "@/lib/use-pagination";
|
||||
|
||||
interface ScheduleOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CoachUtilizationRow {
|
||||
id: string;
|
||||
number: string;
|
||||
coachType: string | null;
|
||||
status: string | null;
|
||||
totalSeats: number;
|
||||
availableSeats: number;
|
||||
bookedSeats: number;
|
||||
blockedSeats: number;
|
||||
maintenanceSeats: number;
|
||||
utilizationRate: number;
|
||||
totalAssignments: number;
|
||||
totalBookings: number;
|
||||
}
|
||||
|
||||
export default function CoachUtilizationReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ["report-schedules-all"],
|
||||
queryFn: () => apiClient.get("/reports/schedules?all=true"),
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useQuery<CoachUtilizationRow[]>({
|
||||
queryKey: ["coach-utilization-report", scheduleId],
|
||||
queryFn: () => apiClient.get(`/fleet/coaches/utilization?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const schedules = schedulesRaw ?? [];
|
||||
const rows = data ?? [];
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((row) => {
|
||||
const coachType = row.coachType ?? "";
|
||||
const status = row.status ?? "";
|
||||
return (
|
||||
row.number.toLowerCase().includes(q) ||
|
||||
coachType.toLowerCase().includes(q) ||
|
||||
status.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [rows, search]);
|
||||
|
||||
const { paged, page, totalPages, setPage, reset } = usePagination(filteredRows, 25);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!rows.length) return null;
|
||||
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => {
|
||||
acc.totalSeats += row.totalSeats;
|
||||
acc.availableSeats += row.availableSeats;
|
||||
acc.bookedSeats += row.bookedSeats;
|
||||
acc.blockedSeats += row.blockedSeats;
|
||||
acc.maintenanceSeats += row.maintenanceSeats;
|
||||
acc.totalBookings += row.totalBookings;
|
||||
acc.totalAssignments += row.totalAssignments;
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
totalSeats: 0,
|
||||
availableSeats: 0,
|
||||
bookedSeats: 0,
|
||||
blockedSeats: 0,
|
||||
maintenanceSeats: 0,
|
||||
totalBookings: 0,
|
||||
totalAssignments: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const avgUtilization = rows.length
|
||||
? rows.reduce((sum, row) => sum + row.utilizationRate, 0) / rows.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalCoaches: rows.length,
|
||||
totalSeats: totals.totalSeats,
|
||||
availableSeats: totals.availableSeats,
|
||||
bookedSeats: totals.bookedSeats,
|
||||
blockedSeats: totals.blockedSeats,
|
||||
maintenanceSeats: totals.maintenanceSeats,
|
||||
avgUtilization,
|
||||
totalAssignments: totals.totalAssignments,
|
||||
totalBookings: totals.totalBookings,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
const doExport = () => {
|
||||
if (!filteredRows.length) return;
|
||||
|
||||
const headers = [
|
||||
"Coach",
|
||||
"Type",
|
||||
"Status",
|
||||
"Total Seats",
|
||||
"Available",
|
||||
"Booked",
|
||||
"Blocked",
|
||||
"Maintenance",
|
||||
"Utilization %",
|
||||
"Assignments",
|
||||
"Total Bookings",
|
||||
];
|
||||
|
||||
const rowsCsv = filteredRows.map((row) => [
|
||||
row.number,
|
||||
row.coachType ?? "—",
|
||||
row.status ?? "—",
|
||||
String(row.totalSeats),
|
||||
String(row.availableSeats),
|
||||
String(row.bookedSeats),
|
||||
String(row.blockedSeats),
|
||||
String(row.maintenanceSeats),
|
||||
`${row.utilizationRate}%`,
|
||||
String(row.totalAssignments),
|
||||
String(row.totalBookings),
|
||||
]);
|
||||
|
||||
const csv = [
|
||||
headers.map((header) => `"${header}"`).join(","),
|
||||
...rowsCsv.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")),
|
||||
].join("\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `coach-utilization-${scheduleId || "fleet"}-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Coach Utilization Report</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Occupancy, availability, and booking load by coach for a selected schedule.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-72">
|
||||
<label className="label">Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={scheduleId}
|
||||
onChange={(e) => {
|
||||
setScheduleId(e.target.value);
|
||||
setSearch("");
|
||||
reset();
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||
</option>
|
||||
{schedules.map((schedule) => (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{schedule.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
{isError && <p className="text-xs text-red-500 mt-2">Failed to load coach utilization.</p>}
|
||||
</div>
|
||||
|
||||
{!scheduleId && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Activity className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-base font-medium text-foreground">Choose a schedule to review coach occupancy</p>
|
||||
<p className="text-xs mt-1">The report drills into availability, bookings, and maintenance status for each assigned coach.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && summary && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<div className="card">
|
||||
<p className="text-muted-foreground text-sm font-medium">Coaches</p>
|
||||
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalCoaches}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Assigned coaches</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Total Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-foreground">{summary.totalSeats}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all coaches</p>
|
||||
</div>
|
||||
<BarChart3 className="h-8 w-8 text-emerald-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Booked</p>
|
||||
<p className="text-2xl font-bold mt-2 text-rose-600 dark:text-rose-400">{summary.bookedSeats}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Occupied seats</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-rose-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Available</p>
|
||||
<p className="text-2xl font-bold mt-2 text-emerald-600 dark:text-emerald-400">{summary.availableSeats}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Open seats</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-emerald-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">{summary.blockedSeats}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Unavailable seats</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Avg Utilization</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
|
||||
{summary.avgUtilization.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across coach set</p>
|
||||
</div>
|
||||
<BarChart3 className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-0">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3 gap-4 flex-wrap">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Coach Details
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-xs pl-10"
|
||||
placeholder="Coach, type, or status…"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={!filteredRows.length}>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
"Coach",
|
||||
"Type",
|
||||
"Status",
|
||||
"Total Seats",
|
||||
"Available",
|
||||
"Booked",
|
||||
"Blocked",
|
||||
"Maintenance",
|
||||
"Utilization",
|
||||
"Assignments",
|
||||
"Bookings",
|
||||
].map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{paged.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td className="px-4 py-3 font-medium whitespace-nowrap">{row.number}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.coachType ?? "—"}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground">{row.status ?? "—"}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalSeats}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-emerald-600 dark:text-emerald-400">{row.availableSeats}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-rose-600 dark:text-rose-400">{row.bookedSeats}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-slate-600 dark:text-slate-400">{row.blockedSeats}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums text-amber-600 dark:text-amber-400">{row.maintenanceSeats}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-20 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${Math.min(row.utilizationRate, 100)}%` }} />
|
||||
</div>
|
||||
<span className="font-mono text-sm">{row.utilizationRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalAssignments}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap font-mono tabular-nums">{row.totalBookings}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{paged.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-8 text-center text-sm text-muted-foreground">
|
||||
No coach utilization rows found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !isLoading && scheduleId && (
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
No utilization data found for this schedule.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
|
||||
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
|
||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
|
||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||
|
||||
@@ -659,8 +659,13 @@ export default function ConfirmationPage() {
|
||||
{(() => {
|
||||
// The server-confirmed settled amount is authoritative — prefer it over
|
||||
// any client-side session state, which can go stale (e.g. after a refresh).
|
||||
// Despite its name, PaymentIntent.amountMinor holds MAJOR units — it is the
|
||||
// charge amount produced by currencyService.*ToChargeMajor (see initiate() in
|
||||
// payments.service.ts; reports.service.ts multiplies it by 100 to get real
|
||||
// minor units). Do NOT divide by 100 here — the voucher does the same via
|
||||
// fareIsMajorUnits.
|
||||
if (_booking?.payment?.amountMinor != null) {
|
||||
return `${_booking.payment.currency || 'ETB'} ${(_booking.payment.amountMinor / 100).toFixed(2)}`;
|
||||
return `${_booking.payment.currency || 'ETB'} ${_booking.payment.amountMinor.toFixed(2)}`;
|
||||
}
|
||||
if (reviewedTotalMinor != null)
|
||||
return `${paidCurrency || 'ETB'} ${(reviewedTotalMinor / 100).toFixed(2)}`;
|
||||
|
||||
Reference in New Issue
Block a user