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:
Marshal
2026-08-23 04:49:58 +00:00
parent 8e6fc09aac
commit e2189040fa
15 changed files with 1746 additions and 613 deletions

View File

@@ -242,7 +242,10 @@ import {
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import {
trainSchedulingService,
type SchedulePhaseSnapshot,
} from "./trainScheduling.service";
import { truckTypesService, type TruckType } from "./truck-types.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
import {
@@ -382,6 +385,14 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id),
),
// One-row heartbeat behind the detail page's 60s poll — the giant detail
// payload refetches only when this snapshot changes.
schedulePhase: endpoint<{ id: string }, SchedulePhaseSnapshot>(
"train-scheduling",
"schedule-phase",
({ id }) => trainSchedulingService.getSchedulePhase(id),
),
eligibleBookings: endpoint<
{ filters?: TrainScheduleFilters; freightType?: FreightType },
EligibleContainerBookingsResponse
@@ -457,15 +468,20 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
scheduleHistory: endpoint<
{ scheduleId: string; page: number; pageSize: number },
PaginatedResponse<ScheduleHistoryEntry>
>(
"train-scheduling",
"schedule-history",
({ scheduleId }) =>
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
({ scheduleId }) => [
({ scheduleId, page, pageSize }) =>
trainBuilderService.scheduleHistory(scheduleId, page, pageSize).then((r) => r.data),
({ scheduleId, page, pageSize }) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"history",
scheduleId,
page,
pageSize,
],
),

View File

