mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
feat: implement consolidation approval process for shared-wagon bookings
- Add migration for consolidation approvals table and status enum - Create ConsolidationApprovalService to handle approval logic - Implement repository for managing consolidation approvals - Add entity for consolidation approval with necessary fields - Develop frontend components for displaying and managing consolidation approvals - Create tests for consolidation approval service to ensure correct behavior
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Approval gate for consolidated (shared-wagon) bookings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A CONSOLIDATED booking does not: it shares one physical
|
||||
* wagon with another customer's booking, which means two customers' cargo, two
|
||||
* invoices and two liabilities riding the same wagon. That pairing is a
|
||||
* commercial decision, so it is reviewed by a person before Operations sees it.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves (booking_id +
|
||||
* partner_booking_id) so an approver can never approve one side of a shared
|
||||
* wagon and leave the other pending. Rows are never deleted; decided rows are
|
||||
* the audit trail of who approved which pairing and when.
|
||||
*
|
||||
* One PENDING row per booking at a time (partial unique index on each side of
|
||||
* the pair): a second request while one is undecided is a coordination failure,
|
||||
* not a workflow.
|
||||
*/
|
||||
export class ConsolidationApprovals3570000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE freight.consolidation_approvals_status_enum
|
||||
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.consolidation_approvals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
partner_booking_id uuid NOT NULL REFERENCES freight.bookings (id),
|
||||
status freight.consolidation_approvals_status_enum NOT NULL DEFAULT 'PENDING',
|
||||
-- Who put the pairing up for review (the GL user who completed it) and
|
||||
-- who decided it. Both are recorded: the point of the gate is that they
|
||||
-- are different people.
|
||||
requested_by uuid,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_by uuid,
|
||||
decided_at timestamptz,
|
||||
decision_note varchar(500),
|
||||
-- Snapshot of what was approved, so the audit trail still reads
|
||||
-- correctly after the bookings themselves move on.
|
||||
scheduled_date timestamptz,
|
||||
booking_reference varchar(50),
|
||||
partner_booking_reference varchar(50),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_booking_status
|
||||
ON freight.consolidation_approvals (booking_id, status)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_consolidation_approvals_status
|
||||
ON freight.consolidation_approvals (status)
|
||||
`);
|
||||
|
||||
// The workflow invariant, enforced where it cannot race: at most one
|
||||
// undecided request per booking — on EITHER side of the pair, so the same
|
||||
// wagon can never collect two pending requests from its two halves.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending
|
||||
ON freight.consolidation_approvals (booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_consolidation_approvals_one_pending_partner
|
||||
ON freight.consolidation_approvals (partner_booking_id)
|
||||
WHERE status = 'PENDING' AND deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS freight.consolidation_approvals`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP TYPE IF EXISTS freight.consolidation_approvals_status_enum`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -463,6 +463,36 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shared-wagon pairing is waiting for a human decision. Two customers' cargo
|
||||
* on one wagon is a commercial call, so this never auto-advances.
|
||||
*/
|
||||
consolidationApprovalRequestedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon needs approval',
|
||||
`Booking ${this.ref(b)} shares a wagon with ${partnerReference} — approve the consolidation before it reaches Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was approved; both halves move on to Operations together. */
|
||||
consolidationApprovedToStaff(b: Booking, partnerReference: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon approved',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was approved — both bookings are now with Operations.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The pairing was rejected; both halves go back to GL for changes. */
|
||||
consolidationRejectedToStaff(b: Booking, partnerReference: string, reason: string): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Shared wagon rejected',
|
||||
`The shared wagon for ${this.ref(b)} and ${partnerReference} was rejected: ${reason}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer uploaded clearance documents — review is next. */
|
||||
clearanceDocsUploadedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
|
||||
@@ -50,6 +50,7 @@ import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { scopedDirections } from '../user-trade-access/trade-scope.util';
|
||||
import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
|
||||
@@ -58,8 +59,10 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
ApproveConsolidationDto,
|
||||
CancelBookingDto,
|
||||
PairedDecisionDto,
|
||||
RejectConsolidationDto,
|
||||
RejectBookingDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
@@ -166,6 +169,7 @@ export class BookingsController {
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -1542,6 +1546,67 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── Shared-wagon (consolidation) approval gate ────────────────────────────
|
||||
// A consolidated pair is held here, not in the operations queue: two
|
||||
// customers' cargo on one wagon is a commercial call, so a person signs off
|
||||
// on the pairing before Operations sees either half.
|
||||
|
||||
@Get("consolidation-approvals/queue")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Shared-wagon pairings awaiting approval, oldest first. Each row covers BOTH bookings on the wagon.",
|
||||
})
|
||||
consolidationApprovalQueue() {
|
||||
return this.consolidationApprovalService.queue();
|
||||
}
|
||||
|
||||
@Get(":id/consolidation-approvals")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approval history for this booking's shared wagon — who decided what, when, and why.",
|
||||
})
|
||||
consolidationApprovalHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.consolidationApprovalService.historyForBooking(id);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/approve")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Approve a shared wagon: both bookings leave the gate and continue to Operations together.",
|
||||
})
|
||||
approveConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: ApproveConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.approve(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("consolidation-approvals/:approvalId/reject")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.approveConsolidation)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reject a shared wagon: both bookings go back to GL for changes with the reason.",
|
||||
})
|
||||
rejectConsolidation(
|
||||
@Param("approvalId", ParseUUIDPipe) approvalId: string,
|
||||
@Body() dto: RejectConsolidationDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.consolidationApprovalService.reject(
|
||||
approvalId,
|
||||
resolveAuthUserId(user) ?? "",
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(":id/paired-decision")
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -30,6 +30,9 @@ import { BookingsController } from './bookings.controller';
|
||||
// import { PayController } from './pay.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { ConsolidationApprovalService } from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalsRepository } from './consolidation-approvals.repository';
|
||||
import { ConsolidationApproval } from './entities/consolidation-approval.entity';
|
||||
import { ContainerValidationService } from './container-validation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -72,6 +75,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
ConsolidationApproval,
|
||||
]),
|
||||
BillingModule,
|
||||
DocumentsModule,
|
||||
@@ -98,6 +102,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
ContainerValidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
@@ -126,6 +132,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
ConsolidationService,
|
||||
ConsolidationApprovalService,
|
||||
ConsolidationApprovalsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
ConsolidationApprovalService,
|
||||
CONSOLIDATION_APPROVAL_PENDING,
|
||||
} from './consolidation-approval.service';
|
||||
import { ConsolidationApprovalStatus } from './entities/consolidation-approval.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate. Two customers' cargo on one wagon is a
|
||||
* commercial call, so the pair is held for a human decision instead of going
|
||||
* straight to Operations.
|
||||
*
|
||||
* The invariants that matter: both halves are held and released TOGETHER (a
|
||||
* decision on one side of a shared wagon is meaningless without the other), the
|
||||
* requester cannot approve their own pairing, and a decided pairing cannot be
|
||||
* decided twice.
|
||||
*/
|
||||
describe('ConsolidationApprovalService', () => {
|
||||
const PENDING = {
|
||||
id: 'ap-1',
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedBy: 'gl-user',
|
||||
};
|
||||
|
||||
function makeService(overrides: {
|
||||
approvals?: Partial<Record<string, jest.Mock>>;
|
||||
bookingsRepository?: Partial<Record<string, jest.Mock>>;
|
||||
} = {}) {
|
||||
const approvals = {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(null),
|
||||
findById: jest.fn().mockResolvedValue(PENDING),
|
||||
create: jest.fn().mockResolvedValue({ id: 'ap-1' }),
|
||||
decide: jest.fn().mockResolvedValue(true),
|
||||
findQueue: jest.fn().mockResolvedValue([]),
|
||||
findAllForBooking: jest.fn().mockResolvedValue([]),
|
||||
...overrides.approvals,
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides.bookingsRepository,
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn(async (id: string) =>
|
||||
({ id, reference: `BK-${id}` }) as Booking,
|
||||
),
|
||||
};
|
||||
const notifier = {
|
||||
consolidationApprovalRequestedToStaff: jest.fn(),
|
||||
consolidationApprovedToStaff: jest.fn(),
|
||||
consolidationRejectedToStaff: jest.fn(),
|
||||
operationRequestedToStaff: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
|
||||
};
|
||||
|
||||
const service = new ConsolidationApprovalService(
|
||||
approvals as never,
|
||||
bookingsRepository as never,
|
||||
bookingsService as never,
|
||||
notifier as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, approvals, bookingsRepository, notifier };
|
||||
}
|
||||
|
||||
it('holds BOTH halves at the gate when a pairing is created', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(approvals.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookingId: 'b-1',
|
||||
partnerBookingId: 'b-2',
|
||||
requestedBy: 'gl-user',
|
||||
}),
|
||||
);
|
||||
// Neither half may sit in the operations queue while the wagon is unreviewed.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
});
|
||||
expect(
|
||||
notifier.consolidationApprovalRequestedToStaff,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not open a second review for a pairing already pending', async () => {
|
||||
const { service, approvals } = makeService({
|
||||
approvals: {
|
||||
findPendingForBooking: jest.fn().mockResolvedValue(PENDING),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.requestApproval('b-1', 'b-2', 'gl-user');
|
||||
|
||||
expect(result).toBe(PENDING);
|
||||
expect(approvals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases BOTH halves to Operations on approval, logging who decided', async () => {
|
||||
const { service, approvals, bookingsRepository, notifier } = makeService();
|
||||
|
||||
await service.approve('ap-1', 'approver-1', 'looks fine');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
'approver-1',
|
||||
'looks fine',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
});
|
||||
// Operations only learns about the pair now — the gate is what kept it out.
|
||||
expect(notifier.operationRequestedToStaff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('sends BOTH halves back to GL on rejection, with the reason on each', async () => {
|
||||
const { service, approvals, bookingsRepository } = makeService();
|
||||
|
||||
await service.reject('ap-1', 'approver-1', 'partner cargo is wrong');
|
||||
|
||||
expect(approvals.decide).toHaveBeenCalledWith(
|
||||
'ap-1',
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
'approver-1',
|
||||
'partner cargo is wrong',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
|
||||
'b-2',
|
||||
'partner cargo is wrong',
|
||||
'CHANGES_REQUESTED',
|
||||
);
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith('b-2', {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to let the requester approve their own pairing', async () => {
|
||||
const { service, bookingsRepository } = makeService();
|
||||
|
||||
await expect(
|
||||
service.approve('ap-1', 'gl-user'),
|
||||
).rejects.toThrow(/must be approved by someone else/i);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires a reason to reject', async () => {
|
||||
const { service, approvals } = makeService();
|
||||
|
||||
await expect(service.reject('ap-1', 'approver-1', ' ')).rejects.toThrow(
|
||||
/reason is required/i,
|
||||
);
|
||||
expect(approvals.decide).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a pairing that was already decided', async () => {
|
||||
const { service, bookingsRepository } = makeService({
|
||||
approvals: {
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
...PENDING,
|
||||
status: ConsolidationApprovalStatus.Approved,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already approved/i,
|
||||
);
|
||||
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loses cleanly when another approver decides the same pairing first', async () => {
|
||||
// decide() writes only against a still-PENDING row, so the loser of the race
|
||||
// affects nothing and must not move the bookings.
|
||||
const { service } = makeService({
|
||||
approvals: { decide: jest.fn().mockResolvedValue(false) },
|
||||
});
|
||||
|
||||
await expect(service.approve('ap-1', 'approver-1')).rejects.toThrow(
|
||||
/already decided by someone else/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
import { ConsolidationApprovalsRepository } from "./consolidation-approvals.repository";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingLifecycleNotifierService } from "./booking-lifecycle-notifier.service";
|
||||
|
||||
/** Where a rejected pair goes back to, so GL can fix and resubmit. */
|
||||
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";
|
||||
|
||||
/**
|
||||
* The shared-wagon approval gate.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon under two separate invoices, so a person reviews the pairing
|
||||
* before Operations sees either half.
|
||||
*
|
||||
* Both halves are held and released TOGETHER — the wagon is shared, so a
|
||||
* decision on one is meaningless without the other. Every request is kept,
|
||||
* decided or not: the table is the audit trail of who approved which pairing,
|
||||
* when, and why.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalService {
|
||||
private readonly logger = new Logger(ConsolidationApprovalService.name);
|
||||
|
||||
constructor(
|
||||
private readonly approvals: ConsolidationApprovalsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Park a newly consolidated pair for review instead of letting it continue to
|
||||
* Operations. Called from the completion path once the two halves are linked.
|
||||
*
|
||||
* Idempotent: a pair that already has an undecided request is left alone, so a
|
||||
* retried completion cannot open a second review of the same wagon.
|
||||
*/
|
||||
async requestApproval(
|
||||
bookingId: string,
|
||||
partnerBookingId: string,
|
||||
requestedBy: string | null,
|
||||
): Promise<ConsolidationApproval> {
|
||||
const existing = await this.approvals.findPendingForBooking(bookingId);
|
||||
if (existing) return existing;
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const partner = await this.bookingsService.findById(partnerBookingId);
|
||||
if (!booking || !partner) {
|
||||
throw new NotFoundException("Both bookings of the pair must exist.");
|
||||
}
|
||||
|
||||
const approval = await this.approvals.create({
|
||||
bookingId,
|
||||
partnerBookingId,
|
||||
requestedBy,
|
||||
scheduledDate: booking.scheduledDate ?? null,
|
||||
bookingReference: booking.reference ?? null,
|
||||
partnerBookingReference: partner.reference ?? null,
|
||||
});
|
||||
|
||||
// Hold BOTH halves: the wagon is shared, so neither may advance alone.
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(partnerBookingId, {
|
||||
status: CONSOLIDATION_APPROVAL_PENDING,
|
||||
} as never);
|
||||
|
||||
this.notifier.consolidationApprovalRequestedToStaff(
|
||||
booking,
|
||||
partner.reference ?? partnerBookingId,
|
||||
);
|
||||
this.logger.log(
|
||||
`Consolidation ${booking.reference} + ${partner.reference} awaiting approval (${approval.id}).`,
|
||||
);
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the pairing: both halves leave the gate and continue to Operations,
|
||||
* which is exactly where a non-consolidated booking would already be.
|
||||
*
|
||||
* All-or-nothing — the two status writes and the decision record share one
|
||||
* transaction, so the audit trail can never claim an approval that did not
|
||||
* take effect.
|
||||
*/
|
||||
async approve(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
note?: string,
|
||||
): Promise<{ booking: Booking; partner: Booking }> {
|
||||
const approval = await this.loadPending(approvalId);
|
||||
this.assertDifferentPerson(approval, decidedBy);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Approved,
|
||||
decidedBy,
|
||||
note,
|
||||
);
|
||||
// Lost the race to another approver deciding the same pairing.
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationApprovedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
);
|
||||
// Operations only now learns about the pair — the gate is what kept it out.
|
||||
this.notifier.operationRequestedToStaff(booking);
|
||||
this.notifier.operationRequestedToStaff(partner);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the pairing: both halves go back to GL as OPERATION_CHANGES_REQUESTED
|
||||
* with the reason, so the cargo or the partner can be changed and resubmitted.
|
||||
*/
|
||||
async reject(
|
||||
approvalId: string,
|
||||
decidedBy: string,
|
||||
reason: string,
|
||||
): 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);
|
||||
this.assertDifferentPerson(approval, decidedBy);
|
||||
|
||||
await this.dataSource.transaction(async () => {
|
||||
const claimed = await this.approvals.decide(
|
||||
approval.id,
|
||||
ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy,
|
||||
reason.trim(),
|
||||
);
|
||||
if (!claimed) {
|
||||
throw new ConflictException(
|
||||
"This consolidation was already decided by someone else.",
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.bookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
await this.bookingsRepository.update(approval.bookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
await this.bookingsRepository.update(approval.partnerBookingId, {
|
||||
status: REJECTED_STATUS,
|
||||
} as never);
|
||||
});
|
||||
|
||||
const booking = await this.bookingsService.findById(approval.bookingId);
|
||||
const partner = await this.bookingsService.findById(
|
||||
approval.partnerBookingId,
|
||||
);
|
||||
this.notifier.consolidationRejectedToStaff(
|
||||
booking,
|
||||
partner.reference ?? approval.partnerBookingId,
|
||||
reason.trim(),
|
||||
);
|
||||
return { booking, partner };
|
||||
}
|
||||
|
||||
/** Pending pairings awaiting a decision, oldest first. */
|
||||
queue(): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findQueue();
|
||||
}
|
||||
|
||||
/** Full decision history for one booking — who decided what, and when. */
|
||||
historyForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.approvals.findAllForBooking(bookingId);
|
||||
}
|
||||
|
||||
/** The undecided request covering this booking, if any. */
|
||||
pendingForBooking(bookingId: string): Promise<ConsolidationApproval | null> {
|
||||
return this.approvals.findPendingForBooking(bookingId);
|
||||
}
|
||||
|
||||
private async loadPending(approvalId: string): Promise<ConsolidationApproval> {
|
||||
const approval = await this.approvals.findById(approvalId);
|
||||
if (!approval) {
|
||||
throw new NotFoundException(`Approval ${approvalId} not found`);
|
||||
}
|
||||
if (approval.status !== ConsolidationApprovalStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This consolidation was already ${approval.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker–checker: the point of the gate is a second pair of eyes, so the GL
|
||||
* user who created the pairing cannot also approve it.
|
||||
*/
|
||||
private assertDifferentPerson(
|
||||
approval: ConsolidationApproval,
|
||||
decidedBy: string,
|
||||
): void {
|
||||
if (approval.requestedBy && approval.requestedBy === decidedBy) {
|
||||
throw new BadRequestException(
|
||||
"You created this consolidation — it must be approved by someone else.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { DataSource, In, Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
ConsolidationApproval,
|
||||
ConsolidationApprovalStatus,
|
||||
} from "./entities/consolidation-approval.entity";
|
||||
|
||||
/**
|
||||
* Persistence for the shared-wagon approval gate. Rows are never deleted —
|
||||
* decided rows are the audit trail of who approved which pairing and when.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ConsolidationApprovalsRepository {
|
||||
private readonly repository: Repository<ConsolidationApproval>;
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {
|
||||
this.repository = this.dataSource.getRepository(ConsolidationApproval);
|
||||
}
|
||||
|
||||
/**
|
||||
* The undecided request covering `bookingId`, from EITHER side of the pair —
|
||||
* one row governs both halves, and the caller may hold either one.
|
||||
*/
|
||||
findPendingForBooking(
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({
|
||||
where: [
|
||||
{ bookingId, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: bookingId,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** Every request touching this booking, newest first (the audit trail). */
|
||||
findAllForBooking(bookingId: string): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: [{ bookingId }, { partnerBookingId: bookingId }],
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
findById(id: string): Promise<ConsolidationApproval | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/** Pending requests for the review queue, oldest first (FIFO). */
|
||||
findQueue(): Promise<ConsolidationApproval[]> {
|
||||
return this.repository.find({
|
||||
where: { status: ConsolidationApprovalStatus.Pending },
|
||||
relations: {
|
||||
booking: { company: true },
|
||||
partnerBooking: { company: true },
|
||||
},
|
||||
order: { requestedAt: "ASC" },
|
||||
});
|
||||
}
|
||||
|
||||
create(input: {
|
||||
bookingId: string;
|
||||
partnerBookingId: string;
|
||||
requestedBy?: string | null;
|
||||
scheduledDate?: Date | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
}): Promise<ConsolidationApproval> {
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
...input,
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
requestedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the decision. Written only against a row still PENDING, so two
|
||||
* approvers racing on the same pairing cannot both succeed — the second
|
||||
* update matches nothing and the caller sees `false`.
|
||||
*/
|
||||
async decide(
|
||||
id: string,
|
||||
status:
|
||||
| ConsolidationApprovalStatus.Approved
|
||||
| ConsolidationApprovalStatus.Rejected,
|
||||
decidedBy: string | null,
|
||||
decisionNote?: string | null,
|
||||
): Promise<boolean> {
|
||||
const result = await this.repository.update(
|
||||
{ id, status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
status,
|
||||
decidedBy,
|
||||
decidedAt: new Date(),
|
||||
decisionNote: decisionNote ?? null,
|
||||
},
|
||||
);
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Undecided requests covering any of these bookings (list badging). */
|
||||
findPendingForBookings(
|
||||
bookingIds: string[],
|
||||
): Promise<ConsolidationApproval[]> {
|
||||
if (bookingIds.length === 0) return Promise.resolve([]);
|
||||
return this.repository.find({
|
||||
where: [
|
||||
{ bookingId: In(bookingIds), status: ConsolidationApprovalStatus.Pending },
|
||||
{
|
||||
partnerBookingId: In(bookingIds),
|
||||
status: ConsolidationApprovalStatus.Pending,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -158,3 +158,26 @@ export class PairedDecisionDto {
|
||||
@Min(1)
|
||||
validityDays?: number;
|
||||
}
|
||||
|
||||
/** Approve a shared-wagon pairing. The note is optional context for the audit. */
|
||||
export class ApproveConsolidationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: "Optional note recorded with the approval.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Reject a shared-wagon pairing. A reason is mandatory — GL has to act on it. */
|
||||
export class RejectConsolidationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the pairing is rejected. Sent back to GL on both bookings.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ export const BOOKING_STATUSES = [
|
||||
// the booking enters the batch holding pool.
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Shared-wagon review gate: a consolidated pair waits for a human decision
|
||||
// before either half reaches Operations. Two customers' cargo on one wagon is
|
||||
// a commercial call, so it is never auto-advanced.
|
||||
'CONSOLIDATION_APPROVAL_PENDING',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
import { Booking } from "./booking.entity";
|
||||
|
||||
export enum ConsolidationApprovalStatus {
|
||||
Pending = "PENDING",
|
||||
Approved = "APPROVED",
|
||||
Rejected = "REJECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* Approval gate for a consolidated (shared-wagon) booking pair.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight from GL completion to the
|
||||
* operations queue. A consolidated one does not: two customers' cargo rides one
|
||||
* physical wagon, under two separate invoices and two separate liabilities. That
|
||||
* pairing is a commercial decision, so a person reviews it before Operations
|
||||
* sees either half.
|
||||
*
|
||||
* The pair is approved as a UNIT — one row covers both halves — so nobody can
|
||||
* approve one side of a shared wagon and leave the other pending. Rows are never
|
||||
* deleted: decided rows are the audit trail of who approved which pairing, when,
|
||||
* and why.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "consolidation_approvals" })
|
||||
@Index(["bookingId", "status"])
|
||||
@Index(["status"])
|
||||
export class ConsolidationApproval extends BaseEntity {
|
||||
@Column({ name: "booking_id", type: "uuid" })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "booking_id" })
|
||||
booking?: Booking;
|
||||
|
||||
/** The other half of the shared wagon. */
|
||||
@Column({ name: "partner_booking_id", type: "uuid" })
|
||||
partnerBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: "partner_booking_id" })
|
||||
partnerBooking?: Booking;
|
||||
|
||||
@Column({
|
||||
name: "status",
|
||||
type: "enum",
|
||||
enum: ConsolidationApprovalStatus,
|
||||
default: ConsolidationApprovalStatus.Pending,
|
||||
})
|
||||
status!: ConsolidationApprovalStatus;
|
||||
|
||||
/** IAM user id of the GL staff whose completion created the pairing. */
|
||||
@Column({ name: "requested_by", type: "uuid", nullable: true })
|
||||
requestedBy?: string | null;
|
||||
|
||||
@Column({ name: "requested_at", type: "timestamptz", default: () => "now()" })
|
||||
requestedAt!: Date;
|
||||
|
||||
/** IAM user id of the approver; null while pending. */
|
||||
@Column({ name: "decided_by", type: "uuid", nullable: true })
|
||||
decidedBy?: string | null;
|
||||
|
||||
@Column({ name: "decided_at", type: "timestamptz", nullable: true })
|
||||
decidedAt?: Date | null;
|
||||
|
||||
/** Why it was approved or rejected. Required on reject, optional on approve. */
|
||||
@Column({
|
||||
name: "decision_note",
|
||||
type: "varchar",
|
||||
length: 500,
|
||||
nullable: true,
|
||||
})
|
||||
decisionNote?: string | null;
|
||||
|
||||
// ── Snapshot ──────────────────────────────────────────────────────────────
|
||||
// Copied at request time so the audit trail still reads correctly after the
|
||||
// bookings themselves move on (rebooked to another day, cancelled, renamed).
|
||||
|
||||
@Column({ name: "scheduled_date", type: "timestamptz", nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
@Column({
|
||||
name: "booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
bookingReference?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "partner_booking_reference",
|
||||
type: "varchar",
|
||||
length: 50,
|
||||
nullable: true,
|
||||
})
|
||||
partnerBookingReference?: string | null;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
@@ -156,6 +157,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
// The pairing is parked for approval rather than going straight to
|
||||
// Operations; the gate itself is covered by its own spec.
|
||||
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
|
||||
);
|
||||
return { service, bookingsRepository, dataSource };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
|
||||
trainSchedulingService as never,
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return { service, bookingsRepository, invoiceService };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
@@ -130,6 +131,8 @@ export class ContractBookingService {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingTransitionService))
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
@Inject(forwardRef(() => ConsolidationApprovalService))
|
||||
private readonly consolidationApprovalService: ConsolidationApprovalService,
|
||||
) {}
|
||||
|
||||
async createUnderContract(
|
||||
@@ -674,6 +677,8 @@ export class ContractBookingService {
|
||||
bookingId: string,
|
||||
dto: CompleteConsolidatedPairDto,
|
||||
actorPermissions?: unknown,
|
||||
/** IAM id of the GL user creating the pairing — recorded on the approval. */
|
||||
actorUserId?: string | null,
|
||||
): Promise<{
|
||||
booking: Booking;
|
||||
partner: Booking;
|
||||
@@ -736,6 +741,16 @@ export class ContractBookingService {
|
||||
return { ownId: own.booking.id, partnerId: other.booking.id };
|
||||
});
|
||||
|
||||
// Both halves have just been completed into the operations queue by the
|
||||
// ordinary completion machine. A shared wagon does not go there unreviewed:
|
||||
// pull the pair back into the approval gate, which releases them to
|
||||
// Operations only once a person signs off on the pairing.
|
||||
await this.consolidationApprovalService.requestApproval(
|
||||
ownId,
|
||||
partnerId,
|
||||
actorUserId ?? null,
|
||||
);
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
|
||||
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
|
||||
@@ -358,32 +358,39 @@ export class ContractClearanceService {
|
||||
// Once GL creates the shipment booking, surface its reference + status so the
|
||||
// customer sees the concrete booking instead of a stale "will be created
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingId: string | null = null;
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
let linkedBookingReviewNote: string | null = null;
|
||||
let linkedBookingScheduledDate: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
linkedBookingScheduledDate = booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString()
|
||||
: null;
|
||||
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||
linkedBookingReviewNote =
|
||||
[...(booking.reviewNotes ?? [])]
|
||||
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0]?.note ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
// The cycle is the historical link, but it is not written on every path (an
|
||||
// FCFS export booking and a GL drawdown both reach the operations queue
|
||||
// without a cycle row), so fall back to the contract's own live booking —
|
||||
// otherwise the clearance page sees no linked booking at all and cannot show
|
||||
// its status or the actions that depend on it.
|
||||
const booking = cycle?.bookingId
|
||||
? await this.bookingsService.findById(cycle.bookingId)
|
||||
: await this.contractsRepository.findLatestBookingForContract(contractId);
|
||||
if (booking) {
|
||||
linkedBookingId = booking.id ?? null;
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
linkedBookingScheduledDate = booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString()
|
||||
: null;
|
||||
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||
linkedBookingReviewNote =
|
||||
[...(booking.reviewNotes ?? [])]
|
||||
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0]?.note ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
bookingMilestones,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +427,7 @@ export class ContractClearanceService {
|
||||
bookingReady: boundary,
|
||||
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
|
||||
exportClearanceFinalized: Boolean(cycle?.completedAt),
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingId,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
|
||||
@@ -1194,6 +1194,9 @@ export class ContractsController {
|
||||
bookingId,
|
||||
dto,
|
||||
user,
|
||||
// Recorded as the requester on the approval: the person who created the
|
||||
// pairing may not be the one who approves it.
|
||||
user?.id ?? user?.sub ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -656,6 +656,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.getCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* The live shipment booking on a contract, newest first.
|
||||
*
|
||||
* The clearance view historically reached the booking through
|
||||
* `currentCycle().bookingId`, but a cycle row is not created on every path —
|
||||
* an FCFS export booking and a GL drawdown both reach
|
||||
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
|
||||
* clearance page loses the booking's status entirely. This resolves it from
|
||||
* the bookings themselves, which is the authoritative link (bookings carry
|
||||
* contract_id), and is used as the fallback when the cycle has no booking.
|
||||
*/
|
||||
async findLatestBookingForContract(
|
||||
contractId: string,
|
||||
): Promise<Booking | null> {
|
||||
return this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.where('b.contract_id = :contractId', { contractId })
|
||||
.andWhere('b.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_BOOKING_STATUSES,
|
||||
})
|
||||
.orderBy('b.created_at', 'DESC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
async createReviewNote(
|
||||
contractId: string,
|
||||
body: string,
|
||||
|
||||
@@ -226,6 +226,14 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
"Rebook cancelled wagons for a customer",
|
||||
),
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial call,
|
||||
// so it is signed off separately from the ordinary booking approvals — and
|
||||
// never by the GL user who created the pairing.
|
||||
perm(
|
||||
"a1000001-0001-4000-8000-000000000029",
|
||||
"edr_freight_app:bookings:approve_consolidation",
|
||||
"Approve shared-wagon consolidation",
|
||||
),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -1754,6 +1762,7 @@ export const FREIGHT_PERMS = {
|
||||
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
|
||||
wagonCancellationRebook:
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
|
||||
// Notification selectors, not route guards — see NOTIFICATION_PERMISSIONS.
|
||||
getNotification: "edr_freight_app:bookings:get_notification",
|
||||
clearanceGetNotification:
|
||||
@@ -2354,6 +2363,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
@@ -2410,6 +2423,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveDirector,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.generateContract,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveDirector,
|
||||
@@ -2422,6 +2439,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.approveCeo,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.approveCeo,
|
||||
...allRuleEngineViewKeys(),
|
||||
@@ -2500,6 +2521,10 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reject,
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
// Shared-wagon gate: two customers' cargo on one wagon is a commercial
|
||||
// call. Granted to the approver roles and NOT to GL — GL creates the
|
||||
// pairing, so GL approving it would defeat the second pair of eyes.
|
||||
FREIGHT_PERMS.bookings.approveConsolidation,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
|
||||
@@ -19,6 +19,7 @@ import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import ConsolidationApprovalsPage from "./pages/bookings/ConsolidationApprovalsPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
|
||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||
@@ -381,6 +382,18 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* Shared-wagon gate: consolidated pairs wait for a human decision
|
||||
before either half reaches Operations. */}
|
||||
<Route
|
||||
path="consolidation-approvals"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.bookings.approveConsolidation}
|
||||
>
|
||||
<ConsolidationApprovalsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="booking-requests/:id"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: "yellow",
|
||||
APPROVED: "teal",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
/**
|
||||
* Audit trail for this booking's shared wagon: every approval request against
|
||||
* it, who decided, when, and why. Rendered only for a booking that is actually
|
||||
* consolidated — there is nothing to show otherwise.
|
||||
*/
|
||||
export function ConsolidationApprovalCard({ bookingId }: { bookingId: string }) {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["consolidation-approvals", "history", bookingId],
|
||||
queryFn: () => bookingsService.consolidationApprovalHistory(bookingId),
|
||||
enabled: Boolean(bookingId),
|
||||
});
|
||||
|
||||
if (!data?.length) return null;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Link2} title="Shared wagon approval">
|
||||
<Stack gap="md">
|
||||
{data.map((row) => (
|
||||
<Box
|
||||
key={row.id}
|
||||
style={{
|
||||
borderLeft: "3px solid var(--mantine-color-gray-3)",
|
||||
paddingLeft: 12,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" wrap="wrap" mb={4}>
|
||||
<Badge
|
||||
color={STATUS_COLOR[row.status] ?? "gray"}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
size="sm"
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
<Text fz={13} fw={600}>
|
||||
{row.bookingReference ?? "—"} + {row.partnerBookingReference ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Text fz={12} c="dimmed">
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.requestedBy ? ` by ${row.requestedBy}` : ""}
|
||||
</Text>
|
||||
|
||||
{row.decidedAt ? (
|
||||
<Text fz={12} c="dimmed">
|
||||
{row.status === "APPROVED" ? "Approved" : "Rejected"}{" "}
|
||||
{formatDateTime(row.decidedAt)}
|
||||
{row.decidedBy ? ` by ${row.decidedBy}` : ""}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fz={12} c="yellow.8">
|
||||
Waiting for a decision — neither booking reaches Operations until
|
||||
this is approved.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{row.decisionNote ? (
|
||||
<Text fz={12.5} mt={4} style={{ whiteSpace: "pre-wrap" }}>
|
||||
“{row.decisionNote}”
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MapPin, Plus, Search } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { api } from "@/services/api";
|
||||
* AVAILABLE, unassigned wagons from every yard — filtered and paged on the API,
|
||||
* so the picker never page-walks the whole fleet into the browser.
|
||||
*/
|
||||
export default function AvailableWagonsPanel({
|
||||
function AvailableWagonsPanel({
|
||||
homeYardId,
|
||||
onAssign,
|
||||
assigning,
|
||||
@@ -36,7 +36,7 @@ export default function AvailableWagonsPanel({
|
||||
const [typeFilter, setTypeFilter] = useState<string>("ALL");
|
||||
const [yardFilter, setYardFilter] = useState<string>("ALL");
|
||||
const [runOnly, setRunOnly] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// The train's own run, e.g. "8001-8002" — only offered when the train has one.
|
||||
@@ -60,6 +60,9 @@ export default function AvailableWagonsPanel({
|
||||
pageSize: PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
// Keep the previous page on screen while the next one loads — otherwise
|
||||
// paging and typing flash the list to "Loading wagons…" on every stroke.
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -105,32 +108,32 @@ export default function AvailableWagonsPanel({
|
||||
[wagonTypesQuery.data],
|
||||
);
|
||||
|
||||
const toggle = (wagonId: string, checked: boolean) => {
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId),
|
||||
);
|
||||
};
|
||||
const toggle = useCallback((wagonId: string, checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(wagonId);
|
||||
else next.delete(wagonId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select-all covers this page only — the rest of the matches are not loaded.
|
||||
const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id));
|
||||
const someSelected = wagons.some((w) => selected.includes(w.id));
|
||||
const allSelected = wagons.length > 0 && wagons.every((w) => selected.has(w.id));
|
||||
const someSelected = wagons.some((w) => selected.has(w.id));
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
setSelected((prev) => {
|
||||
if (checked) {
|
||||
const ids = new Set(prev);
|
||||
wagons.forEach((w) => ids.add(w.id));
|
||||
return [...ids];
|
||||
}
|
||||
const visible = new Set(wagons.map((w) => w.id));
|
||||
return prev.filter((id) => !visible.has(id));
|
||||
const next = new Set(prev);
|
||||
if (checked) wagons.forEach((w) => next.add(w.id));
|
||||
else wagons.forEach((w) => next.delete(w.id));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (!selected.length) return;
|
||||
onAssign(selected);
|
||||
setSelected([]);
|
||||
if (!selected.size) return;
|
||||
onAssign([...selected]);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -178,16 +181,24 @@ export default function AvailableWagonsPanel({
|
||||
indeterminate={!allSelected && someSelected}
|
||||
onChange={(e) => toggleAll(e.currentTarget.checked)}
|
||||
/>
|
||||
{selected.length ? (
|
||||
{selected.size ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{selected.length} selected
|
||||
{selected.size} selected
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<ScrollArea.Autosize mah={380} type="auto">
|
||||
<Stack gap={6}>
|
||||
{/* Previous results stay put while the next page loads (placeholderData),
|
||||
so dim them rather than blanking the list. */}
|
||||
<Stack
|
||||
gap={6}
|
||||
style={{
|
||||
opacity: wagonsQuery.isFetching && !wagonsQuery.isLoading ? 0.55 : 1,
|
||||
transition: "opacity 120ms ease",
|
||||
}}
|
||||
>
|
||||
{wagonsQuery.isLoading ? (
|
||||
<Text py="md" ta="center" c="dimmed" size="sm">
|
||||
Loading wagons…
|
||||
@@ -198,57 +209,14 @@ export default function AvailableWagonsPanel({
|
||||
</Text>
|
||||
) : (
|
||||
wagons.map((wagon) => (
|
||||
<Group
|
||||
<WagonOption
|
||||
key={wagon.id}
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected.includes(wagon.id)}
|
||||
onChange={(e) => toggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
|
||||
leftSection={<MapPin size={10} />}
|
||||
>
|
||||
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
|
||||
</Badge>
|
||||
{wagon.exportTrainNumber ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={
|
||||
wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"
|
||||
}
|
||||
>
|
||||
{wagon.exportTrainNumber}
|
||||
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
wagon={wagon}
|
||||
selected={selected.has(wagon.id)}
|
||||
homeYardId={homeYardId}
|
||||
exportTrainNumber={exportTrainNumber}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
@@ -265,16 +233,19 @@ export default function AvailableWagonsPanel({
|
||||
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
disabled={!selected.length}
|
||||
disabled={!selected.size}
|
||||
loading={assigning}
|
||||
onClick={handleAssign}
|
||||
>
|
||||
Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
|
||||
Add {selected.size ? `${selected.size} wagon${selected.size > 1 ? "s" : ""}` : "wagons"} to consist
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoized: the workspace re-renders on every pending mutation. */
|
||||
export default memo(AvailableWagonsPanel);
|
||||
|
||||
export interface AvailableWagonsPanelProps {
|
||||
/** The train's own yard — sorted first and highlighted; not a restriction. */
|
||||
homeYardId: string | null;
|
||||
@@ -285,3 +256,81 @@ export interface AvailableWagonsPanelProps {
|
||||
/** This train's even IMPORT run — label only; the export run does the matching. */
|
||||
importTrainNumber?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One selectable wagon row. Memoized: the picker re-renders on every keystroke
|
||||
* and every selection change, but a row only actually changes when its own
|
||||
* checkbox flips — so a full page of rows stays untouched.
|
||||
*/
|
||||
const WagonOption = memo(function WagonOption({
|
||||
wagon,
|
||||
selected,
|
||||
homeYardId,
|
||||
exportTrainNumber,
|
||||
onToggle,
|
||||
}: {
|
||||
wagon: {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
currentYardId?: string | null;
|
||||
currentYard?: { label?: string | null; code?: string | null } | null;
|
||||
exportTrainNumber?: string | null;
|
||||
importTrainNumber?: string | null;
|
||||
wagonType?: { name?: string | null; capacityTons?: number | null } | null;
|
||||
};
|
||||
selected: boolean;
|
||||
homeYardId: string | null;
|
||||
exportTrainNumber?: string | null;
|
||||
onToggle: (wagonId: string, checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="xs"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
checked={selected}
|
||||
onChange={(e) => onToggle(wagon.id, e.currentTarget.checked)}
|
||||
aria-label={`Select wagon ${wagon.wagonNumber}`}
|
||||
/>
|
||||
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600} ff="monospace" truncate>
|
||||
{wagon.wagonNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
color={wagon.currentYardId === homeYardId ? "edr-green" : "gray"}
|
||||
leftSection={<MapPin size={10} />}
|
||||
>
|
||||
{wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"}
|
||||
</Badge>
|
||||
{wagon.exportTrainNumber ? (
|
||||
<Badge
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={wagon.exportTrainNumber === exportTrainNumber ? "edr-green" : "gray"}
|
||||
>
|
||||
{wagon.exportTrainNumber}
|
||||
{wagon.importTrainNumber ? `-${wagon.importTrainNumber}` : ""}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{wagon.wagonType
|
||||
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
|
||||
: "Unknown type"}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@hello-pangea/dnd";
|
||||
import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
@@ -32,7 +32,7 @@ const PortalAwareRow = ({
|
||||
* The train's ordered wagon consist. Drag to reorder (persisted on drop),
|
||||
* trash to detach a wagon back to the yard.
|
||||
*/
|
||||
export default function ConsistWagonList({
|
||||
function ConsistWagonList({
|
||||
wagons,
|
||||
editable,
|
||||
onReorder,
|
||||
@@ -40,7 +40,7 @@ export default function ConsistWagonList({
|
||||
onMaintenance,
|
||||
busy = false,
|
||||
}: ConsistWagonListProps) {
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
const onDragEnd = useCallback((result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
const from = result.source.index;
|
||||
const to = result.destination.index;
|
||||
@@ -49,7 +49,18 @@ export default function ConsistWagonList({
|
||||
const [moved] = next.splice(from, 1);
|
||||
next.splice(to, 0, moved!);
|
||||
onReorder(next.map((w) => w.id));
|
||||
};
|
||||
}, [wagons, onReorder]);
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = useMemo(
|
||||
() => [
|
||||
...new Map(
|
||||
wagons.filter((w) => w.wagonType).map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
],
|
||||
[wagons],
|
||||
);
|
||||
|
||||
if (!wagons.length) {
|
||||
return (
|
||||
@@ -59,16 +70,6 @@ export default function ConsistWagonList({
|
||||
);
|
||||
}
|
||||
|
||||
// Legend of the types actually coupled, in consist order — the colour code is
|
||||
// only readable if the row tints are keyed somewhere.
|
||||
const legend = [
|
||||
...new Map(
|
||||
wagons
|
||||
.filter((w) => w.wagonType)
|
||||
.map((w) => [w.wagonType!.code, w.wagonType!]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
|
||||
@@ -118,6 +119,9 @@ export default function ConsistWagonList({
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoized: a 40-wagon consist re-renders every row otherwise. */
|
||||
export default memo(ConsistWagonList);
|
||||
|
||||
export interface ConsistWagonListProps {
|
||||
wagons: TrainCompositionWagon[];
|
||||
editable: boolean;
|
||||
@@ -128,7 +132,7 @@ export interface ConsistWagonListProps {
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
function WagonRow({
|
||||
const WagonRow = memo(function WagonRow({
|
||||
wagon,
|
||||
index,
|
||||
dragProvided,
|
||||
@@ -232,4 +236,4 @@ function WagonRow({
|
||||
</Group>
|
||||
</PortalAwareRow>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Box, Group, Paper, Progress, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { Box as BoxIcon, Container as ContainerIcon, Fuel, Gauge, TrainFront } from "lucide-react";
|
||||
@@ -132,7 +132,7 @@ function Coupler() {
|
||||
);
|
||||
}
|
||||
|
||||
function LocomotiveCar({
|
||||
const LocomotiveCar = memo(function LocomotiveCar({
|
||||
code,
|
||||
name,
|
||||
maxPullWeightTons,
|
||||
@@ -260,7 +260,7 @@ function LocomotiveCar({
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const CONTAINER_GRADIENTS = [
|
||||
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
|
||||
@@ -271,7 +271,7 @@ const CONTAINER_BORDERS = [
|
||||
"var(--mantine-color-blue-8)",
|
||||
];
|
||||
|
||||
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
const WagonCar = memo(function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
// GROSS on both sides: cargo + tare vs rated payload + tare.
|
||||
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
|
||||
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
|
||||
@@ -468,7 +468,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/** Railway track: two rails over evenly-spaced sleepers. */
|
||||
function TrackBed() {
|
||||
@@ -520,7 +520,7 @@ function TrackBed() {
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainCompositionDiagram({
|
||||
export const TrainCompositionDiagram = memo(function TrainCompositionDiagram({
|
||||
locomotive,
|
||||
locomotives,
|
||||
wagons,
|
||||
@@ -818,7 +818,7 @@ export function TrainCompositionDiagram({
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
|
||||
@@ -193,6 +193,15 @@ export const URL_CONSTANTS = {
|
||||
STAFF_ACCEPT: (id: string) => `/bookings/${id}/staff/accept`,
|
||||
// Consolidated pair: one staff decision applied to both halves at once.
|
||||
PAIRED_DECISION: (id: string) => `/bookings/${id}/paired-decision`,
|
||||
// Shared-wagon approval gate: a consolidated pair waits for a human
|
||||
// decision before either half reaches Operations.
|
||||
CONSOLIDATION_APPROVAL_QUEUE: "/bookings/consolidation-approvals/queue",
|
||||
CONSOLIDATION_APPROVAL_HISTORY: (id: string) =>
|
||||
`/bookings/${id}/consolidation-approvals`,
|
||||
CONSOLIDATION_APPROVE: (approvalId: string) =>
|
||||
`/bookings/consolidation-approvals/${approvalId}/approve`,
|
||||
CONSOLIDATION_REJECT: (approvalId: string) =>
|
||||
`/bookings/consolidation-approvals/${approvalId}/reject`,
|
||||
STAFF_REQUEST_CHANGES: (id: string) =>
|
||||
`/bookings/${id}/staff/request-changes`,
|
||||
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
|
||||
|
||||
@@ -102,6 +102,11 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "Operation Changes",
|
||||
color: "bg-orange-50 text-orange-700 border-orange-200",
|
||||
},
|
||||
// Shared-wagon gate: held for a human decision before reaching Operations.
|
||||
CONSOLIDATION_APPROVAL_PENDING: {
|
||||
label: "Wagon Approval",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
},
|
||||
OPERATION_PRICE_PENDING_CONFIRM: {
|
||||
label: "Price Confirm",
|
||||
color: "bg-amber-50 text-amber-700 border-amber-200",
|
||||
@@ -309,6 +314,9 @@ export const BOOKING_LIST_TABS = [
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||
// Held at the shared-wagon gate — still an ops-review-stage booking, it
|
||||
// just needs the pairing signed off before Operations can act on it.
|
||||
"CONSOLIDATION_APPROVAL_PENDING",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,6 +57,7 @@ export const FREIGHT_PERMS = {
|
||||
wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void",
|
||||
wagonCancellationRebook:
|
||||
"edr_freight_app:bookings:wagon_cancellation_rebook",
|
||||
approveConsolidation: "edr_freight_app:bookings:approve_consolidation",
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
|
||||
58
apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
Normal file
58
apps/edr-freight-web/backoffice/src/lib/queryClient.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
/**
|
||||
* The MutationCache seeds `meta.updates` entries into the cache and then skips
|
||||
* exactly those keys when running `meta.invalidates`. Getting that skip wrong
|
||||
* silently reintroduces the refetch it exists to avoid, so it is worth pinning.
|
||||
*/
|
||||
const runOnSuccess = (meta: Record<string, unknown>, data: unknown, variables: unknown) => {
|
||||
const handler = (queryClient.getMutationCache() as unknown as {
|
||||
config: {
|
||||
onSuccess?: (
|
||||
data: unknown,
|
||||
variables: unknown,
|
||||
context: unknown,
|
||||
mutation: { meta?: Record<string, unknown> },
|
||||
) => void;
|
||||
};
|
||||
}).config.onSuccess;
|
||||
handler?.(data, variables, undefined, { meta });
|
||||
};
|
||||
|
||||
describe("MutationCache updates/invalidates", () => {
|
||||
it("writes the mutation response into the seeded key and leaves it fresh", () => {
|
||||
const seededKey = ["train-builder", "composition", "t1"] as const;
|
||||
const siblingKey = ["train-builder", "list", {}] as const;
|
||||
|
||||
queryClient.setQueryData(seededKey, { code: "STALE" });
|
||||
queryClient.setQueryData(siblingKey, { items: [] });
|
||||
|
||||
const response = { code: "FRESH" };
|
||||
runOnSuccess(
|
||||
{
|
||||
updates: (_v: unknown, d: unknown) => [[seededKey, d]],
|
||||
invalidates: () => [["train-builder"]],
|
||||
},
|
||||
response,
|
||||
{ id: "t1" },
|
||||
);
|
||||
|
||||
// Seeded key holds the response, and was NOT invalidated back to stale.
|
||||
expect(queryClient.getQueryData(seededKey)).toStrictEqual(response);
|
||||
expect(queryClient.getQueryState(seededKey)?.isInvalidated).toBe(false);
|
||||
|
||||
// Its siblings under the same root still get invalidated.
|
||||
expect(queryClient.getQueryState(siblingKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("invalidates everything when a mutation declares no updates", () => {
|
||||
const key = ["train-builder", "composition", "t2"] as const;
|
||||
queryClient.setQueryData(key, { code: "X" });
|
||||
|
||||
runOnSuccess({ invalidates: () => [["train-builder"]] }, undefined, undefined);
|
||||
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MutationCache, QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import type { InvalidatesMeta } from "@/utils/endpoint";
|
||||
import type { InvalidatesMeta, UpdatesMeta } from "@/utils/endpoint";
|
||||
|
||||
/**
|
||||
* Single app-wide React Query client (do not nest additional providers).
|
||||
@@ -14,13 +14,37 @@ import type { InvalidatesMeta } from "@/utils/endpoint";
|
||||
export const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onSuccess: (data, variables, _context, mutation) => {
|
||||
// Seed first: endpoints that return the entity they just changed write it
|
||||
// straight into its cache key, so the screen updates from the response
|
||||
// instead of round-tripping for data it already holds.
|
||||
const updates = mutation.meta?.updates as UpdatesMeta | undefined;
|
||||
const seeded: readonly unknown[][] = [];
|
||||
if (typeof updates === "function") {
|
||||
for (const [queryKey, value] of updates(variables, data)) {
|
||||
queryClient.setQueryData(queryKey, value);
|
||||
seeded.push(queryKey as unknown[]);
|
||||
}
|
||||
}
|
||||
|
||||
const invalidates = mutation.meta?.invalidates as
|
||||
| InvalidatesMeta
|
||||
| undefined;
|
||||
if (typeof invalidates !== "function") return;
|
||||
|
||||
for (const queryKey of invalidates(variables, data)) {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey,
|
||||
// A seeded key already holds the authoritative value from this very
|
||||
// response — invalidating it would refetch it right back.
|
||||
predicate: seeded.length
|
||||
? (query) =>
|
||||
!seeded.some(
|
||||
(key) =>
|
||||
key.length === query.queryKey.length &&
|
||||
key.every((part, i) => Object.is(part, query.queryKey[i])),
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
// Mutation failures are surfaced globally by the axios interceptor in
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -48,6 +49,7 @@ import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge
|
||||
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
|
||||
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||
import { ConsolidationApprovalCard } from "@/components/bookings/detail/ConsolidationApprovalCard";
|
||||
import {
|
||||
detailStyles,
|
||||
BookingRouteServiceCard,
|
||||
@@ -460,6 +462,21 @@ export default function BookingRequestDetailPage() {
|
||||
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||
)}
|
||||
|
||||
{booking.status === "CONSOLIDATION_APPROVAL_PENDING" && (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<Link2 size={18} />}
|
||||
title="Waiting for shared-wagon approval"
|
||||
>
|
||||
<Text size="sm">
|
||||
This booking shares a wagon with another customer's booking.
|
||||
Both are held here until the pairing is approved — neither reaches
|
||||
Operations before then.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
@@ -534,6 +551,8 @@ export default function BookingRequestDetailPage() {
|
||||
that train and its clock must be readable before the approve
|
||||
button. */}
|
||||
<BookingSchedulingWindowCard booking={booking} />
|
||||
{/* Renders itself only when this booking has a shared wagon. */}
|
||||
<ConsolidationApprovalCard bookingId={booking.id} />
|
||||
<BookingActionsToolbar
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
bookingsService,
|
||||
type ConsolidationApprovalRow,
|
||||
} from "@/services/bookings.service";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
*
|
||||
* A booking that fills its own wagons goes straight to Operations. A
|
||||
* consolidated one waits here: two customers' cargo rides one physical wagon
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [decision, setDecision] = useState<{
|
||||
row: ConsolidationApprovalRow;
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
});
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
};
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
decision?.kind === "approve"
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(extractErrorMessage(error, "Could not record the decision")),
|
||||
});
|
||||
|
||||
// A rejection has to tell GL what to fix, so the reason is mandatory there.
|
||||
const confirmDisabled =
|
||||
decide.isPending || (decision?.kind === "reject" && !note.trim());
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Shared wagon approvals"
|
||||
subtitle="Two customers' cargo on one wagon — review the pairing before it reaches Operations."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py={80}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.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="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"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={Boolean(decision)}
|
||||
onClose={() => {
|
||||
if (!decide.isPending) close();
|
||||
}}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
label={
|
||||
decision?.kind === "approve"
|
||||
? "Note (optional)"
|
||||
: "Reason (required)"
|
||||
}
|
||||
description={
|
||||
decision?.kind === "approve"
|
||||
? "Recorded with the approval for the audit trail."
|
||||
: "GL sees this on both bookings — say what has to change."
|
||||
}
|
||||
placeholder={
|
||||
decision?.kind === "approve"
|
||||
? "Anything worth recording…"
|
||||
: "e.g. the partner's cargo weights are unbalanced for one wagon"
|
||||
}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={close}
|
||||
disabled={decide.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color={decision?.kind === "approve" ? "edr-green" : "red"}
|
||||
radius="md"
|
||||
loading={decide.isPending}
|
||||
disabled={confirmDisabled}
|
||||
onClick={() => decide.mutate()}
|
||||
>
|
||||
{decision?.kind === "approve" ? "Approve both" : "Reject both"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/** One half of the wagon: its reference (linked) and whose cargo it is. */
|
||||
function BookingSide({
|
||||
id,
|
||||
reference,
|
||||
company,
|
||||
}: {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
company?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${id}`}
|
||||
fz={14}
|
||||
fw={700}
|
||||
c="blue.7"
|
||||
style={{ textDecoration: "none" }}
|
||||
>
|
||||
{reference ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12.5} c="dimmed">
|
||||
{company ?? "—"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Weight,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import AvailableWagonsPanel from "@/components/trainBuilder/AvailableWagonsPanel";
|
||||
@@ -98,7 +98,14 @@ export default function TrainBuilderDetailPage() {
|
||||
const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
api.trainBuilder.composition.queryOptions({
|
||||
input: { id },
|
||||
enabled: Boolean(id),
|
||||
// Mutations seed this key from their own response (see `seedComposition`
|
||||
// in services/api.ts), so the cached consist is authoritative — the
|
||||
// global staleTime of 0 would otherwise refetch it on every remount.
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions());
|
||||
const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions());
|
||||
@@ -111,6 +118,34 @@ export default function TrainBuilderDetailPage() {
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
// re-normalize + repaint every car for each keystroke or pending mutation.
|
||||
const diagramLocomotives = useMemo(
|
||||
() =>
|
||||
(composition?.locomotives ?? []).map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
})),
|
||||
[composition?.locomotives],
|
||||
);
|
||||
const diagramWagons = useMemo(
|
||||
() =>
|
||||
(composition?.wagons ?? []).map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
})),
|
||||
[composition?.wagons],
|
||||
);
|
||||
|
||||
// Staff identify a train by its operational run numbers, not the internal
|
||||
// code — mirrors formatTrainRunLabel on the API, which writes the history note.
|
||||
const trainRunLabel =
|
||||
@@ -130,17 +165,62 @@ export default function TrainBuilderDetailPage() {
|
||||
maintenanceWagon.isPending ||
|
||||
reorderWagons.isPending;
|
||||
|
||||
const withToast = async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
const withToast = useCallback(
|
||||
async (action: () => Promise<unknown>, failTitle: string) => {
|
||||
try {
|
||||
await action();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: failTitle,
|
||||
description: parseError(err, "Something went wrong"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
// Stable handlers: the consist list and wagon picker are memoized, so a new
|
||||
// closure each render would defeat the memo and re-render every wagon row
|
||||
// (and re-mount the drag context) on unrelated state changes.
|
||||
// `trainId` is only absent before the composition loads, and these handlers
|
||||
// are wired to controls that render after that — the guard keeps the promise
|
||||
// rather than leaning on a non-null assertion.
|
||||
const trainId = composition?.id;
|
||||
const handleAssign = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not add wagons",
|
||||
);
|
||||
},
|
||||
[withToast, assignWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleReorder = useCallback(
|
||||
(wagonIds: string[]) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: trainId, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
);
|
||||
},
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
return (
|
||||
@@ -364,21 +444,8 @@ export default function TrainBuilderDetailPage() {
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={composition.locomotives.map((loco) => ({
|
||||
code: loco.code,
|
||||
name: loco.name,
|
||||
maxPullWeightTons: loco.maxPullWeightTons,
|
||||
}))}
|
||||
wagons={composition.wagons.map((wagon, index) => ({
|
||||
sequenceNo: wagon.sequenceNumber ?? index + 1,
|
||||
capacityTons: wagon.wagonType?.capacityTons ?? 0,
|
||||
// No bookings at build time — wagons ride empty until allocation.
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType?.tareWeightTons ?? 0,
|
||||
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||
physicalWagonNumber: wagon.wagonNumber,
|
||||
allocations: [],
|
||||
}))}
|
||||
locomotives={diagramLocomotives}
|
||||
wagons={diagramWagons}
|
||||
trainNumber={composition.code}
|
||||
totalLengthMeters={totals.totalLengthMeters}
|
||||
/>
|
||||
@@ -412,12 +479,7 @@ export default function TrainBuilderDetailPage() {
|
||||
exportTrainNumber={composition.exportTrainNumber}
|
||||
importTrainNumber={composition.importTrainNumber}
|
||||
assigning={assignWagons.isPending}
|
||||
onAssign={(wagonIds) =>
|
||||
void withToast(
|
||||
() => assignWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not add wagons",
|
||||
)
|
||||
}
|
||||
onAssign={handleAssign}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -434,19 +496,9 @@ export default function TrainBuilderDetailPage() {
|
||||
wagons={composition.wagons}
|
||||
editable={composition.editable && canAssign}
|
||||
busy={busy}
|
||||
onReorder={(wagonIds) =>
|
||||
void withToast(
|
||||
() => reorderWagons.mutateAsync({ id: composition.id, wagonIds }),
|
||||
"Could not reorder wagons",
|
||||
)
|
||||
}
|
||||
onRemove={(wagonId) =>
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: composition.id, wagonId }),
|
||||
"Could not detach wagon",
|
||||
)
|
||||
}
|
||||
onMaintenance={(wagon) => setMaintenanceTarget(wagon)}
|
||||
onReorder={handleReorder}
|
||||
onRemove={handleRemove}
|
||||
onMaintenance={handleMaintenance}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -284,6 +284,32 @@ const TRAIN_BUILDER_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.FLEET.ROOT,
|
||||
];
|
||||
|
||||
/**
|
||||
* Coupling/uncoupling wagons moves wagons between the available pool and one
|
||||
* train — it does not touch locomotives, so those roots stay valid. Trimming
|
||||
* the set keeps a drag-reorder from refetching the whole fleet.
|
||||
*/
|
||||
const TRAIN_BUILDER_WAGON_INVALIDATIONS: ReadonlyArray<readonly unknown[]> = [
|
||||
QUERY_KEYS.TRAIN_BUILDER.ROOT,
|
||||
QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
["wagons"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Every train-builder mutation responds with the train's full, fresh
|
||||
* composition — write it straight into the detail cache so the workspace
|
||||
* repaints from the response instead of refetching what it was just handed.
|
||||
*/
|
||||
const seedComposition = (
|
||||
input: { id: string } | string,
|
||||
data: TrainComposition,
|
||||
): ReadonlyArray<readonly [readonly unknown[], unknown]> => [
|
||||
[
|
||||
QUERY_KEYS.TRAIN_BUILDER.composition(typeof input === "string" ? input : input.id),
|
||||
data,
|
||||
],
|
||||
];
|
||||
|
||||
export const api = {
|
||||
trainScheduling: {
|
||||
// ── Queries ────────────────────────────────────────────────────────────
|
||||
@@ -2070,6 +2096,7 @@ export const api = {
|
||||
trainBuilderService.setLocomotives(id, locomotiveIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
|
||||
@@ -2079,6 +2106,7 @@ export const api = {
|
||||
trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
updateDetails: endpoint<
|
||||
@@ -2091,6 +2119,7 @@ export const api = {
|
||||
trainBuilderService.updateDetails(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
@@ -2099,7 +2128,8 @@ export const api = {
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.assignWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>(
|
||||
@@ -2108,7 +2138,8 @@ export const api = {
|
||||
({ id, wagonId }) =>
|
||||
trainBuilderService.removeWagon(id, wagonId).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
sendWagonToMaintenance: endpoint<
|
||||
@@ -2120,7 +2151,8 @@ export const api = {
|
||||
({ id, wagonId, note }) =>
|
||||
trainBuilderService.sendWagonToMaintenance(id, wagonId, note).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
|
||||
@@ -2129,7 +2161,8 @@ export const api = {
|
||||
({ id, wagonIds }) =>
|
||||
trainBuilderService.reorderWagons(id, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
deactivate: endpoint<string, TrainComposition>(
|
||||
@@ -2138,6 +2171,7 @@ export const api = {
|
||||
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
activate: endpoint<string, TrainComposition>(
|
||||
@@ -2146,6 +2180,7 @@ export const api = {
|
||||
(id) => trainBuilderService.activate(id).then((r) => r.data),
|
||||
undefined,
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
seedComposition,
|
||||
),
|
||||
|
||||
disband: endpoint<string, void>(
|
||||
|
||||
@@ -6,6 +6,35 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
/**
|
||||
* One shared-wagon approval. Covers BOTH bookings on the wagon — the pair is
|
||||
* decided as a unit, never one side at a time.
|
||||
*/
|
||||
export interface ConsolidationApprovalRow {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
partnerBookingId: string;
|
||||
status: "PENDING" | "APPROVED" | "REJECTED";
|
||||
requestedBy?: string | null;
|
||||
requestedAt: string;
|
||||
decidedBy?: string | null;
|
||||
decidedAt?: string | null;
|
||||
decisionNote?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
bookingReference?: string | null;
|
||||
partnerBookingReference?: string | null;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
partnerBooking?: {
|
||||
id: string;
|
||||
reference?: string;
|
||||
company?: { name?: string } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs */
|
||||
@@ -323,6 +352,40 @@ export const bookingsService = {
|
||||
cancel: (id: string, reason: string) =>
|
||||
postBooking<BookingDetail>(B.CANCEL(id), { reason }),
|
||||
|
||||
// ── 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[];
|
||||
},
|
||||
|
||||
/** Decision history for one booking's shared wagon — who, when, and why. */
|
||||
consolidationApprovalHistory: async (
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationApprovalRow[]> => {
|
||||
const response = await client.get(
|
||||
B.CONSOLIDATION_APPROVAL_HISTORY(bookingId),
|
||||
);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
||||
},
|
||||
|
||||
/** Approve: both bookings leave the gate and continue to Operations. */
|
||||
approveConsolidation: async (approvalId: string, note?: string) => {
|
||||
const response = await client.post(B.CONSOLIDATION_APPROVE(approvalId), {
|
||||
note,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Reject: both bookings go back to GL for changes with the reason. */
|
||||
rejectConsolidation: async (approvalId: string, reason: string) => {
|
||||
const response = await client.post(B.CONSOLIDATION_REJECT(approvalId), {
|
||||
reason,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply one staff decision to BOTH halves of a consolidated pair. The two
|
||||
* bookings share a wagon, so they advance or cancel together — all-or-nothing
|
||||
|
||||
@@ -33,6 +33,27 @@ export type InvalidatesMeta = (
|
||||
data: unknown,
|
||||
) => ReadonlyArray<readonly unknown[]>;
|
||||
|
||||
/**
|
||||
* Cache entries a mutation can write DIRECTLY from its own response, skipping
|
||||
* a refetch. Many endpoints already return the fresh entity they just changed
|
||||
* (e.g. every train-builder mutation returns the whole `TrainComposition`), so
|
||||
* re-fetching that same key is a wasted round-trip and a visible flicker.
|
||||
*
|
||||
* Returned pairs are written with `setQueryData` by the app-wide MutationCache
|
||||
* BEFORE the `invalidates` keys are invalidated, and any key seeded here is
|
||||
* skipped by that invalidation pass — the value just written IS the fresh one.
|
||||
*/
|
||||
export type UpdatesFn<TInput, TResponse> = (
|
||||
input: TInput,
|
||||
data: TResponse,
|
||||
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
|
||||
|
||||
/** Shape stored in `mutation.meta.updates` and consumed by the MutationCache. */
|
||||
export type UpdatesMeta = (
|
||||
variables: unknown,
|
||||
data: unknown,
|
||||
) => ReadonlyArray<readonly [readonly unknown[], unknown]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Endpoint interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,6 +88,7 @@ export function endpoint<TInput, TResponse>(
|
||||
execute: (input: TInput) => Promise<TResponse>,
|
||||
queryKeyBuilder?: (input: TInput) => readonly unknown[],
|
||||
invalidates?: InvalidatesFn<TInput, TResponse>,
|
||||
updates?: UpdatesFn<TInput, TResponse>,
|
||||
) {
|
||||
const buildKey = (input?: TInput): readonly unknown[] => {
|
||||
if (queryKeyBuilder && input !== undefined) {
|
||||
@@ -101,16 +123,30 @@ export function endpoint<TInput, TResponse>(
|
||||
const mutationOptions = (
|
||||
config?: Omit<UseMutationOptions<TResponse, Error, TInput>, "mutationFn">,
|
||||
): UseMutationOptions<TResponse, Error, TInput> => {
|
||||
const meta = invalidates
|
||||
? {
|
||||
...config?.meta,
|
||||
invalidates: ((variables, data) =>
|
||||
invalidates(
|
||||
variables as TInput,
|
||||
data as TResponse,
|
||||
)) satisfies InvalidatesMeta,
|
||||
}
|
||||
: config?.meta;
|
||||
const meta =
|
||||
invalidates || updates
|
||||
? {
|
||||
...config?.meta,
|
||||
...(invalidates
|
||||
? {
|
||||
invalidates: ((variables, data) =>
|
||||
invalidates(
|
||||
variables as TInput,
|
||||
data as TResponse,
|
||||
)) satisfies InvalidatesMeta,
|
||||
}
|
||||
: {}),
|
||||
...(updates
|
||||
? {
|
||||
updates: ((variables, data) =>
|
||||
updates(
|
||||
variables as TInput,
|
||||
data as TResponse,
|
||||
)) satisfies UpdatesMeta,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: config?.meta;
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
||||
Reference in New Issue
Block a user