From 65f18b4796ac2b75fb77c0b3ffaab04b1cfff841 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Fri, 31 Jul 2026 00:41:51 +0000
Subject: [PATCH] add goverment booking
---
.../contracts/contract-view-model.builder.ts | 12 +-
.../3090000000000-AddSavedSignatureStamp.ts | 17 +++
.../bookings/booking-contract.service.ts | 59 ++++++--
.../src/modules/bookings/bookings.service.ts | 44 +++++-
.../modules/bookings/dto/contract-view.dto.ts | 3 +
.../scheduling-reschedule.service.ts | 11 ++
.../signatures/dto/save-signature.dto.ts | 16 +-
.../entities/saved-signature.entity.ts | 7 +
.../signatures/signatures.controller.ts | 1 +
.../signatures/signatures.repository.ts | 2 +-
.../modules/signatures/signatures.service.ts | 89 +++++++----
.../train-scheduling/booking-batch.service.ts | 113 +++++++++++---
.../dto/switch-government-booking.dto.ts | 18 +++
.../train-scheduling.controller.ts | 20 +++
.../train-scheduling.service.spec.ts | 84 ++++++++++
.../train-scheduling.service.ts | 143 ++++++++++++++++++
.../components/profile/MySignatureCard.tsx | 24 +++
.../trainScheduling/EligibleBookingsPanel.tsx | 27 +++-
.../trainScheduling/ScheduleBookingsStep.tsx | 16 +-
.../SwitchGovernmentBookingModal.tsx | 143 ++++++++++++++++++
.../backoffice/src/constants/URLS.ts | 2 +
.../bookings/BookingRequestDetailPage.tsx | 15 ++
.../src/pages/bookings/NewBookingPage.tsx | 9 +-
.../src/pages/contracts/ContractViewPage.tsx | 3 +-
.../TrainScheduleV2DetailPage.tsx | 47 ++++++
.../backoffice/src/services/api.ts | 16 ++
.../src/services/contracts.service.ts | 1 +
.../src/services/signatures.service.ts | 3 +
.../src/services/trainScheduling.service.ts | 12 ++
.../backoffice/src/types/trainScheduling.ts | 2 +
.../ContractCustomerAction.tsx | 11 +-
.../components/profile/MySignatureCard.tsx | 24 +++
.../src/pages/contracts/ContractViewPage.tsx | 3 +-
.../portal/src/services/bookings.service.ts | 1 +
.../portal/src/services/signatures.service.ts | 3 +
35 files changed, 913 insertions(+), 88 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/SwitchGovernmentBookingModal.tsx
diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
index 8b8b92f09..07f4d25d3 100644
--- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
+++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts
@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
pricing,
rateSchedule,
signatures,
- canSignCustomer:
- booking.status === 'CONTRACT_READY' && !hasCustomer,
- canSignStaff:
- booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
+ // Government contracts are generated at creation and signable at any
+ // time, in any order — no status gate, no customer-first sequencing.
+ canSignCustomer: booking.isGovernment
+ ? !hasCustomer
+ : booking.status === 'CONTRACT_READY' && !hasCustomer,
+ canSignStaff: booking.isGovernment
+ ? !hasStaff
+ : booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
hasContractDocument: hasContractFile,
hasCustomerSignature: hasCustomer,
hasStaffSignature: hasStaff,
diff --git a/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts
new file mode 100644
index 000000000..3beafb6bf
--- /dev/null
+++ b/apps/edr-freight-api/src/migrations/3090000000000-AddSavedSignatureStamp.ts
@@ -0,0 +1,17 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddSavedSignatureStamp3090000000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.saved_signatures
+ ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL;
+ `);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ ALTER TABLE freight.saved_signatures
+ DROP COLUMN IF EXISTS stamp_file_id;
+ `);
+ }
+}
diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
index 5297f3d57..99f053545 100644
--- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts
@@ -112,15 +112,9 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
- // PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
- // from becoming ready — the document is (re)rendered lazily on view/download.
- try {
- await this.upsertContractPdf(bookingId, booking.reference, templateKey);
- } catch (err) {
- this.logger.warn(
- `Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
- );
- }
+ // No eager PDF render here: streamContract re-renders the document on every
+ // view/download, so rendering now only adds a Chromium launch (seconds, or a
+ // 60s asset-load hang) inside the staff-accept request.
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -132,6 +126,22 @@ export class BookingContractService {
return updated!;
}
+ /**
+ * Government bookings skip the whole customer contract flow (approve →
+ * CONTRACT_READY → sign chain): their contract is stamped server-side at
+ * creation/expedite WITHOUT touching booking status — the booking is already
+ * PAID/allocatable and the contract can be signed at any time. Idempotent.
+ */
+ async generateContractForGovernment(bookingId: string): Promise {
+ const booking = await this.requireBooking(bookingId);
+ if (!booking.isGovernment || booking.contractGeneratedAt) return;
+ await this.bookingsRepository.update(bookingId, {
+ contractSummary: this.buildContractSummary(booking),
+ contractTemplateKey: this.templateResolver.resolve(booking),
+ contractGeneratedAt: new Date(),
+ } as never);
+ }
+
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
@@ -152,8 +162,13 @@ export class BookingContractService {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
+ // Government contracts are order-free and status-free: either party may
+ // sign at any time (each once) — the booking is already expedited past the
+ // customer contract flow, so no status gate applies.
if (role === 'CUSTOMER') {
- assertBookingStatus(booking, ['CONTRACT_READY']);
+ if (!booking.isGovernment) {
+ assertBookingStatus(booking, ['CONTRACT_READY']);
+ }
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
@@ -162,7 +177,9 @@ export class BookingContractService {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
- assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+ if (!booking.isGovernment) {
+ assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
+ }
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
@@ -235,20 +252,30 @@ export class BookingContractService {
);
if (role === 'CUSTOMER') {
- updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
+ // Government bookings keep their operational status (PAID) — a signature
+ // must never pull them back into the customer workflow.
+ if (!booking.isGovernment) updates.status = 'SIGNED_CUSTOMER';
} else {
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
- updates.lockedAt = now;
- updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
+ if (!booking.isGovernment) {
+ updates.lockedAt = now;
+ updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
+ }
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
- // clearance bookings enter operations after the GL document gate.
- if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
+ // clearance bookings enter operations after the GL document gate. Government
+ // bookings are already in the pool from expedite — signing changes nothing.
+ if (
+ role === 'STAFF' &&
+ !booking.isGovernment &&
+ !clearanceCode &&
+ updated?.trainScheduleId
+ ) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {
diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
index 2c5e6ed88..d54bd1190 100644
--- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
+import { BookingBatchService } from '../train-scheduling/booking-batch.service';
+import { BookingContractService } from './booking-contract.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -103,6 +105,10 @@ export class BookingsService {
private readonly vehiclesService: VehiclesService,
private readonly pdfRender: PdfRenderService,
private readonly events: EventEmitter2,
+ @Inject(forwardRef(() => BookingContractService))
+ private readonly bookingContractService: BookingContractService,
+ @Inject(forwardRef(() => BookingBatchService))
+ private readonly bookingBatchService: BookingBatchService,
) {}
async assignCustomerTruck(
@@ -982,6 +988,21 @@ export class BookingsService {
warnings.push(...consolidation.messages);
}
+ // Government bookings pass every customer step at creation: the server
+ // expedites them to PAID/Eligible, generates the contract (signable at any
+ // time) and queues priority placement. Best-effort — the booking row is
+ // already inserted, so a late failure must not 500 the whole create; the
+ // idempotent expedite endpoint remains the retry path.
+ if (isGovernment) {
+ try {
+ full = await this.governmentExpedite(booking.id, userId ?? 'system');
+ } catch (err) {
+ warnings.push(
+ `Government expedite incomplete — retry via the expedite action: ${(err as Error).message}`,
+ );
+ }
+ }
+
return { booking: full, warnings };
}
@@ -1830,13 +1851,22 @@ export class BookingsService {
return false;
}
- /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
+ /**
+ * Expedite a government booking past every customer step: PAID + Eligible
+ * (no commercial hold, no payment), contract generated server-side (signable
+ * at any time), and the (route, day) fill kicked immediately so it grabs a
+ * seat on any open train — government-first, preempting commercial cargo if
+ * the day is full. Runs automatically at creation; the endpoint remains as a
+ * no-op-safe retry for older bookings.
+ */
async governmentExpedite(id: string, staffUserId: string): Promise {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
- const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
+ // Idempotent: create() already expedites — a repeat call changes nothing.
+ if (booking.status === 'PAID') return booking;
+ const blocked = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
@@ -1848,12 +1878,22 @@ export class BookingsService {
holdStartedAt: null,
holdExpiresAt: null,
});
+ await this.bookingContractService.generateContractForGovernment(id);
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
+ // Priority placement: run the day-level fill now instead of waiting for a
+ // batch tick — the pool sorts government first and preempts if needed.
+ if (booking.scheduledDate) {
+ this.bookingBatchService.enqueueRouteDayProcessing(
+ booking.originYardId,
+ booking.destinationYardId,
+ eatDay(booking.scheduledDate),
+ );
+ }
return this.findById(id);
}
diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
index 919652af6..0dceadf43 100644
--- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
+++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts
@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
@ApiPropertyOptional()
signatureImageUrl?: string | null;
+
+ @ApiPropertyOptional()
+ stampImageUrl?: string | null;
}
export class ContractViewDto {
diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
index e84eb6124..fefbbc868 100644
--- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
+++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts
@@ -139,6 +139,17 @@ export class SchedulingRescheduleService {
actorUserId?: string,
) {
const plan = await this.previewReschedule(scheduleId, dto);
+ // Gov bookings may never be pushed off a train. Checked here (not only in
+ // unassignBooking) because the displacement loop below swallows unassign
+ // errors and force-detaches the booking anyway.
+ const govDisplaced = plan.displaced.filter((b) => b.isGovernment);
+ if (govDisplaced.length) {
+ throw new BadRequestException(
+ `Government bookings cannot be removed from a train: ${govDisplaced
+ .map((b) => b.reference)
+ .join(', ')}`,
+ );
+ }
const expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
const providedDisplaced = new Set(dto.displacedBookingIds);
if (
diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
index a5ae63100..a1f245e23 100644
--- a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
+++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts
@@ -1,5 +1,5 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsString, MinLength } from 'class-validator';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsOptional, IsString, MinLength } from 'class-validator';
export class SaveSignatureDto {
@ApiProperty()
@@ -13,6 +13,15 @@ export class SaveSignatureDto {
@IsString()
@MinLength(20)
signatureImageBase64!: string;
+
+ @ApiPropertyOptional({
+ description:
+ 'Company stamp/seal image as base64 (with or without data URL prefix). Omit to keep the existing saved stamp.',
+ })
+ @IsOptional()
+ @IsString()
+ @MinLength(20)
+ stampImageBase64?: string;
}
export class SavedSignatureDto {
@@ -21,4 +30,7 @@ export class SavedSignatureDto {
@ApiProperty({ nullable: true })
signatureImageUrl!: string | null;
+
+ @ApiProperty({ nullable: true })
+ stampImageUrl!: string | null;
}
diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
index 08263cf7d..3814eb017 100644
--- a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
+++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts
@@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity {
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
+
+ @Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
+ stampFileId?: string | null;
+
+ @ManyToOne(() => FileRecord, { nullable: true })
+ @JoinColumn({ name: 'stamp_file_id' })
+ stampFile?: FileRecord | null;
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
index d112edef3..24dd0aa5c 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts
@@ -33,6 +33,7 @@ export class SignaturesController {
userId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
+ stampImageBase64: dto.stampImageBase64,
});
return this.signaturesService.getForUser(userId);
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
index 70c04ad7b..de8007d65 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts
@@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository {
findByUserId(userId: string): Promise {
return this.repository.findOne({
where: { userId } as never,
- relations: ['signatureFile'],
+ relations: ['signatureFile', 'stampFile'],
});
}
diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
index 7137ab6a5..836888dab 100644
--- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
+++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts
@@ -13,6 +13,8 @@ export interface UpsertSignatureInput {
userId: string;
signerDisplayName: string;
signatureImageBase64: string;
+ /** Optional company stamp/seal; omitted = keep the existing saved stamp. */
+ stampImageBase64?: string;
}
@Injectable()
@@ -31,15 +33,65 @@ export class SignaturesService {
return {
signerDisplayName: saved.signerDisplayName,
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
+ stampImageUrl: await this.inlineImageUrl(saved.stampFile?.url),
};
}
- /** Insert or update the user's reusable signature, storing the image in MinIO. */
+ /** Insert or update the user's reusable signature (and optional stamp), storing the images in MinIO. */
async upsertForUser(input: UpsertSignatureInput): Promise {
- const buffer = this.decodeSignatureImage(input.signatureImageBase64);
- const file: Express.Multer.File = {
- fieldname: 'signature',
- originalname: `signature-${input.userId}.png`,
+ // Capture the previously referenced files so we can remove them only AFTER
+ // the saved_signatures row is repointed — deleting first would violate the
+ // FK constraint (saved_signatures.*_file_id -> files.id).
+ const existing = await this.signaturesRepository.findByUserId(input.userId);
+ const previousFileId = existing?.signatureFileId ?? null;
+ const previousStampFileId = existing?.stampFileId ?? null;
+
+ const fileRecord = await this.filesService.upload({
+ resourceId: input.userId,
+ resource: 'saved_signatures',
+ code: 'signature',
+ file: this.toUploadFile('signature', input.userId, input.signatureImageBase64),
+ });
+
+ const stampRecord = input.stampImageBase64
+ ? await this.filesService.upload({
+ resourceId: input.userId,
+ resource: 'saved_signatures',
+ code: 'stamp',
+ file: this.toUploadFile('stamp', input.userId, input.stampImageBase64),
+ })
+ : null;
+
+ const saved = await this.signaturesRepository.upsert({
+ userId: input.userId,
+ signerDisplayName: input.signerDisplayName,
+ signatureFileId: fileRecord.id,
+ // Omitted stamp keeps whatever was saved before.
+ ...(stampRecord ? { stampFileId: stampRecord.id } : {}),
+ });
+
+ const staleIds = [
+ previousFileId !== fileRecord.id ? previousFileId : null,
+ stampRecord && previousStampFileId !== stampRecord.id
+ ? previousStampFileId
+ : null,
+ ].filter((id): id is string => Boolean(id));
+ if (staleIds.length) {
+ await this.dataSource.getRepository(FileRecord).delete(staleIds);
+ }
+
+ return saved;
+ }
+
+ private toUploadFile(
+ kind: 'signature' | 'stamp',
+ userId: string,
+ base64: string,
+ ): Express.Multer.File {
+ const buffer = this.decodeSignatureImage(base64);
+ return {
+ fieldname: kind,
+ originalname: `${kind}-${userId}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
@@ -49,33 +101,6 @@ export class SignaturesService {
filename: '',
path: '',
};
-
- // Capture the previously referenced file so we can remove it only AFTER the
- // saved_signatures row is repointed — deleting it first would violate the
- // FK constraint (saved_signatures.signature_file_id -> files.id).
- const existing = await this.signaturesRepository.findByUserId(input.userId);
- const previousFileId = existing?.signatureFileId ?? null;
-
- const fileRecord = await this.filesService.upload({
- resourceId: input.userId,
- resource: 'saved_signatures',
- code: 'signature',
- file,
- });
-
- const saved = await this.signaturesRepository.upsert({
- userId: input.userId,
- signerDisplayName: input.signerDisplayName,
- signatureFileId: fileRecord.id,
- });
-
- if (previousFileId && previousFileId !== fileRecord.id) {
- await this.dataSource
- .getRepository(FileRecord)
- .delete({ id: previousFileId });
- }
-
- return saved;
}
private async inlineImageUrl(
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index 92f96dfa5..06e8a5f1c 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
@@ -1475,7 +1475,9 @@ export class BookingBatchService implements OnModuleInit {
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
- trainSet: { locomotive: true, train: true },
+ // locomotives (plural) too — the caps SUM the whole set's pull; the
+ // single legacy column alone under-reports a two-loco train by half.
+ trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below;
@@ -1538,7 +1540,21 @@ export class BookingBatchService implements OnModuleInit {
};
});
- board.push(this.buildScheduleSummary(s, items));
+ board.push(
+ this.buildScheduleSummary(
+ s,
+ items,
+ new Map(
+ bookings.map((b) => [
+ b.id,
+ {
+ originYardId: b.originYardId ?? null,
+ destinationYardId: b.destinationYardId ?? null,
+ },
+ ]),
+ ),
+ ),
+ );
}
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
@@ -1853,47 +1869,63 @@ export class BookingBatchService implements OnModuleInit {
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
- // Per-leg committed weight: a booking holds weight only on the edges it
- // rides, so the meter compares the HEAVIEST single edge against the pull
- // limit. Whole-route bookings (or yards missing from the stop list) load
- // every edge — never under-reported.
+ // Per-leg committed usage: a booking holds capacity only on the edges it
+ // rides, so every meter compares the HEAVIEST single edge against its cap
+ // — weight, wagons and length alike. Whole-route bookings (or yards
+ // missing from the stop list) load every edge — never under-reported.
const stops = legCtx?.stops ?? [];
let usedWeightTons = round2(
committed.reduce((sum, i) => sum + i.weightTons, 0),
);
+ let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0);
+ let allocatedLengthMeters = round2(
+ allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
+ );
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
if (legCtx && stops.length > 2) {
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
- const edges = new Array(stops.length - 1).fill(0);
- for (const item of committed) {
- const yards = legCtx.yardsByBookingId.get(item.id);
+ const edgeCount = stops.length - 1;
+ const legOf = (bookingId: string): { from: number; to: number } => {
+ const yards = legCtx.yardsByBookingId.get(bookingId);
const from = yards?.originYardId
? stopIndex.get(yards.originYardId)
: undefined;
const to = yards?.destinationYardId
? stopIndex.get(yards.destinationYardId)
: undefined;
- const leg =
- from != null && to != null && from < to
- ? { from, to }
- : { from: 0, to: edges.length };
- for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons;
+ return from != null && to != null && from < to
+ ? { from, to }
+ : { from: 0, to: edgeCount };
+ };
+ const weightEdges = new Array(edgeCount).fill(0);
+ for (const item of committed) {
+ const leg = legOf(item.id);
+ for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons;
+ }
+ const wagonEdges = new Array(edgeCount).fill(0);
+ const lengthEdges = new Array(edgeCount).fill(0);
+ for (const item of allocated) {
+ const leg = legOf(item.id);
+ for (let e = leg.from; e < leg.to; e += 1) {
+ wagonEdges[e] += item.wagons;
+ lengthEdges[e] += item.lengthMeters;
+ }
}
const label = (yardId: string) =>
legCtx.labelByYardId.get(yardId) ?? yardId;
- legUsage = edges.map((weight, i) => ({
+ legUsage = weightEdges.map((weight, i) => ({
from: label(stops[i]),
to: label(stops[i + 1]),
usedWeightTons: round2(weight),
}));
- usedWeightTons = round2(Math.max(0, ...edges));
+ usedWeightTons = round2(Math.max(0, ...weightEdges));
+ allocatedWagons = Math.max(0, ...wagonEdges);
+ allocatedLengthMeters = round2(Math.max(0, ...lengthEdges));
}
return {
- allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
- allocatedLengthMeters: round2(
- allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
- ),
+ allocatedWagons,
+ allocatedLengthMeters,
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons,
maxWeightTons: caps ? caps.maxWeightTons : null,
@@ -1919,11 +1951,46 @@ export class BookingBatchService implements OnModuleInit {
return new Map(yards.map((y) => [y.id, y.label ?? y.code]));
}
+ /**
+ * Corridor stops + labels from the already-loaded route graph (milestones
+ * with yards) — the list flow must not fire a query per schedule row.
+ */
+ private stopsFromGraph(s: TrainSchedule): {
+ stops: string[];
+ labelByYardId: Map;
+ } {
+ const milestones = [...(s.route?.milestones ?? [])].sort(
+ (a, b) => a.sequenceNo - b.sequenceNo,
+ );
+ const stops: string[] = [];
+ const labelByYardId = new Map();
+ const push = (yardId?: string | null, label?: string | null) => {
+ if (!yardId || labelByYardId.has(yardId)) return;
+ stops.push(yardId);
+ labelByYardId.set(yardId, label ?? yardId);
+ };
+ if (milestones.length >= 2) {
+ for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code);
+ } else {
+ push(s.originStationId, s.originStation?.label ?? s.originStation?.code);
+ push(
+ s.destinationStationId,
+ s.destinationStation?.label ?? s.destinationStation?.code,
+ );
+ }
+ return { stops, labelByYardId };
+ }
+
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
+ yardsByBookingId: Map<
+ string,
+ { originYardId: string | null; destinationYardId: string | null }
+ >,
): BatchBoardSchedule {
const loco = trainSetLocomotiveLimits(s.trainSet);
+ const { stops, labelByYardId } = this.stopsFromGraph(s);
return {
scheduleId: s.id,
@@ -1966,9 +2033,9 @@ export class BookingBatchService implements OnModuleInit {
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
- stops: [],
- labelByYardId: new Map(),
- yardsByBookingId: new Map(),
+ stops,
+ labelByYardId,
+ yardsByBookingId,
trainLengthMeters: this.builtTrainLengthOf(s),
}),
counts: {
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts
new file mode 100644
index 000000000..c72a1879b
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/switch-government-booking.dto.ts
@@ -0,0 +1,18 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
+
+export class SwitchGovernmentBookingDto {
+ @ApiProperty({ format: 'uuid', description: 'Government booking to allocate onto the train' })
+ @IsUUID()
+ governmentBookingId!: string;
+
+ @ApiProperty({
+ format: 'uuid',
+ isArray: true,
+ description: 'Assigned commercial bookings to switch out in its place',
+ })
+ @IsArray()
+ @ArrayNotEmpty()
+ @IsUUID('4', { each: true })
+ removeBookingIds!: string[];
+}
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index b7e9a750f..4f0c8903a 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
@@ -19,6 +19,7 @@ import {
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
+import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
@@ -408,6 +409,25 @@ export class TrainSchedulingController {
);
}
+ @Post("schedules/:id/switch-government-booking")
+ @TrainSchedulingUpdate()
+ @ApiOperation({
+ summary:
+ "Switch out commercial bookings to allocate a government booking in their place",
+ })
+ switchGovernmentBooking(
+ @Param("id", ParseUUIDPipe) id: string,
+ @Body() dto: SwitchGovernmentBookingDto,
+ @CurrentUser() user: AuthUserPayload,
+ ) {
+ return this.trainSchedulingService.switchGovernmentBooking(
+ id,
+ dto.governmentBookingId,
+ dto.removeBookingIds,
+ resolveAuthUserId(user),
+ );
+ }
+
@Get("schedules/:id/composition-removals")
@TrainSchedulingView()
@ApiOperation({ summary: "Get removal log for a schedule" })
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
index 0d724a040..80030c6c3 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
@@ -1351,4 +1351,88 @@ describe('TrainSchedulingService', () => {
).rejects.toThrow(/over its/);
});
});
+
+ describe('government booking protection', () => {
+ const scheduleId = 'sched-gov-1';
+ const govBooking = makeBooking('gov-1', 'BKG-GOV', 200, 10, '20FT', 10, undefined, undefined, undefined, {
+ isGovernment: true,
+ wagonsRequired: 10,
+ });
+ const commercial = makeBooking('bk-1', 'BKG-COM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
+ wagonsRequired: 5,
+ });
+
+ const scheduleGraph = {
+ id: scheduleId,
+ status: 'DRAFT',
+ scheduledDepartureDate: new Date('2026-06-20T08:00:00.000Z'),
+ originStationId: 'yard-origin',
+ destinationStationId: 'yard-destination',
+ trainSetId: 'ts-1',
+ trainSet: {
+ id: 'ts-1',
+ locomotive,
+ wagons: [{ id: 'tsw-1' }, { id: 'tsw-2' }],
+ },
+ scheduleBookings: [{ bookingId: 'gov-1' }, { bookingId: 'bk-1' }],
+ };
+
+ it('unassignBooking rejects a government booking', async () => {
+ trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
+ bookingsRepository.findById = jest.fn().mockResolvedValue(govBooking);
+
+ await expect(service.unassignBooking(scheduleId, 'gov-1')).rejects.toThrow(
+ /Government bookings cannot be removed/,
+ );
+ });
+
+ it('switchGovernmentBooking rejects a non-government incoming booking', async () => {
+ trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
+ bookingsRepository.findByIdsForScheduling.mockResolvedValueOnce([commercial]);
+
+ await expect(
+ service.switchGovernmentBooking(scheduleId, 'bk-1', ['gov-1']),
+ ).rejects.toThrow(/Only government bookings/);
+ });
+
+ it('switchGovernmentBooking rejects when the freed wagons are fewer than the government booking needs', async () => {
+ trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(scheduleGraph);
+ dataSource.getRepository.mockImplementation((entity: unknown) => {
+ if (entity === WagonBookingAllocation) {
+ return { find: jest.fn().mockResolvedValue([{ bookingId: 'bk-1' }]) };
+ }
+ return { find: jest.fn().mockResolvedValue([]) };
+ });
+ bookingsRepository.findByIdsForScheduling.mockImplementation((ids: string[]) =>
+ Promise.resolve(
+ ids.map((id) => (id === 'gov-1' ? govBooking : commercial)),
+ ),
+ );
+ jest
+ .spyOn(service as never as { resolveTrainLimitConfig: () => unknown }, 'resolveTrainLimitConfig')
+ .mockResolvedValue({} as never);
+ // Gov booking fits the plan (10 slots) but the switched-out booking only
+ // frees 5 wagons — the user-facing wagon rule must still reject it.
+ jest
+ .spyOn(
+ service as never as { validateBookingsForScheduling: () => unknown },
+ 'validateBookingsForScheduling',
+ )
+ .mockResolvedValue({
+ valid: true,
+ violations: [],
+ warnings: [],
+ deferredBookings: [],
+ bookings: [govBooking],
+ wagonPlan: Array.from({ length: 10 }, (_, i) => ({
+ sequenceNo: i + 1,
+ allocations: [{ bookingId: 'gov-1' }],
+ })),
+ } as never);
+
+ await expect(
+ service.switchGovernmentBooking(scheduleId, 'gov-1', ['bk-1']),
+ ).rejects.toThrow(/free only 5/);
+ });
+ });
});
diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index dc7283c37..e4af8e6b5 100644
--- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
+++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
@@ -1886,6 +1886,11 @@ export class TrainSchedulingService {
}
const booking = await this.bookingsRepository.findById(bookingId);
+ if (booking?.isGovernment) {
+ throw new BadRequestException(
+ 'Government bookings cannot be removed from a train. They can only be switched onto another allocation.',
+ );
+ }
const bookingReference = booking?.reference ?? null;
await this.dataSource.transaction(async (manager) => {
@@ -5384,6 +5389,7 @@ export class TrainSchedulingService {
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
status: booking.status,
+ isGovernment: Boolean(booking.isGovernment),
};
}
@@ -7189,6 +7195,7 @@ export class TrainSchedulingService {
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
+ isGovernment: Boolean(sb.booking?.isGovernment),
})) ?? [],
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
@@ -7361,6 +7368,142 @@ export class TrainSchedulingService {
);
}
+ /**
+ * Government-priority switch: free wagons by unassigning the selected
+ * commercial bookings, then allocate the government booking in their place.
+ * The gov booking must need no more wagons than the switched-out bookings
+ * free (ops selects more bookings otherwise), and the post-switch
+ * composition is fully validated BEFORE anything is unassigned so a failing
+ * switch never leaves the train half-emptied.
+ */
+ async switchGovernmentBooking(
+ scheduleId: string,
+ governmentBookingId: string,
+ removeBookingIds: string[],
+ userId?: string,
+ ) {
+ if (removeBookingIds.includes(governmentBookingId)) {
+ throw new BadRequestException('Government booking cannot be switched out by itself');
+ }
+
+ const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
+ if (!schedule) {
+ throw new NotFoundException(`Train schedule ${scheduleId} not found`);
+ }
+ if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
+ throw new BadRequestException(
+ `Cannot switch bookings on a schedule in status ${schedule.status}`,
+ );
+ }
+
+ const [govBooking] = await this.bookingsRepository.findByIdsForScheduling([
+ governmentBookingId,
+ ]);
+ if (!govBooking) {
+ throw new NotFoundException(`Booking ${governmentBookingId} not found`);
+ }
+ if (!govBooking.isGovernment) {
+ throw new BadRequestException('Only government bookings can be switched onto a train');
+ }
+
+ const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
+ if (wagonAssignedIds.has(governmentBookingId)) {
+ throw new BadRequestException('Government booking is already allocated on this train');
+ }
+
+ const removed = await this.bookingsRepository.findByIdsForScheduling(removeBookingIds);
+ if (removed.length !== removeBookingIds.length) {
+ throw new NotFoundException('One or more bookings to switch out were not found');
+ }
+ const notOnTrain = removed.filter((b) => !wagonAssignedIds.has(b.id));
+ if (notOnTrain.length) {
+ throw new BadRequestException(
+ `Not allocated on this train: ${notOnTrain.map((b) => b.reference).join(', ')}`,
+ );
+ }
+ const govRemoved = removed.filter((b) => b.isGovernment);
+ if (govRemoved.length) {
+ throw new BadRequestException(
+ `Government bookings cannot be switched out: ${govRemoved.map((b) => b.reference).join(', ')}`,
+ );
+ }
+
+ // Dry-run the post-switch composition: survivors + the gov booking.
+ const survivorIds = [...wagonAssignedIds].filter((id) => !removeBookingIds.includes(id));
+ const previewDto = {
+ bookingIds: [...survivorIds, governmentBookingId],
+ scheduleDate: schedule.scheduledDepartureDate.toISOString(),
+ originStationId: schedule.originStationId,
+ destinationStationId: schedule.destinationStationId,
+ };
+ const limits = await this.resolveTrainLimitConfig(
+ undefined,
+ trainSetLocomotiveLimits(schedule.trainSet),
+ );
+ const validation = await this.validateBookingsForScheduling(
+ previewDto,
+ null,
+ false,
+ [],
+ false,
+ limits,
+ scheduleId,
+ );
+ const freedWagons = removed.reduce((sum, b) => sum + Number(b.wagonsRequired ?? 0), 0);
+ if (!validation.valid) {
+ throw new BadRequestException({
+ message: `Switch validation failed: ${validation.violations.join('; ')}`,
+ violations: validation.violations,
+ warnings: validation.warnings,
+ });
+ }
+ if (!validation.bookings.some((b) => b.id === governmentBookingId)) {
+ throw new BadRequestException(
+ `Switching out ${removed.map((b) => b.reference).join(', ')} frees ${freedWagons} wagon(s) — not enough for this government booking. Select more bookings to switch out.`,
+ );
+ }
+ const govWagons = sumWagonsRequired(govBooking, validation.wagonPlan);
+ if (govWagons > freedWagons) {
+ throw new BadRequestException(
+ `Government booking needs ${govWagons} wagon(s) but the selected bookings free only ${freedWagons}. Select more bookings to switch out.`,
+ );
+ }
+
+ // Same container-number gate as single-booking assignment, applied to the
+ // incoming gov booking only.
+ const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
+ const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
+ const slots = getContainerSlotSequenceNos(validation.wagonPlan);
+ const placements = autoFillPlacements(units, slots);
+ const missingForGov = findMissingContainerNumberIssues(units, placements).find(
+ (m) => m.bookingId === governmentBookingId,
+ );
+ if (missingForGov) {
+ throw new BadRequestException({
+ message: missingForGov.issue,
+ violations: [missingForGov.issue],
+ });
+ }
+
+ // ponytail: unassign + assign run as sequential own-transaction steps, not
+ // one atomic unit — the dry-run above means the assign step can only fail
+ // on a concurrent edit; staff re-add from the eligible pool if it does.
+ for (const booking of removed) {
+ await this.unassignBooking(scheduleId, booking.id, userId);
+ }
+
+ const assignableSet = new Set(validation.bookings.map((b) => b.id));
+ const assignPlacements = placementsForBookings(placements, assignableSet, units);
+ return this.assignBookingsToSchedule(
+ scheduleId,
+ {
+ bookingIds: validation.bookings.map((b) => b.id),
+ containerPlacements: containerBookings.length > 0 ? assignPlacements : undefined,
+ },
+ undefined,
+ );
+ }
+
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx
index 5f4f4d7a7..ca895b70b 100644
--- a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx
@@ -5,6 +5,7 @@ import toast from "react-hot-toast";
import { api } from "@/services/api";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import { StampUpload } from "@/components/contracts/StampUpload";
import {
Card,
CardContent,
@@ -40,6 +41,7 @@ export function MySignatureCard() {
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState(null);
+ const [stampData, setStampData] = useState(null);
const defaultName =
user?.name?.en || user?.username || user?.email || "";
@@ -47,6 +49,7 @@ export function MySignatureCard() {
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
setSignatureData(null);
+ setStampData(saved?.stampImageUrl ?? null);
setOpen(true);
};
@@ -56,6 +59,10 @@ export function MySignatureCard() {
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
+ // Only send the stamp when it changed — omitted keeps the saved one.
+ ...(stampData && stampData !== saved?.stampImageUrl
+ ? { stampImageBase64: stampData }
+ : {}),
},
{
onSuccess: () => {
@@ -101,6 +108,18 @@ export function MySignatureCard() {
You have not saved a signature yet.
)}
+ {saved?.stampImageUrl && (
+
+
+

+
+
Company stamp
+
+ )}
@@ -126,6 +145,11 @@ export function MySignatureCard() {
/>
+
+ )}
{booking.customsClearingEnabled && (
{
if (isGovernment) {
+ // The server already expedited + generated the contract at creation;
+ // this call is an idempotent no-op that doubles as a retry if that
+ // best-effort step failed.
await bookingsService.governmentExpedite(booking.id);
- toast.success("Government booking created and expedited to scheduling");
+ toast.success(
+ "Government booking created — contract generated, priority scheduling queued",
+ );
} else {
toast.success("Booking created as draft");
}
@@ -891,7 +896,7 @@ export default function NewBookingPage() {
{isGovernment
- ? "Government bookings skip the commercial 3-hour hold and enter the priority lane."
+ ? "Government bookings skip every customer step: paid & eligible immediately, contract generated automatically (signable any time), priority seat on any open train of the route."
: "Overweight container lines are allowed here and flagged later at scheduling."}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx
index 89fc51225..b49b0e25c 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx
@@ -98,7 +98,8 @@ export default function ContractViewPage() {
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
- setStampData(null);
+ // Prefill with the reusable stamp saved on the profile; still replaceable.
+ setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index d87fa50b8..d26f27363 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -56,6 +56,7 @@ import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTr
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
+import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
@@ -79,6 +80,7 @@ import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
import type {
ContainerPlacement,
+ EligibleContainerBooking,
FreightType,
TrainSchedulePreviewResponse,
} from "@/types/trainScheduling";
@@ -110,6 +112,7 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
+ const [switchTarget, setSwitchTarget] = useState(null);
const autoPreviewedRef = useRef(false);
const detailQuery = useQuery(
@@ -192,6 +195,7 @@ export default function TrainScheduleV2DetailPage() {
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
+ const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
const downloadMarshalling = useMutation({
@@ -511,6 +515,17 @@ export default function TrainScheduleV2DetailPage() {
};
const handleUnassign = async (bookingId: string) => {
+ // Gov bookings never leave a train by removal — only by switching. The API
+ // enforces this too; the guard here just gives the warning without a call.
+ if (schedule?.bookings?.some((b) => b.id === bookingId && b.isGovernment)) {
+ toast({
+ title: "Government booking cannot be removed",
+ description:
+ "Government bookings cannot be removed from the train. They can only be switched onto another allocation.",
+ variant: "destructive",
+ });
+ return;
+ }
try {
await unassign.mutateAsync({ id: scheduleId, bookingId });
toast({ title: "Booking unassigned" });
@@ -631,6 +646,7 @@ export default function TrainScheduleV2DetailPage() {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
weightTons: b.weightTons,
+ isGovernment: b.isGovernment,
}))}
eligibleItems={eligibleQuery.data?.items ?? []}
eligibleLoading={eligibleQuery.isLoading}
@@ -643,6 +659,7 @@ export default function TrainScheduleV2DetailPage() {
freightType={freightType}
canRemove={canModifyBookings}
onRemove={handleUnassign}
+ onSwitch={canModifyBookings ? setSwitchTarget : undefined}
/>
{canEditBookings ? (
@@ -1274,6 +1291,36 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
+ setSwitchTarget(null)}
+ govBooking={switchTarget}
+ assignedBookings={schedule.bookings ?? []}
+ loading={switchGov.isPending}
+ onConfirm={async (removeBookingIds) => {
+ if (!scheduleId || !switchTarget) return;
+ try {
+ await switchGov.mutateAsync({
+ id: scheduleId,
+ governmentBookingId: switchTarget.id,
+ removeBookingIds,
+ });
+ toast({ title: `Government booking ${switchTarget.reference} switched onto the train` });
+ setSwitchTarget(null);
+ setSelectedBookingIds([]);
+ setPreviewResult(null);
+ autoPreviewedRef.current = false;
+ } catch (err) {
+ toast({
+ title: "Switch failed",
+ description: parseError(err, "Could not switch the government booking"),
+ variant: "destructive",
+ });
+ }
+ }}
+ />
+
setDispatchConfirmOpen(false)}
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index a0fa8a35e..7385343a2 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -659,6 +659,22 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
+ switchGovernmentBooking: endpoint<
+ { id: string; governmentBookingId: string; removeBookingIds: string[] },
+ TrainScheduleDetail
+ >(
+ "train-scheduling",
+ "switch-government-booking",
+ ({ id, governmentBookingId, removeBookingIds }) =>
+ trainSchedulingService.switchGovernmentBooking(
+ id,
+ governmentBookingId,
+ removeBookingIds,
+ ),
+ undefined,
+ () => TRAIN_SCHEDULING_INVALIDATIONS,
+ ),
+
setLoadingStatus: endpoint<
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
TrainScheduleDetail
diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
index c020886fb..c0a397d7f 100644
--- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts
@@ -114,6 +114,7 @@ export interface ContractView {
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
+ stampImageUrl?: string | null;
} | null;
}
diff --git a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts
index 163ec2d74..577e3b4de 100644
--- a/apps/edr-freight-web/backoffice/src/services/signatures.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/signatures.service.ts
@@ -6,11 +6,14 @@ const SIGNATURE_URL = "/me/signature";
export interface SavedSignature {
signerDisplayName: string;
signatureImageUrl?: string | null;
+ stampImageUrl?: string | null;
}
export interface SaveSignaturePayload {
signerDisplayName: string;
signatureImageBase64: string;
+ /** Omit to keep the existing saved stamp. */
+ stampImageBase64?: string;
}
export const signaturesService = {
diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
index c176061e1..063b3e8d6 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts
@@ -367,6 +367,18 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
+ switchGovernmentBooking: async (
+ scheduleId: string,
+ governmentBookingId: string,
+ removeBookingIds: string[],
+ ): Promise => {
+ const response = await client.post(
+ URL_CONSTANTS.TRAIN_SCHEDULING.SWITCH_GOVERNMENT_BOOKING(scheduleId),
+ { governmentBookingId, removeBookingIds },
+ );
+ return unwrap(response.data);
+ },
+
pinWagons: async (
scheduleId: string,
payload: PinWagonsPayload,
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index 2dc7662b4..3ac115a76 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -38,6 +38,7 @@ export interface EligibleContainerBooking {
status: string;
schedulingStatus?: SchedulingStatus;
priorityScore?: number;
+ isGovernment?: boolean;
}
export interface EligibleContainerBookingsResponse {
@@ -686,6 +687,7 @@ export interface TrainScheduleDetail {
arrivedAt?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
+ isGovernment?: boolean;
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
index 4555a5db8..7a9d942f8 100644
--- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
+++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx
@@ -137,8 +137,15 @@ export function InitiateBookingButton({
setConfirmOpen(false);
navigate(`/bookings/${booking.id}`);
},
- onError: (e: Error) =>
- toast.error(e.message || "Could not initiate the booking"),
+ onError: (e: Error) => {
+ const data = (
+ e as { response?: { data?: { message?: string | string[] } } }
+ ).response?.data;
+ const message = Array.isArray(data?.message)
+ ? data.message.join(", ")
+ : data?.message;
+ toast.error(message || e.message || "Could not initiate the booking");
+ },
});
return (
diff --git a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx
index 8eb0b8e4e..edb72b91f 100644
--- a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx
+++ b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { FileSignature, Loader2 } from "lucide-react";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
+import { StampUpload } from "@/components/contracts/StampUpload";
import useAuth from "@/hooks/useAuth";
import {
useMySignature,
@@ -37,12 +38,14 @@ export function MySignatureCard() {
const [open, setOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState(null);
+ const [stampData, setStampData] = useState(null);
const defaultName = user?.name?.en || user?.username || user?.email || "";
const openDialog = () => {
setSignerName(saved?.signerDisplayName ?? defaultName);
setSignatureData(null);
+ setStampData(saved?.stampImageUrl ?? null);
setOpen(true);
};
@@ -52,6 +55,10 @@ export function MySignatureCard() {
{
signerDisplayName: signerName.trim(),
signatureImageBase64: signatureData,
+ // Only send the stamp when it changed — omitted keeps the saved one.
+ ...(stampData && stampData !== saved?.stampImageUrl
+ ? { stampImageBase64: stampData }
+ : {}),
},
{ onSuccess: () => setOpen(false) },
);
@@ -91,6 +98,18 @@ export function MySignatureCard() {
You have not saved a signature yet.
)}
+ {saved?.stampImageUrl && (
+
+
+

+
+
Company stamp
+
+ )}
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
@@ -116,6 +135,11 @@ export function MySignatureCard() {
/>
+
setOpen(false)}>
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx
index c8fc97921..c525b03c4 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx
@@ -164,7 +164,8 @@ export default function ContractViewPage() {
if (!canProceedToSign) return;
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
- setStampData(null);
+ // Prefill with the reusable stamp saved on the profile; still replaceable.
+ setStampData(data?.savedSignature?.stampImageUrl ?? null);
setDrawNew(false);
setSignOpen(true);
};
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 79d61a1a9..602c477bb 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -50,6 +50,7 @@ export interface ContractView {
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
+ stampImageUrl?: string | null;
} | null;
}
diff --git a/apps/edr-freight-web/portal/src/services/signatures.service.ts b/apps/edr-freight-web/portal/src/services/signatures.service.ts
index 05ae61ea1..052e26b07 100644
--- a/apps/edr-freight-web/portal/src/services/signatures.service.ts
+++ b/apps/edr-freight-web/portal/src/services/signatures.service.ts
@@ -5,11 +5,14 @@ const SIGNATURE_URL = "/api/me/signature";
export interface SavedSignature {
signerDisplayName: string;
signatureImageUrl?: string | null;
+ stampImageUrl?: string | null;
}
export interface SaveSignaturePayload {
signerDisplayName: string;
signatureImageBase64: string;
+ /** Omit to keep the existing saved stamp. */
+ stampImageBase64?: string;
}
export const signaturesService = {