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:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -18,6 +19,7 @@ import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repo
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
import { YardScopeService } from "../rule-engine/services/yard-scope.service";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
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. */
|
||||
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.
|
||||
*
|
||||
@@ -53,6 +61,7 @@ export class ConsolidationApprovalService {
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly yardScope: YardScopeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -116,8 +125,16 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
note?: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const approval = await this.loadPending(approvalId);
|
||||
// A pairing that was rejected can still be approved later — the reviewer
|
||||
// changed their mind, or GL argued the case. Only an already-approved one
|
||||
// is final, since both halves have moved on to Operations by then.
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -125,6 +142,10 @@ export class ConsolidationApprovalService {
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
[
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
],
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
@@ -162,13 +183,17 @@ export class ConsolidationApprovalService {
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
user?: unknown,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
"A reason is required to reject a consolidation.",
|
||||
);
|
||||
}
|
||||
const approval = await this.loadPending(approvalId);
|
||||
const approval = await this.loadDecidable(approvalId, [
|
||||
ConsolidationApprovalStatus.Pending,
|
||||
]);
|
||||
await this.assertInScope(approval, user);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
@@ -212,9 +237,88 @@ export class ConsolidationApprovalService {
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
/**
|
||||
* One page of the review queue, or of its history: pending pairings first,
|
||||
* then the decided ones, each carrying the display name of whoever requested
|
||||
* and whoever decided it — the stored ids tell a reviewer nothing.
|
||||
*
|
||||
* `user` narrows the whole thing to the caller's yards: a Mojo desk sees the
|
||||
* pairings that start or end at Mojo, a desk mapped to Mojo AND Adama sees
|
||||
* both yards' pairings. The counts behind the tabs are narrowed the same way,
|
||||
* so a badge never promises rows the caller cannot open.
|
||||
*/
|
||||
async queue(options?: {
|
||||
status?: ConsolidationApprovalStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** The `/auth/me` caller. Omit only for internal, unscoped reads. */
|
||||
user?: unknown;
|
||||
}): Promise<{
|
||||
items: ConsolidationApprovalView[];
|
||||
total: number;
|
||||
/** Counts per status within the caller's scope — the tab badges. */
|
||||
counts: Record<ConsolidationApprovalStatus, number>;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}> {
|
||||
const page = Math.max(1, options?.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, options?.pageSize ?? 10));
|
||||
const yardIds = await this.scopedYardIds(options?.user);
|
||||
|
||||
const { items: rows, total } = await this.approvals.findQueuePage({
|
||||
status: options?.status,
|
||||
yardIds,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
const counts = await this.approvals.countByStatus(yardIds);
|
||||
const names = await this.bookingsRepository.resolveStaffNames(
|
||||
rows.flatMap((r) => [r.requestedBy, r.decidedBy]),
|
||||
);
|
||||
const 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. */
|
||||
@@ -227,12 +331,46 @@ export class ConsolidationApprovalService {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
/**
|
||||
* Refuse a decision on a pairing outside the caller's yards.
|
||||
*
|
||||
* Hiding the row from the list is not enough on its own: the id is guessable
|
||||
* from a shared link, and deciding a pairing moves two other yards' bookings.
|
||||
* Same rule as the list — either half's origin or destination is enough.
|
||||
*/
|
||||
private async assertInScope(
|
||||
approval: ConsolidationApproval,
|
||||
user: unknown,
|
||||
): Promise<void> {
|
||||
const yardIds = await this.scopedYardIds(user);
|
||||
if (!yardIds) return;
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
const touches = (b: Booking | null | undefined) =>
|
||||
!!b &&
|
||||
(yardIds.includes(b.originYardId) ||
|
||||
yardIds.includes(b.destinationYardId));
|
||||
|
||||
if (!touches(booking) && !touches(partner)) {
|
||||
throw new ForbiddenException(
|
||||
"This shared wagon is outside your assigned yards.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Load a row and refuse it unless it is in one of the decidable states. */
|
||||
private async loadDecidable(
|
||||
approvalId: string,
|
||||
allowed: ConsolidationApprovalStatus[],
|
||||
): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
if (!allowed.includes(approval.status)) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user