@@ -18,6 +18,9 @@ export interface ConsolidationApprovalRow {
requestedBy?: string | null;
requestedAt: string;
decidedBy?: string | null;
/** Display name of the approver/rejecter — the id alone means nothing. */
decidedByName?: string | null;
requestedByName?: string | null;
decidedAt?: string | null;
decisionNote?: string | null;
scheduledDate?: string | null;
@@ -35,6 +38,21 @@ export interface ConsolidationApprovalRow {
} | null;
}
/** One page of approval rows plus the whole-queue counts behind the tabs. */
export interface ConsolidationApprovalPage {
items: ConsolidationApprovalRow[];
total: number;
counts: Record<ConsolidationApprovalRow["status"], number>;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface BookingListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs */
@@ -164,7 +182,9 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
}
export const bookingsService = {
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
getListSummary: async (
filter?: BookingListFilter,
): Promise<BookingListSummary> => {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
@@ -176,14 +196,16 @@ export const bookingsService = {
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentCurrency)
params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.destinationYardId)
params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
}
@@ -203,22 +225,26 @@ export const bookingsService = {
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
if (filter.schedulingStatuses)
params.schedulingStatuses = filter.schedulingStatuses;
if (filter.assignedToSchedule)
params.assignedToSchedule = filter.assignedToSchedule;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.contractId) params.contractId = filter.contractId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
if (filter.bookingType) params.bookingType = filter.bookingType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentCurrency)
params.paymentCurrency = filter.paymentCurrency;
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
if (filter.originYardId) params.originYardId = filter.originYardId;
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
if (filter.destinationYardId)
params.destinationYardId = filter.destinationYardId;
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
if (filter.customerKind) params.customerKind = filter.customerKind;
if (filter.customsClearingEnabled)
@@ -330,7 +356,9 @@ export const bookingsService = {
getConsolidationDetails: async (
id: string,
): Promise<ConsolidationDetails> => {
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
const response = await client.get<ConsolidationDetails>(
B.CONSOLIDATION(id),
);
return unwrap(response.data) as ConsolidationDetails;
},
@@ -348,8 +376,7 @@ export const bookingsService = {
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
startTransit: (id: string) =>
postBooking<BookingDetail>(B.START_TRANSIT(id)),
startTransit: (id: string) => postBooking<BookingDetail>(B.START_TRANSIT(id)),
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
@@ -358,10 +385,36 @@ export const bookingsService = {
// ── Shared-wagon approval gate ──────────────────────────────────────────
/** Pairings awaiting a decision, oldest first. */
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
/**
* One page of the gate. `status` picks the tab; the counts come back for all
* three tabs regardless, so the badges show the whole queue and not the page.
*/
consolidationApprovalQueue: async (
params: {
status?: ConsolidationApprovalRow["status"];
page?: number;
pageSize?: number;
} = {},
): Promise<ConsolidationApprovalPage> => {
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE, {
params,
});
const data = unwrap(response.data) as ConsolidationApprovalPage | null;
return (
data ?? {
items: [],
total: 0,
counts: { PENDING: 0, APPROVED: 0, REJECTED: 0 },
meta: {
page: 1,
pageSize: params.pageSize ?? 10,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
}
);
},
/** Decision history for one booking's shared wagon — who, when, and why. */
@@ -411,10 +464,9 @@ export const bookingsService = {
},
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
B.BASE,
payload,
);
const response = await client.post<
{ booking: BookingDetail } | BookingDetail
>(B.BASE, payload);
const data = unwrap(response.data) as { booking?: BookingDetail };
return (data.booking ?? data) as BookingDetail;
},
@@ -433,7 +485,10 @@ export const bookingsService = {
},
/** GL asks the customer for additional clearance document(s). */
requestAdditionalDocuments: async (id: string, note: string): Promise<void> => {
requestAdditionalDocuments: async (
id: string,
note: string,
): Promise<void> => {
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
},
@@ -446,7 +501,9 @@ export const bookingsService = {
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
getClearanceCharges: async (
id: string,
): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);
return unwrap(response.data) as Freight.ClearanceCharge[];
},
@@ -510,7 +567,9 @@ export const bookingsService = {
},
// ── Additional charges (ad-hoc finance billing) ──
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
getAdditionalCharges: async (
id: string,
): Promise<Freight.AdditionalCharge[]> => {
const response = await client.get(`/bookings/${id}/additional-charges`);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
@@ -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. */
createAdditionalCharge: async (
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[]> => {
const form = new FormData();
form.append("reason", payload.reason);
@@ -526,9 +591,13 @@ export const bookingsService = {
form.append("currency", payload.currency);
form.append("action", payload.action);
if (payload.file) form.append("file", payload.file);
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
headers: { "Content-Type": "multipart/form-data" },
});
const response = await client.post(
`/bookings/${id}/additional-charges`,
form,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return unwrap(response.data) as Freight.AdditionalCharge[];
},
@@ -613,12 +682,18 @@ export const bookingsService = {
currency: string,
): Promise<BookingDetail> => {
const form = new FormData();
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
files.forEach((file, index) =>
form.append(`draft_declaration_${index}`, file),
);
form.append("price", String(price));
form.append("currency", currency);
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
const response = await client.post(
B.CLEARANCE_DRAFT_DECLARATION(id),
form,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
return unwrap(response.data) as BookingDetail;
},

View File

@@ -460,8 +460,8 @@ export const trainBuilderService = {
payload,
),
/** Unified wagon/booking change history for the schedule's History tab. */
scheduleHistory: (scheduleId: string) =>
apiClient.get<ScheduleHistoryEntry[]>(
`/train-scheduling/schedules/${scheduleId}/history`,
scheduleHistory: (scheduleId: string, page: number, pageSize: number) =>
apiClient.get<PaginatedResponse<ScheduleHistoryEntry>>(
`/train-scheduling/schedules/${scheduleId}/history?page=${page}&pageSize=${pageSize}`,
),
};

View File

@@ -51,12 +51,30 @@ interface BookingReferenceDataResponse {
yard?: Array<YardOption & { label?: string }>;
}
/** Lightweight polling snapshot — refetch the full detail only when this changes. */
export interface SchedulePhaseSnapshot {
status: string;
bookingWindowStatus: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
updatedAt: string;
}
const pathsFor = (freightType?: FreightType) =>
freightType === "BULK"
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
export const trainSchedulingService = {
getSchedulePhase: async (id: string): Promise<SchedulePhaseSnapshot> => {
const response = await client.get<SchedulePhaseSnapshot>(
`/train-scheduling/schedules/${id}/phase`,
);
return unwrap(response.data);
},
getEligibleBookings: async (
filters?: TrainScheduleFilters,
freightType?: FreightType,