mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add pagination to schedule history and consolidation approvals
- Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ConsolidationApprovalService,
|
ConsolidationApprovalService,
|
||||||
CONSOLIDATION_APPROVAL_PENDING,
|
CONSOLIDATION_APPROVAL_PENDING,
|
||||||
} from './consolidation-approval.service';
|
} from "./consolidation-approval.service";
|
||||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
import { ConsolidationApprovalStatus } from "./entities/consolidation-approval.entity";
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||||
@@ -14,37 +14,53 @@ import { Booking } from './entities/booking.entity';
|
|||||||
* decision on one side of a shared wagon is meaningless without the other), and
|
* decision on one side of a shared wagon is meaningless without the other), and
|
||||||
* a decided pairing cannot be decided twice.
|
* a decided pairing cannot be decided twice.
|
||||||
*/
|
*/
|
||||||
describe('ConsolidationApprovalService', () => {
|
describe("ConsolidationApprovalService", () => {
|
||||||
const PENDING = {
|
const PENDING = {
|
||||||
id: 'ap-1',
|
id: "ap-1",
|
||||||
bookingId: 'b-1',
|
bookingId: "b-1",
|
||||||
partnerBookingId: 'b-2',
|
partnerBookingId: "b-2",
|
||||||
status: ConsolidationApprovalStatus.Pending,
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
requestedBy: 'gl-user',
|
requestedBy: "gl-user",
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(overrides: {
|
function makeService(
|
||||||
approvals?: Partial<Record<string, jest.Mock>>;
|
overrides: {
|
||||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
approvals?: Partial<Record<string, jest.Mock>>;
|
||||||
} = {}) {
|
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||||
|
bookingsService?: Partial<Record<string, jest.Mock>>;
|
||||||
|
/** Yard ids the caller is scoped to; null = unrestricted. */
|
||||||
|
yardScope?: string[] | null;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const approvals = {
|
const approvals = {
|
||||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||||
findById: jest.fn().mockResolvedValue(PENDING),
|
findById: jest.fn().mockResolvedValue(PENDING),
|
||||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
create: jest.fn().mockResolvedValue({ id: "ap-1" }),
|
||||||
decide: jest.fn().mockResolvedValue(true),
|
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([]),
|
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||||
...overrides.approvals,
|
...overrides.approvals,
|
||||||
};
|
};
|
||||||
const bookingsRepository = {
|
const bookingsRepository = {
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
resolveStaffNames: jest.fn().mockResolvedValue(new Map()),
|
||||||
...overrides.bookingsRepository,
|
...overrides.bookingsRepository,
|
||||||
};
|
};
|
||||||
const bookingsService = {
|
const bookingsService = {
|
||||||
findById: jest.fn(async (id: string) =>
|
findById: jest.fn(
|
||||||
({ id, reference: `BK-${id}` }) as Booking,
|
async (id: string) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: `BK-${id}`,
|
||||||
|
originYardId: "mojo",
|
||||||
|
destinationYardId: "djibouti",
|
||||||
|
}) as Booking,
|
||||||
),
|
),
|
||||||
|
...overrides.bookingsService,
|
||||||
};
|
};
|
||||||
const notifier = {
|
const notifier = {
|
||||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||||
@@ -55,6 +71,11 @@ describe('ConsolidationApprovalService', () => {
|
|||||||
const dataSource = {
|
const dataSource = {
|
||||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||||
};
|
};
|
||||||
|
const yardScope = {
|
||||||
|
getScopedYardIds: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(overrides.yardScope ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
const service = new ConsolidationApprovalService(
|
const service = new ConsolidationApprovalService(
|
||||||
approvals as never,
|
approvals as never,
|
||||||
@@ -62,27 +83,28 @@ describe('ConsolidationApprovalService', () => {
|
|||||||
bookingsService as never,
|
bookingsService as never,
|
||||||
notifier as never,
|
notifier as never,
|
||||||
dataSource as never,
|
dataSource as never,
|
||||||
|
yardScope as never,
|
||||||
);
|
);
|
||||||
return { service, approvals, bookingsRepository, notifier };
|
return { service, approvals, bookingsRepository, notifier, yardScope };
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
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(approvals.create).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
bookingId: 'b-1',
|
bookingId: "b-1",
|
||||||
partnerBookingId: 'b-2',
|
partnerBookingId: "b-2",
|
||||||
requestedBy: 'gl-user',
|
requestedBy: "gl-user",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
// 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,
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
});
|
});
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -90,94 +112,102 @@ describe('ConsolidationApprovalService', () => {
|
|||||||
).toHaveBeenCalledTimes(1);
|
).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({
|
const { service, approvals } = makeService({
|
||||||
approvals: {
|
approvals: {
|
||||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
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(result).toBe(PENDING);
|
||||||
expect(approvals.create).not.toHaveBeenCalled();
|
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();
|
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(
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
'ap-1',
|
"ap-1",
|
||||||
ConsolidationApprovalStatus.Approved,
|
ConsolidationApprovalStatus.Approved,
|
||||||
'approver-1',
|
"approver-1",
|
||||||
'looks fine',
|
"looks fine",
|
||||||
|
[
|
||||||
|
ConsolidationApprovalStatus.Pending,
|
||||||
|
ConsolidationApprovalStatus.Rejected,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
});
|
});
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
});
|
});
|
||||||
// Operations only learns about the pair now — the gate is what kept it out.
|
// Operations only learns about the pair now — the gate is what kept it out.
|
||||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
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();
|
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(
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
'ap-1',
|
"ap-1",
|
||||||
ConsolidationApprovalStatus.Rejected,
|
ConsolidationApprovalStatus.Rejected,
|
||||||
'approver-1',
|
"approver-1",
|
||||||
'partner cargo is wrong',
|
"partner cargo is wrong",
|
||||||
);
|
);
|
||||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||||
'b-1',
|
"b-1",
|
||||||
'partner cargo is wrong',
|
"partner cargo is wrong",
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
);
|
);
|
||||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||||
'b-2',
|
"b-2",
|
||||||
'partner cargo is wrong',
|
"partner cargo is wrong",
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
);
|
);
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
expect(bookingsRepository.update).toHaveBeenCalledWith("b-1", {
|
||||||
status: 'OPERATION_CHANGES_REQUESTED',
|
status: "OPERATION_CHANGES_REQUESTED",
|
||||||
});
|
});
|
||||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
expect(bookingsRepository.update).toHaveBeenCalledWith("b-2", {
|
||||||
status: 'OPERATION_CHANGES_REQUESTED',
|
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,
|
// No maker-checker separation: the permission alone decides who may approve,
|
||||||
// and the audit trail still records requester and approver separately.
|
// and the audit trail still records requester and approver separately.
|
||||||
const { service, approvals } = makeService();
|
const { service, approvals } = makeService();
|
||||||
|
|
||||||
await service.approve('ap-1', 'gl-user');
|
await service.approve("ap-1", "gl-user");
|
||||||
|
|
||||||
expect(approvals.decide).toHaveBeenCalledWith(
|
expect(approvals.decide).toHaveBeenCalledWith(
|
||||||
'ap-1',
|
"ap-1",
|
||||||
ConsolidationApprovalStatus.Approved,
|
ConsolidationApprovalStatus.Approved,
|
||||||
'gl-user',
|
"gl-user",
|
||||||
undefined,
|
undefined,
|
||||||
|
[
|
||||||
|
ConsolidationApprovalStatus.Pending,
|
||||||
|
ConsolidationApprovalStatus.Rejected,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a reason to reject', async () => {
|
it("requires a reason to reject", async () => {
|
||||||
const { service, approvals } = makeService();
|
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,
|
/reason is required/i,
|
||||||
);
|
);
|
||||||
expect(approvals.decide).not.toHaveBeenCalled();
|
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({
|
const { service, bookingsRepository } = makeService({
|
||||||
approvals: {
|
approvals: {
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
@@ -187,21 +217,203 @@ 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,
|
/already approved/i,
|
||||||
);
|
);
|
||||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
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
|
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||||
// affects nothing and must not move the bookings.
|
// affects nothing and must not move the bookings.
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
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,
|
/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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
|
|||||||
import { BookingsRepository } from "./bookings.repository";
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingsService } from "./bookings.service";
|
||||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||||
|
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
||||||
|
|
||||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||||
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
||||||
@@ -25,6 +27,12 @@ const REJECTED_STATUS = "OPERATION_CHANGES_REQUESTED";
|
|||||||
/** The gate's own holding status — neither half reaches Operations from here. */
|
/** The gate's own holding status — neither half reaches Operations from here. */
|
||||||
export const CONSOLIDATION_APPROVAL_PENDING = "CONSOLIDATION_APPROVAL_PENDING";
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared-wagon approval gate.
|
* The shared-wagon approval gate.
|
||||||
*
|
*
|
||||||
@@ -53,6 +61,7 @@ export class ConsolidationApprovalService {
|
|||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
private readonly notifier: BookingLifecycleNotifierService,
|
private readonly notifier: BookingLifecycleNotifierService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly yardScope: YardScopeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,8 +125,16 @@ export class ConsolidationApprovalService {
|
|||||||
approvalId: string,
|
approvalId: string,
|
||||||
decidedBy: string,
|
decidedBy: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
|
user?: unknown,
|
||||||
): Promise<{ booking: Booking; partner: Booking }> {
|
): 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 () => {
|
await this.dataSource.transaction(async () => {
|
||||||
const claimed = await this.approvals.decide(
|
const claimed = await this.approvals.decide(
|
||||||
@@ -125,6 +142,10 @@ export class ConsolidationApprovalService {
|
|||||||
ConsolidationApprovalStatus.Approved,
|
ConsolidationApprovalStatus.Approved,
|
||||||
decidedBy,
|
decidedBy,
|
||||||
note,
|
note,
|
||||||
|
[
|
||||||
|
ConsolidationApprovalStatus.Pending,
|
||||||
|
ConsolidationApprovalStatus.Rejected,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
// Lost the race to another approver deciding the same pairing.
|
// Lost the race to another approver deciding the same pairing.
|
||||||
if (!claimed) {
|
if (!claimed) {
|
||||||
@@ -162,13 +183,17 @@ export class ConsolidationApprovalService {
|
|||||||
approvalId: string,
|
approvalId: string,
|
||||||
decidedBy: string,
|
decidedBy: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
|
user?: unknown,
|
||||||
): Promise<{ booking: Booking; partner: Booking }> {
|
): Promise<{ booking: Booking; partner: Booking }> {
|
||||||
if (!reason?.trim()) {
|
if (!reason?.trim()) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"A reason is required to reject a consolidation.",
|
"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 () => {
|
await this.dataSource.transaction(async () => {
|
||||||
const claimed = await this.approvals.decide(
|
const claimed = await this.approvals.decide(
|
||||||
@@ -212,9 +237,88 @@ export class ConsolidationApprovalService {
|
|||||||
return { booking, partner };
|
return { booking, partner };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pending pairings awaiting a decision, oldest first. */
|
/**
|
||||||
queue(): Promise<ConsolidationApproval[]> {
|
* One page of the review queue, or of its history: pending pairings first,
|
||||||
return this.approvals.findQueue();
|
* 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 items = rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
requestedByName: row.requestedBy
|
||||||
|
? (names.get(row.requestedBy) ?? null)
|
||||||
|
: null,
|
||||||
|
decidedByName: row.decidedBy ? (names.get(row.decidedBy) ?? null) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
counts,
|
||||||
|
meta: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. */
|
/** Full decision history for one booking — who decided what, and when. */
|
||||||
@@ -227,12 +331,46 @@ export class ConsolidationApprovalService {
|
|||||||
return this.approvals.findPendingForBooking(bookingId);
|
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);
|
const approval = await this.approvals.findById(approvalId);
|
||||||
if (!approval) {
|
if (!approval) {
|
||||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||||
}
|
}
|
||||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
if (!allowed.includes(approval.status)) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { DataSource, In, Repository } from "typeorm";
|
import { DataSource, In, Repository, SelectQueryBuilder } from "typeorm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConsolidationApproval,
|
ConsolidationApproval,
|
||||||
ConsolidationApprovalStatus,
|
ConsolidationApprovalStatus,
|
||||||
} from "./entities/consolidation-approval.entity";
|
} 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 —
|
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||||
* decided rows are the audit trail of who approved which pairing and when.
|
* 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 } });
|
return this.repository.findOne({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
/**
|
||||||
findQueue(): Promise<ConsolidationApproval[]> {
|
* One page of review-queue rows, with both bookings loaded.
|
||||||
return this.repository.find({
|
*
|
||||||
where: { status: ConsolidationApprovalStatus.Pending },
|
* Pending rows are work still to do, so they come oldest first (FIFO) and
|
||||||
relations: {
|
* ahead of everything else. Decided rows are history, so they come
|
||||||
booking: { company: true },
|
* newest-decision-first. Ordering is done in SQL, not after the fact — a page
|
||||||
partnerBooking: { company: true },
|
* sorted in memory would only be sorted within itself.
|
||||||
},
|
*
|
||||||
order: { requestedAt: "ASC" },
|
* `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: {
|
create(input: {
|
||||||
@@ -89,9 +194,11 @@ export class ConsolidationApprovalsRepository {
|
|||||||
| ConsolidationApprovalStatus.Rejected,
|
| ConsolidationApprovalStatus.Rejected,
|
||||||
decidedBy: string | null,
|
decidedBy: string | null,
|
||||||
decisionNote?: string | null,
|
decisionNote?: string | null,
|
||||||
|
/** Statuses the row may be claimed FROM. Defaults to pending-only. */
|
||||||
|
from: ConsolidationApprovalStatus[] = [ConsolidationApprovalStatus.Pending],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await this.repository.update(
|
const result = await this.repository.update(
|
||||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
{ id, status: In(from) },
|
||||||
{
|
{
|
||||||
status,
|
status,
|
||||||
decidedBy,
|
decidedBy,
|
||||||
@@ -109,7 +216,10 @@ export class ConsolidationApprovalsRepository {
|
|||||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||||
return this.repository.find({
|
return this.repository.find({
|
||||||
where: [
|
where: [
|
||||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
{
|
||||||
|
bookingId: In(bookingIds),
|
||||||
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
partnerBookingId: In(bookingIds),
|
partnerBookingId: In(bookingIds),
|
||||||
status: ConsolidationApprovalStatus.Pending,
|
status: ConsolidationApprovalStatus.Pending,
|
||||||
|
|||||||
@@ -18,6 +18,30 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
return manager ? manager.getRepository(TrainSchedule) : this.repository;
|
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> {
|
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||||
return this.repo(manager).findOne({
|
return this.repo(manager).findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
|
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 { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
|
||||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
@@ -243,14 +244,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")
|
@Get("schedules/:id/history")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
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",
|
"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) {
|
getScheduleHistory(
|
||||||
return this.trainSchedulingService.getScheduleHistory(id);
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query() query: PaginationQueryDto,
|
||||||
|
) {
|
||||||
|
return this.trainSchedulingService.getScheduleHistory(id, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("bookable-schedules")
|
@Get("bookable-schedules")
|
||||||
|
|||||||
@@ -4379,7 +4379,10 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
/** Log the train passing a station. Logging the destination station triggers arrival. */
|
/** Log the train passing a station. Logging the destination station triggers arrival. */
|
||||||
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
// Slim graph: checkpoint logging reads stops, locomotives, the built
|
||||||
|
// train and the wagon plans — never the booking/container branches.
|
||||||
|
// (arriveSchedule, invoked on the final leg, loads its own full graph.)
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
@@ -4473,9 +4476,23 @@ export class TrainSchedulingService {
|
|||||||
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
const cutNow = Object.entries(cutPlan).filter(([, yardId]) =>
|
||||||
passedYardIds.includes(yardId),
|
passedYardIds.includes(yardId),
|
||||||
);
|
);
|
||||||
|
// One fetch for the whole plan, one bulk insert per log table — the
|
||||||
|
// per-wagon UPDATEs stay (each patch differs) but the transaction no
|
||||||
|
// longer serializes a findOne + save pair per wagon.
|
||||||
|
const cutWagonById = new Map(
|
||||||
|
cutNow.length
|
||||||
|
? (
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.find({ where: { id: In(cutNow.map(([wagonId]) => wagonId)) } })
|
||||||
|
).map((w) => [w.id, w])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const adjustmentRows: ScheduleWagonAdjustmentLog[] = [];
|
||||||
|
const movementRows: WagonMovement[] = [];
|
||||||
let realCutHappened = false;
|
let realCutHappened = false;
|
||||||
for (const [wagonId, cutYardId] of cutNow) {
|
for (const [wagonId, cutYardId] of cutNow) {
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
const wagon = cutWagonById.get(wagonId);
|
||||||
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
// Already settled earlier (or re-pinned elsewhere) — not ours to move.
|
||||||
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
if (!wagon || wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||||
if (realCutIds.has(wagonId) && builtTrainId) {
|
if (realCutIds.has(wagonId) && builtTrainId) {
|
||||||
@@ -4497,7 +4514,7 @@ export class TrainSchedulingService {
|
|||||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||||
[wagonId, builtTrainId],
|
[wagonId, builtTrainId],
|
||||||
);
|
);
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
adjustmentRows.push(
|
||||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
trainId: builtTrainId,
|
trainId: builtTrainId,
|
||||||
@@ -4519,7 +4536,7 @@ export class TrainSchedulingService {
|
|||||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await manager.getRepository(WagonMovement).save(
|
movementRows.push(
|
||||||
manager.getRepository(WagonMovement).create({
|
manager.getRepository(WagonMovement).create({
|
||||||
wagonId,
|
wagonId,
|
||||||
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId,
|
fromYardId: scheduleYardOf(schedule.plannedWagonYards, wagon) ?? schedule.originStationId,
|
||||||
@@ -4530,6 +4547,12 @@ export class TrainSchedulingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (adjustmentRows.length) {
|
||||||
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(adjustmentRows);
|
||||||
|
}
|
||||||
|
if (movementRows.length) {
|
||||||
|
await manager.getRepository(WagonMovement).save(movementRows);
|
||||||
|
}
|
||||||
// Keep the coupling order gapless after permanent removals.
|
// Keep the coupling order gapless after permanent removals.
|
||||||
if (realCutHappened && builtTrainId) {
|
if (realCutHappened && builtTrainId) {
|
||||||
const remaining = await manager.getRepository(Wagon).find({
|
const remaining = await manager.getRepository(Wagon).find({
|
||||||
@@ -4557,8 +4580,16 @@ export class TrainSchedulingService {
|
|||||||
select: { id: true, sequenceNumber: true },
|
select: { id: true, sequenceNumber: true },
|
||||||
});
|
});
|
||||||
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
let maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||||
|
const coupleWagonById = new Map(
|
||||||
|
(
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.find({ where: { id: In(coupleNow.map(([wagonId]) => wagonId)) } })
|
||||||
|
).map((w) => [w.id, w]),
|
||||||
|
);
|
||||||
|
const coupleLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||||
for (const [wagonId, coupleYardId] of coupleNow) {
|
for (const [wagonId, coupleYardId] of coupleNow) {
|
||||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
const wagon = coupleWagonById.get(wagonId);
|
||||||
if (
|
if (
|
||||||
!wagon ||
|
!wagon ||
|
||||||
wagon.trainId ||
|
wagon.trainId ||
|
||||||
@@ -4575,7 +4606,7 @@ export class TrainSchedulingService {
|
|||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
currentTrainScheduleId: scheduleId,
|
currentTrainScheduleId: scheduleId,
|
||||||
});
|
});
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
coupleLogRows.push(
|
||||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
trainId: builtTrainId,
|
trainId: builtTrainId,
|
||||||
@@ -4588,6 +4619,9 @@ export class TrainSchedulingService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (coupleLogRows.length) {
|
||||||
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(coupleLogRows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await manager
|
await manager
|
||||||
.getRepository(Wagon)
|
.getRepository(Wagon)
|
||||||
@@ -4798,11 +4832,23 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One fetch for every pinned wagon and bulk log/ledger inserts — the
|
||||||
|
// per-wagon UPDATEs stay (patches differ per wagon).
|
||||||
|
const pinnedIds = (schedule.trainSet?.wagons ?? [])
|
||||||
|
.map((slot) => slot.physicalWagonId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
const settleWagonById = new Map(
|
||||||
|
pinnedIds.length
|
||||||
|
? (
|
||||||
|
await manager.getRepository(Wagon).find({ where: { id: In(pinnedIds) } })
|
||||||
|
).map((w) => [w.id, w])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const arrivalLogRows: ScheduleWagonAdjustmentLog[] = [];
|
||||||
|
const arrivalMovementRows: WagonMovement[] = [];
|
||||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||||
if (!slot.physicalWagonId) continue;
|
if (!slot.physicalWagonId) continue;
|
||||||
const wagon = await manager
|
const wagon = settleWagonById.get(slot.physicalWagonId);
|
||||||
.getRepository(Wagon)
|
|
||||||
.findOne({ where: { id: slot.physicalWagonId } });
|
|
||||||
if (!wagon) continue;
|
if (!wagon) continue;
|
||||||
// A wagon that already alighted mid-route (unload released it, possibly
|
// A wagon that already alighted mid-route (unload released it, possibly
|
||||||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||||
@@ -4839,7 +4885,7 @@ export class TrainSchedulingService {
|
|||||||
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
AND train_set_id IN (SELECT id FROM freight.train_sets WHERE train_id = $2)`,
|
||||||
[wagon.id, ownerTrainId],
|
[wagon.id, ownerTrainId],
|
||||||
);
|
);
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
arrivalLogRows.push(
|
||||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
trainId: ownerTrainId,
|
trainId: ownerTrainId,
|
||||||
@@ -4863,7 +4909,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
// Ledger: the wagon rode this schedule to its settle yard.
|
// Ledger: the wagon rode this schedule to its settle yard.
|
||||||
const slotAllocations = slot.allocations ?? [];
|
const slotAllocations = slot.allocations ?? [];
|
||||||
await manager.getRepository(WagonMovement).save(
|
arrivalMovementRows.push(
|
||||||
manager.getRepository(WagonMovement).create({
|
manager.getRepository(WagonMovement).create({
|
||||||
wagonId: wagon.id,
|
wagonId: wagon.id,
|
||||||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||||||
@@ -4884,10 +4930,22 @@ export class TrainSchedulingService {
|
|||||||
// so a still-loose planned couple physically rode along.
|
// so a still-loose planned couple physically rode along.
|
||||||
const arrivalCouplePlan = schedule.plannedWagonCouples ?? {};
|
const arrivalCouplePlan = schedule.plannedWagonCouples ?? {};
|
||||||
const arrivalTrainId = schedule.trainSet?.trainId ?? null;
|
const arrivalTrainId = schedule.trainSet?.trainId ?? null;
|
||||||
for (const [coupleWagonId, coupleYardId] of Object.entries(arrivalCouplePlan)) {
|
const coupleEntries = Object.entries(arrivalCouplePlan);
|
||||||
const wagon = await manager
|
const arrivalCoupleById = new Map(
|
||||||
.getRepository(Wagon)
|
coupleEntries.length
|
||||||
.findOne({ where: { id: coupleWagonId } });
|
? (
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.find({ where: { id: In(coupleEntries.map(([wagonId]) => wagonId)) } })
|
||||||
|
).map((w) => [w.id, w])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
// Join sequence numbers continue after the settled consist; the max is
|
||||||
|
// read once and incremented locally — identical to re-querying after
|
||||||
|
// each join, without one consist scan per wagon.
|
||||||
|
let arrivalMaxSeq: number | null = null;
|
||||||
|
for (const [coupleWagonId, coupleYardId] of coupleEntries) {
|
||||||
|
const wagon = arrivalCoupleById.get(coupleWagonId);
|
||||||
if (!wagon) continue;
|
if (!wagon) continue;
|
||||||
if (wagon.currentTrainScheduleId === scheduleId) {
|
if (wagon.currentTrainScheduleId === scheduleId) {
|
||||||
// Joined during the trip, slot-less: settle at the destination.
|
// Joined during the trip, slot-less: settle at the destination.
|
||||||
@@ -4897,7 +4955,7 @@ export class TrainSchedulingService {
|
|||||||
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
|
||||||
currentYardId: schedule.destinationStationId,
|
currentYardId: schedule.destinationStationId,
|
||||||
});
|
});
|
||||||
await manager.getRepository(WagonMovement).save(
|
arrivalMovementRows.push(
|
||||||
manager.getRepository(WagonMovement).create({
|
manager.getRepository(WagonMovement).create({
|
||||||
wagonId: wagon.id,
|
wagonId: wagon.id,
|
||||||
fromYardId: coupleYardId,
|
fromYardId: coupleYardId,
|
||||||
@@ -4914,18 +4972,21 @@ export class TrainSchedulingService {
|
|||||||
!wagon.currentTrainScheduleId &&
|
!wagon.currentTrainScheduleId &&
|
||||||
wagon.currentYardId === coupleYardId
|
wagon.currentYardId === coupleYardId
|
||||||
) {
|
) {
|
||||||
const consist = await manager.getRepository(Wagon).find({
|
if (arrivalMaxSeq === null) {
|
||||||
where: { trainId: arrivalTrainId },
|
const consist = await manager.getRepository(Wagon).find({
|
||||||
select: { id: true, sequenceNumber: true },
|
where: { trainId: arrivalTrainId },
|
||||||
});
|
select: { id: true, sequenceNumber: true },
|
||||||
const maxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
});
|
||||||
|
arrivalMaxSeq = consist.reduce((m, w) => Math.max(m, w.sequenceNumber ?? 0), 0);
|
||||||
|
}
|
||||||
|
arrivalMaxSeq += 1;
|
||||||
await manager.getRepository(Wagon).update(wagon.id, {
|
await manager.getRepository(Wagon).update(wagon.id, {
|
||||||
trainId: arrivalTrainId,
|
trainId: arrivalTrainId,
|
||||||
sequenceNumber: maxSeq + 1,
|
sequenceNumber: arrivalMaxSeq,
|
||||||
status: WagonStatus.Assigned,
|
status: WagonStatus.Assigned,
|
||||||
currentYardId: schedule.destinationStationId,
|
currentYardId: schedule.destinationStationId,
|
||||||
});
|
});
|
||||||
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
|
arrivalLogRows.push(
|
||||||
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
manager.getRepository(ScheduleWagonAdjustmentLog).create({
|
||||||
trainScheduleId: scheduleId,
|
trainScheduleId: scheduleId,
|
||||||
trainId: arrivalTrainId,
|
trainId: arrivalTrainId,
|
||||||
@@ -4937,7 +4998,7 @@ export class TrainSchedulingService {
|
|||||||
occurredAt: now,
|
occurredAt: now,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await manager.getRepository(WagonMovement).save(
|
arrivalMovementRows.push(
|
||||||
manager.getRepository(WagonMovement).create({
|
manager.getRepository(WagonMovement).create({
|
||||||
wagonId: wagon.id,
|
wagonId: wagon.id,
|
||||||
fromYardId: coupleYardId,
|
fromYardId: coupleYardId,
|
||||||
@@ -4949,6 +5010,12 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (arrivalLogRows.length) {
|
||||||
|
await manager.getRepository(ScheduleWagonAdjustmentLog).save(arrivalLogRows);
|
||||||
|
}
|
||||||
|
if (arrivalMovementRows.length) {
|
||||||
|
await manager.getRepository(WagonMovement).save(arrivalMovementRows);
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||||
const stations = await this.buildScheduleStations(schedule);
|
const stations = await this.buildScheduleStations(schedule);
|
||||||
@@ -5720,6 +5787,39 @@ export class TrainSchedulingService {
|
|||||||
return rows[0]?.train_id ?? null;
|
return rows[0]?.train_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polling heartbeat for the detail page: one row, no joins. Clients compare
|
||||||
|
* this snapshot between polls and refetch the (expensive) full detail only
|
||||||
|
* when it changed — `updatedAt` catches any schedule-row write, the phase
|
||||||
|
* fields drive countdowns directly.
|
||||||
|
*/
|
||||||
|
async getSchedulePhase(scheduleId: string) {
|
||||||
|
const rows: Array<{
|
||||||
|
status: string;
|
||||||
|
bookingWindowStatus: string | null;
|
||||||
|
windowPhase: string | null;
|
||||||
|
windowOpensAt: Date | null;
|
||||||
|
windowClosesAt: Date | null;
|
||||||
|
docReviewEndsAt: Date | null;
|
||||||
|
paymentPhaseEndsAt: Date | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT status,
|
||||||
|
booking_window_status AS "bookingWindowStatus",
|
||||||
|
window_phase AS "windowPhase",
|
||||||
|
window_opens_at AS "windowOpensAt",
|
||||||
|
window_closes_at AS "windowClosesAt",
|
||||||
|
doc_review_ends_at AS "docReviewEndsAt",
|
||||||
|
payment_phase_ends_at AS "paymentPhaseEndsAt",
|
||||||
|
updated_at AS "updatedAt"
|
||||||
|
FROM freight.train_schedules
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
|
/** `{ wagonId: yardId }` this schedule boards each wagon from; `{}` when unset. */
|
||||||
private async plannedWagonYardsOf(
|
private async plannedWagonYardsOf(
|
||||||
scheduleId: string | undefined,
|
scheduleId: string | undefined,
|
||||||
@@ -5760,17 +5860,35 @@ export class TrainSchedulingService {
|
|||||||
return rows[0]?.planned_wagon_couples ?? {};
|
return rows[0]?.planned_wagon_couples ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wagon types are near-static reference data — 60s TTL like the batch service's dims cache. */
|
||||||
|
private wagonTypesCache: { value: WagonType[]; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
private async loadWagonTypesCached(): Promise<WagonType[]> {
|
||||||
|
if (this.wagonTypesCache && this.wagonTypesCache.expiresAt > Date.now()) {
|
||||||
|
return this.wagonTypesCache.value;
|
||||||
|
}
|
||||||
|
const value = await this.dataSource.getRepository(WagonType).find();
|
||||||
|
this.wagonTypesCache = { value, expiresAt: Date.now() + 60_000 };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
private async countFleetAvailability(
|
private async countFleetAvailability(
|
||||||
originYardId: string,
|
originYardId: string,
|
||||||
targetScheduleId?: string,
|
targetScheduleId?: string,
|
||||||
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
|
||||||
const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
const [wagonTypes, builtTrainId, pinnedToTargetIds, plan] = await Promise.all([
|
||||||
this.dataSource.getRepository(Wagon).find(),
|
this.loadWagonTypesCached(),
|
||||||
this.dataSource.getRepository(WagonType).find(),
|
|
||||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||||
this.plannedWagonYardsOf(targetScheduleId),
|
this.plannedWagonYardsOf(targetScheduleId),
|
||||||
]);
|
]);
|
||||||
|
// Only two wagon populations can ever count below: the built train's own
|
||||||
|
// consist, or (train-less schedules) loose wagons — `if (wagon.trainId)
|
||||||
|
// continue` used to drop everything else in JS after loading the whole
|
||||||
|
// national fleet. Same result, fleet-sized query avoided.
|
||||||
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: builtTrainId ? { trainId: builtTrainId } : { trainId: IsNull() },
|
||||||
|
});
|
||||||
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
|
||||||
const counts = new Map<string, { code: string; available: number }>();
|
const counts = new Map<string, { code: string; available: number }>();
|
||||||
|
|
||||||
@@ -5945,9 +6063,16 @@ export class TrainSchedulingService {
|
|||||||
slots: TrainSetWagon[],
|
slots: TrainSetWagon[],
|
||||||
reverseWagonOrder = false,
|
reverseWagonOrder = false,
|
||||||
) {
|
) {
|
||||||
const wagons = await manager.getRepository(Wagon).find();
|
|
||||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
|
||||||
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
|
||||||
|
// pickPhysicalWagonForSlot can only ever pin the built train's own wagons,
|
||||||
|
// couple-planned loose wagons, or (loose-pool schedules) wagons with no
|
||||||
|
// train — its own filters reject everything else, so don't load the fleet.
|
||||||
|
const wagons = await manager.getRepository(Wagon).find({
|
||||||
|
where: builtTrainId
|
||||||
|
? [{ trainId: builtTrainId }, { trainId: IsNull() }]
|
||||||
|
: { trainId: IsNull() },
|
||||||
|
});
|
||||||
|
const wagonTypes = await this.loadWagonTypesCached();
|
||||||
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
manager,
|
manager,
|
||||||
@@ -6034,11 +6159,17 @@ export class TrainSchedulingService {
|
|||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!wagonPlan.length) return [];
|
if (!wagonPlan.length) return [];
|
||||||
|
|
||||||
const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
const [builtTrainId, pinnedToScheduleIds] = await Promise.all([
|
||||||
this.dataSource.getRepository(Wagon).find(),
|
|
||||||
this.builtTrainIdOfSchedule(targetScheduleId),
|
this.builtTrainIdOfSchedule(targetScheduleId),
|
||||||
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
|
||||||
]);
|
]);
|
||||||
|
// Same population argument as autoPinWagonsForSchedule: consist + loose
|
||||||
|
// wagons are the only candidates the pin filters can accept.
|
||||||
|
const wagons = await this.dataSource.getRepository(Wagon).find({
|
||||||
|
where: builtTrainId
|
||||||
|
? [{ trainId: builtTrainId }, { trainId: IsNull() }]
|
||||||
|
: { trainId: IsNull() },
|
||||||
|
});
|
||||||
const targetSchedule = targetScheduleId
|
const targetSchedule = targetScheduleId
|
||||||
? await this.trainSchedulesRepository.findById(targetScheduleId)
|
? await this.trainSchedulesRepository.findById(targetScheduleId)
|
||||||
: null;
|
: null;
|
||||||
@@ -6906,7 +7037,9 @@ export class TrainSchedulingService {
|
|||||||
* (already carrying this schedule's cargo).
|
* (already carrying this schedule's cargo).
|
||||||
*/
|
*/
|
||||||
async getScheduleWagonYards(scheduleId: string) {
|
async getScheduleWagonYards(scheduleId: string) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
// Slim graph: this read needs stops, the built train, and which slots
|
||||||
|
// carry allocations — not the full booking/container branches.
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithConsistLite(scheduleId);
|
||||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
const builtTrain = schedule.trainSet?.train;
|
const builtTrain = schedule.trainSet?.train;
|
||||||
if (!builtTrain) {
|
if (!builtTrain) {
|
||||||
@@ -8227,7 +8360,7 @@ export class TrainSchedulingService {
|
|||||||
* through iam.users; rows survive wagon/train deletion (log tables carry
|
* through iam.users; rows survive wagon/train deletion (log tables carry
|
||||||
* plain columns, no FKs).
|
* plain columns, no FKs).
|
||||||
*/
|
*/
|
||||||
async getScheduleHistory(scheduleId: string) {
|
async getScheduleHistory(scheduleId: string, query: { page?: number; pageSize?: number } = {}) {
|
||||||
type HistoryRow = {
|
type HistoryRow = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'WAGON' | 'BOOKING';
|
kind: 'WAGON' | 'BOOKING';
|
||||||
@@ -8238,105 +8371,83 @@ export class TrainSchedulingService {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
occurredAt: Date;
|
occurredAt: Date;
|
||||||
};
|
};
|
||||||
const wagonRows: HistoryRow[] = (
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||||
await this.dataSource.query(
|
// One UNION ALL over the four event sources, paginated in SQL — the old
|
||||||
`SELECT l.id,
|
// shape capped each source at 200 and merge-sorted up to 800 rows in
|
||||||
l.action,
|
// memory per request. Same rows, same order, same field mapping.
|
||||||
l.wagon_number AS "subject",
|
const historyCte = `
|
||||||
COALESCE(y.label, y.code) AS "yardLabel",
|
SELECT l.id::text AS "id",
|
||||||
COALESCE(u.username, u.email) AS "actor",
|
'WAGON' AS "kind",
|
||||||
l.occurred_at AS "occurredAt"
|
l.action AS "action",
|
||||||
FROM freight.schedule_wagon_adjustment_logs l
|
l.wagon_number AS "subject",
|
||||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
COALESCE(y.label, y.code) AS "yardLabel",
|
||||||
LEFT JOIN iam.users u ON u.id = l.adjusted_by_user_id
|
COALESCE(u.username, u.email) AS "actor",
|
||||||
WHERE l.train_schedule_id = $1
|
NULL::text AS "note",
|
||||||
AND l.deleted_at IS NULL
|
l.occurred_at AS "occurredAt"
|
||||||
ORDER BY l.occurred_at DESC
|
FROM freight.schedule_wagon_adjustment_logs l
|
||||||
LIMIT 200`,
|
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_schedule_id = $1
|
||||||
|
AND l.deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT r.id::text,
|
||||||
|
'BOOKING',
|
||||||
|
'BOOKING_REMOVED',
|
||||||
|
r.booking_reference,
|
||||||
|
NULL,
|
||||||
|
COALESCE(u.username, u.email),
|
||||||
|
r.notes,
|
||||||
|
r.removed_at
|
||||||
|
FROM freight.train_composition_removal_logs r
|
||||||
|
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
||||||
|
WHERE r.schedule_id = $1
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT b.id::text,
|
||||||
|
'BOOKING',
|
||||||
|
'BOOKING_LOADED',
|
||||||
|
b.reference,
|
||||||
|
COALESCE(oy.label, oy.code),
|
||||||
|
COALESCE(u.username, u.email),
|
||||||
|
NULL,
|
||||||
|
b.loaded_at
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.train_schedule_bookings tsb
|
||||||
|
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
|
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
|
||||||
|
WHERE b.loaded_at IS NOT NULL
|
||||||
|
AND b.deleted_at IS NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT b.id::text,
|
||||||
|
'BOOKING',
|
||||||
|
'BOOKING_UNLOADED',
|
||||||
|
b.reference,
|
||||||
|
COALESCE(dy.label, dy.code),
|
||||||
|
COALESCE(u.username, u.email),
|
||||||
|
NULL,
|
||||||
|
b.arrived_at
|
||||||
|
FROM freight.bookings b
|
||||||
|
JOIN freight.train_schedule_bookings tsb
|
||||||
|
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
|
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
|
||||||
|
WHERE b.arrived_at IS NOT NULL
|
||||||
|
AND b.deleted_at IS NULL`;
|
||||||
|
const [countRows, rows]: [Array<{ total: string }>, HistoryRow[]] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT count(*) AS total FROM (${historyCte}) history`,
|
||||||
[scheduleId],
|
[scheduleId],
|
||||||
)
|
),
|
||||||
).map((r: Omit<HistoryRow, 'kind' | 'note'>) => ({
|
this.dataSource.query(
|
||||||
...r,
|
`SELECT * FROM (${historyCte}) history
|
||||||
kind: 'WAGON' as const,
|
ORDER BY "occurredAt" DESC
|
||||||
note: null,
|
LIMIT $2 OFFSET $3`,
|
||||||
}));
|
[scheduleId, take, skip],
|
||||||
const bookingRows: HistoryRow[] = (
|
),
|
||||||
await this.dataSource.query(
|
]);
|
||||||
`SELECT r.id,
|
const total = Number(countRows[0]?.total ?? 0);
|
||||||
r.booking_reference AS "subject",
|
return { items: rows, meta: buildPaginationMeta(total, page, pageSize) };
|
||||||
r.notes AS "note",
|
|
||||||
COALESCE(u.username, u.email) AS "actor",
|
|
||||||
r.removed_at AS "occurredAt"
|
|
||||||
FROM freight.train_composition_removal_logs r
|
|
||||||
LEFT JOIN iam.users u ON u.id = r.removed_by_user_id
|
|
||||||
WHERE r.schedule_id = $1
|
|
||||||
AND r.deleted_at IS NULL
|
|
||||||
ORDER BY r.removed_at DESC
|
|
||||||
LIMIT 200`,
|
|
||||||
[scheduleId],
|
|
||||||
)
|
|
||||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'yardLabel'>) => ({
|
|
||||||
...r,
|
|
||||||
kind: 'BOOKING' as const,
|
|
||||||
action: 'BOOKING_REMOVED',
|
|
||||||
yardLabel: null,
|
|
||||||
}));
|
|
||||||
// Per-booking journey events (load at boarding yard / unload at alighting
|
|
||||||
// yard) — sourced from the booking's own loaded_at/arrived_at stamps, so a
|
|
||||||
// multi-stop train's disjoint legs (a→b loads then unloads at b while a→c
|
|
||||||
// rides through) each show as their own row. Append-only: these columns are
|
|
||||||
// only ever set once per booking, never cleared, so rows never disappear.
|
|
||||||
const journeyRows: HistoryRow[] = (
|
|
||||||
await this.dataSource.query(
|
|
||||||
`SELECT b.id,
|
|
||||||
b.reference AS "subject",
|
|
||||||
COALESCE(oy.label, oy.code) AS "yardLabel",
|
|
||||||
COALESCE(u.username, u.email) AS "actor",
|
|
||||||
b.loaded_at AS "occurredAt"
|
|
||||||
FROM freight.bookings b
|
|
||||||
JOIN freight.train_schedule_bookings tsb
|
|
||||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
|
||||||
LEFT JOIN iam.users u ON u.id = b.loaded_by_user_id
|
|
||||||
WHERE b.loaded_at IS NOT NULL
|
|
||||||
AND b.deleted_at IS NULL
|
|
||||||
ORDER BY b.loaded_at DESC
|
|
||||||
LIMIT 200`,
|
|
||||||
[scheduleId],
|
|
||||||
)
|
|
||||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
|
||||||
...r,
|
|
||||||
kind: 'BOOKING' as const,
|
|
||||||
action: 'BOOKING_LOADED',
|
|
||||||
note: null,
|
|
||||||
}));
|
|
||||||
const unloadRows: HistoryRow[] = (
|
|
||||||
await this.dataSource.query(
|
|
||||||
`SELECT b.id,
|
|
||||||
b.reference AS "subject",
|
|
||||||
COALESCE(dy.label, dy.code) AS "yardLabel",
|
|
||||||
COALESCE(u.username, u.email) AS "actor",
|
|
||||||
b.arrived_at AS "occurredAt"
|
|
||||||
FROM freight.bookings b
|
|
||||||
JOIN freight.train_schedule_bookings tsb
|
|
||||||
ON tsb.booking_id = b.id AND tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
|
||||||
LEFT JOIN iam.users u ON u.id = b.arrived_by_user_id
|
|
||||||
WHERE b.arrived_at IS NOT NULL
|
|
||||||
AND b.deleted_at IS NULL
|
|
||||||
ORDER BY b.arrived_at DESC
|
|
||||||
LIMIT 200`,
|
|
||||||
[scheduleId],
|
|
||||||
)
|
|
||||||
).map((r: Omit<HistoryRow, 'kind' | 'action' | 'note'>) => ({
|
|
||||||
...r,
|
|
||||||
kind: 'BOOKING' as const,
|
|
||||||
action: 'BOOKING_UNLOADED',
|
|
||||||
note: null,
|
|
||||||
}));
|
|
||||||
return [...wagonRows, ...bookingRows, ...journeyRows, ...unloadRows].sort(
|
|
||||||
(a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9752,12 +9863,24 @@ export class TrainSchedulingService {
|
|||||||
* yardId → display label for error messages that name corridor legs. One
|
* yardId → display label for error messages that name corridor legs. One
|
||||||
* query; unknown ids fall back to the raw id so a message never goes blank.
|
* query; unknown ids fall back to the raw id so a message never goes blank.
|
||||||
*/
|
*/
|
||||||
|
private yardLabelsCache: { value: Map<string, string>; expiresAt: number } | null = null;
|
||||||
|
|
||||||
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
|
private async yardLabelMap(yardIds: string[]): Promise<Map<string, string>> {
|
||||||
if (!yardIds.length) return new Map();
|
if (!yardIds.length) return new Map();
|
||||||
const yards = await this.dataSource
|
// Yards are near-static — cache the whole label map for 60s instead of
|
||||||
.getRepository(Yard)
|
// one IN(...) query per detail/board render. A missing id degrades exactly
|
||||||
.find({ where: { id: In(yardIds) } });
|
// as before: the consumer falls back to the raw id.
|
||||||
return new Map(yards.map((y) => [y.id, y.label || y.code || y.id]));
|
if (!this.yardLabelsCache || this.yardLabelsCache.expiresAt <= Date.now()) {
|
||||||
|
const yards = await this.dataSource.getRepository(Yard).find();
|
||||||
|
this.yardLabelsCache = {
|
||||||
|
value: new Map(yards.map((y) => [y.id, y.label || y.code || y.id])),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const all = this.yardLabelsCache.value;
|
||||||
|
return new Map(
|
||||||
|
yardIds.filter((id) => all.has(id)).map((id) => [id, all.get(id) as string]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
/** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Group,
|
Group,
|
||||||
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Timeline,
|
Timeline,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
History,
|
History,
|
||||||
@@ -41,13 +43,18 @@ const ACTION_META: Record<
|
|||||||
* bookings removed from the composition — newest first.
|
* bookings removed from the composition — newest first.
|
||||||
*/
|
*/
|
||||||
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
const historyQuery = useQuery(
|
const historyQuery = useQuery(
|
||||||
api.trainScheduling.scheduleHistory.queryOptions({
|
api.trainScheduling.scheduleHistory.queryOptions({
|
||||||
input: { scheduleId },
|
input: { scheduleId, page, pageSize: 20 },
|
||||||
enabled: Boolean(scheduleId),
|
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 (
|
return (
|
||||||
<Paper radius="xl" p="lg">
|
<Paper radius="xl" p="lg">
|
||||||
@@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin
|
|||||||
})}
|
})}
|
||||||
</Timeline>
|
</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>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,13 +10,15 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
|
Pagination,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
@@ -28,6 +30,39 @@ import { formatDateTime } from "@/lib/format";
|
|||||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||||
|
|
||||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
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.
|
* Review queue for shared-wagon pairings.
|
||||||
@@ -37,6 +72,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
|||||||
* under two separate invoices, so a person signs off on the pairing first.
|
* under two separate invoices, so a person signs off on the pairing first.
|
||||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||||
* GL with the reason.
|
* 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() {
|
export default function ConsolidationApprovalsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -45,16 +85,31 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
kind: "approve" | "reject";
|
kind: "approve" | "reject";
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
const [tab, setTab] = useState<Status>("PENDING");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const {
|
const { data, isLoading, isError, isFetching } = useQuery({
|
||||||
data: rows,
|
queryKey: [...QUEUE_KEY, tab, page],
|
||||||
isLoading,
|
queryFn: () =>
|
||||||
isError,
|
bookingsService.consolidationApprovalQueue({
|
||||||
} = useQuery({
|
status: tab,
|
||||||
queryKey: QUEUE_KEY,
|
page,
|
||||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
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 = () => {
|
const close = () => {
|
||||||
setDecision(null);
|
setDecision(null);
|
||||||
setNote("");
|
setNote("");
|
||||||
@@ -64,7 +119,10 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
if (!decision) throw new Error("No pairing selected");
|
if (!decision) throw new Error("No pairing selected");
|
||||||
return decision.kind === "approve"
|
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());
|
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -73,6 +131,7 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
? "Shared wagon approved — both bookings sent to Operations"
|
? "Shared wagon approved — both bookings sent to Operations"
|
||||||
: "Shared wagon rejected — both bookings returned to GL",
|
: "Shared wagon rejected — both bookings returned to GL",
|
||||||
);
|
);
|
||||||
|
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
|
||||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||||
close();
|
close();
|
||||||
},
|
},
|
||||||
@@ -99,90 +158,190 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
Could not load the approval queue.
|
Could not load the approval queue.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : !rows?.length ? (
|
|
||||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
|
||||||
Nothing waiting for approval.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Tabs
|
||||||
{rows.map((row) => (
|
value={tab}
|
||||||
<Paper
|
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
|
||||||
key={row.id}
|
radius="md"
|
||||||
withBorder
|
>
|
||||||
radius="lg"
|
<Tabs.List mb="md">
|
||||||
p="lg"
|
{TABS.map(({ value, label }) => (
|
||||||
style={{ borderColor: "#E6ECF2" }}
|
<Tabs.Tab
|
||||||
>
|
key={value}
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
value={value}
|
||||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
rightSection={
|
||||||
<Group gap={8} align="center" mb={10}>
|
<Badge
|
||||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
size="sm"
|
||||||
<Link2 size={16} />
|
|
||||||
</ThemeIcon>
|
|
||||||
<Text fw={800} fz={15}>
|
|
||||||
Shared wagon
|
|
||||||
</Text>
|
|
||||||
<Badge color="yellow" variant="light" radius="sm">
|
|
||||||
Awaiting approval
|
|
||||||
</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap="xl" wrap="wrap">
|
|
||||||
<BookingSide
|
|
||||||
id={row.bookingId}
|
|
||||||
reference={row.booking?.reference ?? row.bookingReference}
|
|
||||||
company={row.booking?.company?.name}
|
|
||||||
/>
|
|
||||||
<BookingSide
|
|
||||||
id={row.partnerBookingId}
|
|
||||||
reference={
|
|
||||||
row.partnerBooking?.reference ??
|
|
||||||
row.partnerBookingReference
|
|
||||||
}
|
|
||||||
company={row.partnerBooking?.company?.name}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Group gap={6} mt={12} c="dimmed">
|
|
||||||
<Clock size={13} />
|
|
||||||
<Text fz={12}>
|
|
||||||
Requested {formatDateTime(row.requestedAt)}
|
|
||||||
{row.scheduledDate
|
|
||||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Group gap="sm">
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Check size={15} />}
|
|
||||||
onClick={() => {
|
|
||||||
setDecision({ row, kind: "approve" });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
variant="light"
|
||||||
radius="md"
|
color={STATUS_COLOR[value]}
|
||||||
leftSection={<X size={15} />}
|
radius="sm"
|
||||||
onClick={() => {
|
|
||||||
setDecision({ row, kind: "reject" });
|
|
||||||
setNote("");
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Reject
|
{countOf(value)}
|
||||||
</Button>
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
))}
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
{!shown.length ? (
|
||||||
|
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||||
|
{EMPTY_TEXT[tab]}
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Stack gap="md">
|
||||||
|
{shown.map((row) => (
|
||||||
|
<Paper
|
||||||
|
key={row.id}
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
p="lg"
|
||||||
|
style={{ borderColor: "#E6ECF2" }}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
<Link2 size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={800} fz={15}>
|
||||||
|
Shared wagon
|
||||||
|
</Text>
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
company={row.booking?.company?.name}
|
||||||
|
/>
|
||||||
|
<BookingSide
|
||||||
|
id={row.partnerBookingId}
|
||||||
|
reference={
|
||||||
|
row.partnerBooking?.reference ??
|
||||||
|
row.partnerBookingReference
|
||||||
|
}
|
||||||
|
company={row.partnerBooking?.company?.name}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} mt={12} c="dimmed">
|
||||||
|
<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"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Check size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setDecision({ row, kind: "approve" });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.status === "REJECTED"
|
||||||
|
? "Approve anyway"
|
||||||
|
: "Approve"}
|
||||||
|
</Button>
|
||||||
|
{row.status === "PENDING" && (
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setDecision({ row, kind: "reject" });
|
||||||
|
setNote("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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>
|
</Group>
|
||||||
</Group>
|
)}
|
||||||
</Paper>
|
</Stack>
|
||||||
))}
|
)}
|
||||||
</Stack>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -194,17 +353,21 @@ export default function ConsolidationApprovalsPage() {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
title={
|
title={
|
||||||
<Text fw={800} fz={16}>
|
<Text fw={800} fz={16}>
|
||||||
{decision?.kind === "approve"
|
{decision?.kind !== "approve"
|
||||||
? "Approve this shared wagon?"
|
? "Reject this shared wagon?"
|
||||||
: "Reject this shared wagon?"}
|
: decision.row.status === "REJECTED"
|
||||||
|
? "Approve this rejected shared wagon?"
|
||||||
|
: "Approve this shared wagon?"}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text fz="sm" c="dimmed">
|
<Text fz="sm" c="dimmed">
|
||||||
{decision?.kind === "approve"
|
{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."
|
||||||
: "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>
|
</Text>
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -147,13 +147,36 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
input: { id: scheduleId ?? "" },
|
input: { id: scheduleId ?? "" },
|
||||||
enabled: Boolean(scheduleId),
|
enabled: Boolean(scheduleId),
|
||||||
// Live phase updates come from the booking-window socket (PHASE pushes
|
// 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
|
// invalidate this query). The fast self-heal net is the one-row phase
|
||||||
// the workspace countdown never freezes on an expired 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,
|
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));
|
useBookingWindowSocket(Boolean(scheduleId));
|
||||||
const schedule = detailQuery.data;
|
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 freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||||
const isDjiboutiPort = (value?: string | null) =>
|
const isDjiboutiPort = (value?: string | null) =>
|
||||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||||
@@ -221,7 +244,9 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const eligibleQuery = useQuery(
|
const eligibleQuery = useQuery(
|
||||||
api.trainScheduling.eligibleBookings.queryOptions({
|
api.trainScheduling.eligibleBookings.queryOptions({
|
||||||
input: { filters: eligibleFilters, freightType: eligibleFreightType },
|
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());
|
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||||
@@ -1276,7 +1301,13 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
) : null}
|
) : 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.List mb="md">
|
||||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||||
Workflow
|
Workflow
|
||||||
|
|||||||
@@ -242,7 +242,10 @@ import {
|
|||||||
type UpdateTrainDetailsPayload,
|
type UpdateTrainDetailsPayload,
|
||||||
type UsedTrainNumbers,
|
type UsedTrainNumbers,
|
||||||
} from "./trainBuilder.service";
|
} from "./trainBuilder.service";
|
||||||
import { trainSchedulingService } from "./trainScheduling.service";
|
import {
|
||||||
|
trainSchedulingService,
|
||||||
|
type SchedulePhaseSnapshot,
|
||||||
|
} from "./trainScheduling.service";
|
||||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||||
import {
|
import {
|
||||||
@@ -382,6 +385,14 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id),
|
({ 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<
|
eligibleBookings: endpoint<
|
||||||
{ filters?: TrainScheduleFilters; freightType?: FreightType },
|
{ filters?: TrainScheduleFilters; freightType?: FreightType },
|
||||||
EligibleContainerBookingsResponse
|
EligibleContainerBookingsResponse
|
||||||
@@ -457,15 +468,20 @@ export const api = {
|
|||||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||||
),
|
),
|
||||||
|
|
||||||
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
|
scheduleHistory: endpoint<
|
||||||
|
{ scheduleId: string; page: number; pageSize: number },
|
||||||
|
PaginatedResponse<ScheduleHistoryEntry>
|
||||||
|
>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"schedule-history",
|
"schedule-history",
|
||||||
({ scheduleId }) =>
|
({ scheduleId, page, pageSize }) =>
|
||||||
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
|
trainBuilderService.scheduleHistory(scheduleId, page, pageSize).then((r) => r.data),
|
||||||
({ scheduleId }) => [
|
({ scheduleId, page, pageSize }) => [
|
||||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||||
"history",
|
"history",
|
||||||
scheduleId,
|
scheduleId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export interface ConsolidationApprovalRow {
|
|||||||
requestedBy?: string | null;
|
requestedBy?: string | null;
|
||||||
requestedAt: string;
|
requestedAt: string;
|
||||||
decidedBy?: string | null;
|
decidedBy?: string | null;
|
||||||
|
/** Display name of the approver/rejecter — the id alone means nothing. */
|
||||||
|
decidedByName?: string | null;
|
||||||
|
requestedByName?: string | null;
|
||||||
decidedAt?: string | null;
|
decidedAt?: string | null;
|
||||||
decisionNote?: string | null;
|
decisionNote?: string | null;
|
||||||
scheduledDate?: string | null;
|
scheduledDate?: string | null;
|
||||||
@@ -35,6 +38,21 @@ export interface ConsolidationApprovalRow {
|
|||||||
} | 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 {
|
export interface BookingListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
/** Comma-separated statuses for grouped tabs */
|
/** Comma-separated statuses for grouped tabs */
|
||||||
@@ -164,7 +182,9 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const bookingsService = {
|
export const bookingsService = {
|
||||||
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
getListSummary: async (
|
||||||
|
filter?: BookingListFilter,
|
||||||
|
): Promise<BookingListSummary> => {
|
||||||
const params: Record<string, string | number | boolean | undefined> = {};
|
const params: Record<string, string | number | boolean | undefined> = {};
|
||||||
if (filter) {
|
if (filter) {
|
||||||
if (filter.statuses) params.statuses = filter.statuses;
|
if (filter.statuses) params.statuses = filter.statuses;
|
||||||
@@ -176,14 +196,16 @@ export const bookingsService = {
|
|||||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
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.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
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.isGovernment) params.isGovernment = filter.isGovernment;
|
||||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||||
}
|
}
|
||||||
@@ -203,22 +225,26 @@ export const bookingsService = {
|
|||||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||||
if (filter.sortBy) params.sortBy = filter.sortBy;
|
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||||
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
if (filter.schedulingStatuses)
|
||||||
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
params.schedulingStatuses = filter.schedulingStatuses;
|
||||||
|
if (filter.assignedToSchedule)
|
||||||
|
params.assignedToSchedule = filter.assignedToSchedule;
|
||||||
if (filter.companyId) params.companyId = filter.companyId;
|
if (filter.companyId) params.companyId = filter.companyId;
|
||||||
if (filter.contractId) params.contractId = filter.contractId;
|
if (filter.contractId) params.contractId = filter.contractId;
|
||||||
if (filter.freightType) params.freightType = filter.freightType;
|
if (filter.freightType) params.freightType = filter.freightType;
|
||||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
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.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
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.isGovernment) params.isGovernment = filter.isGovernment;
|
||||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||||
if (filter.customsClearingEnabled)
|
if (filter.customsClearingEnabled)
|
||||||
@@ -330,7 +356,9 @@ export const bookingsService = {
|
|||||||
getConsolidationDetails: async (
|
getConsolidationDetails: async (
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<ConsolidationDetails> => {
|
): 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;
|
return unwrap(response.data) as ConsolidationDetails;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -348,8 +376,7 @@ export const bookingsService = {
|
|||||||
|
|
||||||
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
||||||
|
|
||||||
startTransit: (id: string) =>
|
startTransit: (id: string) => postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||||
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
|
||||||
|
|
||||||
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
||||||
|
|
||||||
@@ -358,10 +385,36 @@ export const bookingsService = {
|
|||||||
|
|
||||||
// ── Shared-wagon approval gate ──────────────────────────────────────────
|
// ── Shared-wagon approval gate ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Pairings awaiting a decision, oldest first. */
|
/**
|
||||||
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
|
* One page of the gate. `status` picks the tab; the counts come back for all
|
||||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
|
* three tabs regardless, so the badges show the whole queue and not the page.
|
||||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
*/
|
||||||
|
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. */
|
/** Decision history for one booking's shared wagon — who, when, and why. */
|
||||||
@@ -411,10 +464,9 @@ export const bookingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
||||||
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
const response = await client.post<
|
||||||
B.BASE,
|
{ booking: BookingDetail } | BookingDetail
|
||||||
payload,
|
>(B.BASE, payload);
|
||||||
);
|
|
||||||
const data = unwrap(response.data) as { booking?: BookingDetail };
|
const data = unwrap(response.data) as { booking?: BookingDetail };
|
||||||
return (data.booking ?? data) as BookingDetail;
|
return (data.booking ?? data) as BookingDetail;
|
||||||
},
|
},
|
||||||
@@ -433,7 +485,10 @@ export const bookingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/** GL asks the customer for additional clearance document(s). */
|
/** 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 });
|
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -446,7 +501,9 @@ export const bookingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Clearance charges (post-finalization customer billing) ──
|
// ── 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`);
|
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||||
},
|
},
|
||||||
@@ -510,7 +567,9 @@ export const bookingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ── Additional charges (ad-hoc finance billing) ──
|
// ── 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`);
|
const response = await client.get(`/bookings/${id}/additional-charges`);
|
||||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
},
|
},
|
||||||
@@ -518,7 +577,13 @@ export const bookingsService = {
|
|||||||
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
|
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
|
||||||
createAdditionalCharge: async (
|
createAdditionalCharge: async (
|
||||||
id: string,
|
id: string,
|
||||||
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
|
payload: {
|
||||||
|
reason: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
action: "draft" | "send";
|
||||||
|
file?: File | null;
|
||||||
|
},
|
||||||
): Promise<Freight.AdditionalCharge[]> => {
|
): Promise<Freight.AdditionalCharge[]> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("reason", payload.reason);
|
form.append("reason", payload.reason);
|
||||||
@@ -526,9 +591,13 @@ export const bookingsService = {
|
|||||||
form.append("currency", payload.currency);
|
form.append("currency", payload.currency);
|
||||||
form.append("action", payload.action);
|
form.append("action", payload.action);
|
||||||
if (payload.file) form.append("file", payload.file);
|
if (payload.file) form.append("file", payload.file);
|
||||||
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
|
const response = await client.post(
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
`/bookings/${id}/additional-charges`,
|
||||||
});
|
form,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
},
|
||||||
|
);
|
||||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -613,12 +682,18 @@ export const bookingsService = {
|
|||||||
currency: string,
|
currency: string,
|
||||||
): Promise<BookingDetail> => {
|
): Promise<BookingDetail> => {
|
||||||
const form = new FormData();
|
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("price", String(price));
|
||||||
form.append("currency", currency);
|
form.append("currency", currency);
|
||||||
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
const response = await client.post(
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
B.CLEARANCE_DRAFT_DECLARATION(id),
|
||||||
});
|
form,
|
||||||
|
{
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
},
|
||||||
|
);
|
||||||
return unwrap(response.data) as BookingDetail;
|
return unwrap(response.data) as BookingDetail;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -460,8 +460,8 @@ export const trainBuilderService = {
|
|||||||
payload,
|
payload,
|
||||||
),
|
),
|
||||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||||
scheduleHistory: (scheduleId: string) =>
|
scheduleHistory: (scheduleId: string, page: number, pageSize: number) =>
|
||||||
apiClient.get<ScheduleHistoryEntry[]>(
|
apiClient.get<PaginatedResponse<ScheduleHistoryEntry>>(
|
||||||
`/train-scheduling/schedules/${scheduleId}/history`,
|
`/train-scheduling/schedules/${scheduleId}/history?page=${page}&pageSize=${pageSize}`,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,12 +51,30 @@ interface BookingReferenceDataResponse {
|
|||||||
yard?: Array<YardOption & { label?: string }>;
|
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) =>
|
const pathsFor = (freightType?: FreightType) =>
|
||||||
freightType === "BULK"
|
freightType === "BULK"
|
||||||
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
|
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
|
||||||
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
|
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
|
||||||
|
|
||||||
export const trainSchedulingService = {
|
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 (
|
getEligibleBookings: async (
|
||||||
filters?: TrainScheduleFilters,
|
filters?: TrainScheduleFilters,
|
||||||
freightType?: FreightType,
|
freightType?: FreightType,
|
||||||
|
|||||||
Reference in New Issue
Block a user