mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
add goverment booking
This commit is contained in:
@@ -157,10 +157,14 @@ export class ContractViewModelBuilder {
|
|||||||
pricing,
|
pricing,
|
||||||
rateSchedule,
|
rateSchedule,
|
||||||
signatures,
|
signatures,
|
||||||
canSignCustomer:
|
// Government contracts are generated at creation and signable at any
|
||||||
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
// time, in any order — no status gate, no customer-first sequencing.
|
||||||
canSignStaff:
|
canSignCustomer: booking.isGovernment
|
||||||
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
? !hasCustomer
|
||||||
|
: booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||||
|
canSignStaff: booking.isGovernment
|
||||||
|
? !hasStaff
|
||||||
|
: booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
hasContractDocument: hasContractFile,
|
hasContractDocument: hasContractFile,
|
||||||
hasCustomerSignature: hasCustomer,
|
hasCustomerSignature: hasCustomer,
|
||||||
hasStaffSignature: hasStaff,
|
hasStaffSignature: hasStaff,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddSavedSignatureStamp3090000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.saved_signatures
|
||||||
|
ADD COLUMN IF NOT EXISTS stamp_file_id UUID NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.saved_signatures
|
||||||
|
DROP COLUMN IF EXISTS stamp_file_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -112,15 +112,9 @@ export class BookingContractService {
|
|||||||
const templateKey = this.templateResolver.resolve(booking);
|
const templateKey = this.templateResolver.resolve(booking);
|
||||||
const summary = this.buildContractSummary(booking);
|
const summary = this.buildContractSummary(booking);
|
||||||
|
|
||||||
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
|
// No eager PDF render here: streamContract re-renders the document on every
|
||||||
// from becoming ready — the document is (re)rendered lazily on view/download.
|
// view/download, so rendering now only adds a Chromium launch (seconds, or a
|
||||||
try {
|
// 60s asset-load hang) inside the staff-accept request.
|
||||||
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.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
@@ -132,6 +126,22 @@ export class BookingContractService {
|
|||||||
return updated!;
|
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<void> {
|
||||||
|
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) {
|
async streamContract(bookingId: string) {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
const templateKey =
|
const templateKey =
|
||||||
@@ -152,8 +162,13 @@ export class BookingContractService {
|
|||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
const role = dto.role as ContractSignerRole;
|
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') {
|
if (role === 'CUSTOMER') {
|
||||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
if (!booking.isGovernment) {
|
||||||
|
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||||
|
}
|
||||||
const existing = await this.bookingsRepository.findContractSignature(
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
bookingId,
|
bookingId,
|
||||||
'CUSTOMER',
|
'CUSTOMER',
|
||||||
@@ -162,7 +177,9 @@ export class BookingContractService {
|
|||||||
throw new BadRequestException('Customer has already signed this contract');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
if (!booking.isGovernment) {
|
||||||
|
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||||
|
}
|
||||||
const existing = await this.bookingsRepository.findContractSignature(
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
bookingId,
|
bookingId,
|
||||||
'STAFF',
|
'STAFF',
|
||||||
@@ -235,20 +252,30 @@ export class BookingContractService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
updates.status = 'SIGNED_CUSTOMER';
|
|
||||||
updates.customerSignedAt = now;
|
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 {
|
} else {
|
||||||
updates.fullyExecutedAt = now;
|
updates.fullyExecutedAt = now;
|
||||||
updates.marketingApprovedAt = now;
|
updates.marketingApprovedAt = now;
|
||||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||||
updates.lockedAt = now;
|
if (!booking.isGovernment) {
|
||||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
updates.lockedAt = now;
|
||||||
|
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||||||
// clearance bookings enter operations after the GL document gate.
|
// clearance bookings enter operations after the GL document gate. Government
|
||||||
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
// bookings are already in the pool from expedite — signing changes nothing.
|
||||||
|
if (
|
||||||
|
role === 'STAFF' &&
|
||||||
|
!booking.isGovernment &&
|
||||||
|
!clearanceCode &&
|
||||||
|
updated?.trainScheduleId
|
||||||
|
) {
|
||||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { Yard } from '../rule-engine/entities/yard.entity';
|
|||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.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 { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
@@ -103,6 +105,10 @@ export class BookingsService {
|
|||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly pdfRender: PdfRenderService,
|
private readonly pdfRender: PdfRenderService,
|
||||||
private readonly events: EventEmitter2,
|
private readonly events: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => BookingContractService))
|
||||||
|
private readonly bookingContractService: BookingContractService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async assignCustomerTruck(
|
async assignCustomerTruck(
|
||||||
@@ -982,6 +988,21 @@ export class BookingsService {
|
|||||||
warnings.push(...consolidation.messages);
|
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 };
|
return { booking: full, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1830,13 +1851,22 @@ export class BookingsService {
|
|||||||
return false;
|
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<Booking> {
|
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||||
const booking = await this.findById(id);
|
const booking = await this.findById(id);
|
||||||
if (!booking.isGovernment) {
|
if (!booking.isGovernment) {
|
||||||
throw new BadRequestException('Only government bookings can be expedited');
|
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)) {
|
if (blocked.includes(booking.status)) {
|
||||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||||
}
|
}
|
||||||
@@ -1848,12 +1878,22 @@ export class BookingsService {
|
|||||||
holdStartedAt: null,
|
holdStartedAt: null,
|
||||||
holdExpiresAt: null,
|
holdExpiresAt: null,
|
||||||
});
|
});
|
||||||
|
await this.bookingContractService.generateContractForGovernment(id);
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
id,
|
id,
|
||||||
`Government booking expedited to PAID by staff (${staffUserId})`,
|
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||||
'STAFF_NOTE',
|
'STAFF_NOTE',
|
||||||
staffUserId,
|
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);
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class SavedSignatureViewDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
stampImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ContractViewDto {
|
export class ContractViewDto {
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ export class SchedulingRescheduleService {
|
|||||||
actorUserId?: string,
|
actorUserId?: string,
|
||||||
) {
|
) {
|
||||||
const plan = await this.previewReschedule(scheduleId, dto);
|
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 expectedDisplaced = new Set(plan.displaced.map((b) => b.id));
|
||||||
const providedDisplaced = new Set(dto.displacedBookingIds);
|
const providedDisplaced = new Set(dto.displacedBookingIds);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsString, MinLength } from 'class-validator';
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
export class SaveSignatureDto {
|
export class SaveSignatureDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -13,6 +13,15 @@ export class SaveSignatureDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
signatureImageBase64!: string;
|
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 {
|
export class SavedSignatureDto {
|
||||||
@@ -21,4 +30,7 @@ export class SavedSignatureDto {
|
|||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
signatureImageUrl!: string | null;
|
signatureImageUrl!: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
stampImageUrl!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,11 @@ export class SavedSignature extends BaseEntity {
|
|||||||
@ManyToOne(() => FileRecord, { nullable: true })
|
@ManyToOne(() => FileRecord, { nullable: true })
|
||||||
@JoinColumn({ name: 'signature_file_id' })
|
@JoinColumn({ name: 'signature_file_id' })
|
||||||
signatureFile?: FileRecord | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export class SignaturesController {
|
|||||||
userId,
|
userId,
|
||||||
signerDisplayName: dto.signerDisplayName,
|
signerDisplayName: dto.signerDisplayName,
|
||||||
signatureImageBase64: dto.signatureImageBase64,
|
signatureImageBase64: dto.signatureImageBase64,
|
||||||
|
stampImageBase64: dto.stampImageBase64,
|
||||||
});
|
});
|
||||||
return this.signaturesService.getForUser(userId);
|
return this.signaturesService.getForUser(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class SignaturesRepository extends BaseRepository<SavedSignature> {
|
|||||||
findByUserId(userId: string): Promise<SavedSignature | null> {
|
findByUserId(userId: string): Promise<SavedSignature | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
where: { userId } as never,
|
where: { userId } as never,
|
||||||
relations: ['signatureFile'],
|
relations: ['signatureFile', 'stampFile'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface UpsertSignatureInput {
|
|||||||
userId: string;
|
userId: string;
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
|
/** Optional company stamp/seal; omitted = keep the existing saved stamp. */
|
||||||
|
stampImageBase64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -31,15 +33,65 @@ export class SignaturesService {
|
|||||||
return {
|
return {
|
||||||
signerDisplayName: saved.signerDisplayName,
|
signerDisplayName: saved.signerDisplayName,
|
||||||
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
|
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<SavedSignature> {
|
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
|
||||||
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
|
// Capture the previously referenced files so we can remove them only AFTER
|
||||||
const file: Express.Multer.File = {
|
// the saved_signatures row is repointed — deleting first would violate the
|
||||||
fieldname: 'signature',
|
// FK constraint (saved_signatures.*_file_id -> files.id).
|
||||||
originalname: `signature-${input.userId}.png`,
|
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',
|
encoding: '7bit',
|
||||||
mimetype: 'image/png',
|
mimetype: 'image/png',
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
@@ -49,33 +101,6 @@ export class SignaturesService {
|
|||||||
filename: '',
|
filename: '',
|
||||||
path: '',
|
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(
|
private async inlineImageUrl(
|
||||||
|
|||||||
@@ -1475,7 +1475,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||||
where,
|
where,
|
||||||
relations: {
|
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,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
// Yards supply the route's display name for `routeName` below;
|
// 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) };
|
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||||
@@ -1853,47 +1869,63 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null;
|
: null;
|
||||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
// Per-leg committed weight: a booking holds weight only on the edges it
|
// Per-leg committed usage: a booking holds capacity only on the edges it
|
||||||
// rides, so the meter compares the HEAVIEST single edge against the pull
|
// rides, so every meter compares the HEAVIEST single edge against its cap
|
||||||
// limit. Whole-route bookings (or yards missing from the stop list) load
|
// — weight, wagons and length alike. Whole-route bookings (or yards
|
||||||
// every edge — never under-reported.
|
// missing from the stop list) load every edge — never under-reported.
|
||||||
const stops = legCtx?.stops ?? [];
|
const stops = legCtx?.stops ?? [];
|
||||||
let usedWeightTons = round2(
|
let usedWeightTons = round2(
|
||||||
committed.reduce((sum, i) => sum + i.weightTons, 0),
|
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;
|
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
|
||||||
if (legCtx && stops.length > 2) {
|
if (legCtx && stops.length > 2) {
|
||||||
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||||||
const edges = new Array<number>(stops.length - 1).fill(0);
|
const edgeCount = stops.length - 1;
|
||||||
for (const item of committed) {
|
const legOf = (bookingId: string): { from: number; to: number } => {
|
||||||
const yards = legCtx.yardsByBookingId.get(item.id);
|
const yards = legCtx.yardsByBookingId.get(bookingId);
|
||||||
const from = yards?.originYardId
|
const from = yards?.originYardId
|
||||||
? stopIndex.get(yards.originYardId)
|
? stopIndex.get(yards.originYardId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const to = yards?.destinationYardId
|
const to = yards?.destinationYardId
|
||||||
? stopIndex.get(yards.destinationYardId)
|
? stopIndex.get(yards.destinationYardId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const leg =
|
return from != null && to != null && from < to
|
||||||
from != null && to != null && from < to
|
? { from, to }
|
||||||
? { from, to }
|
: { from: 0, to: edgeCount };
|
||||||
: { from: 0, to: edges.length };
|
};
|
||||||
for (let e = leg.from; e < leg.to; e += 1) edges[e] += item.weightTons;
|
const weightEdges = new Array<number>(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<number>(edgeCount).fill(0);
|
||||||
|
const lengthEdges = new Array<number>(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) =>
|
const label = (yardId: string) =>
|
||||||
legCtx.labelByYardId.get(yardId) ?? yardId;
|
legCtx.labelByYardId.get(yardId) ?? yardId;
|
||||||
legUsage = edges.map((weight, i) => ({
|
legUsage = weightEdges.map((weight, i) => ({
|
||||||
from: label(stops[i]),
|
from: label(stops[i]),
|
||||||
to: label(stops[i + 1]),
|
to: label(stops[i + 1]),
|
||||||
usedWeightTons: round2(weight),
|
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 {
|
return {
|
||||||
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
|
allocatedWagons,
|
||||||
allocatedLengthMeters: round2(
|
allocatedLengthMeters,
|
||||||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
|
||||||
),
|
|
||||||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||||||
usedWeightTons,
|
usedWeightTons,
|
||||||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
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]));
|
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<string, string>;
|
||||||
|
} {
|
||||||
|
const milestones = [...(s.route?.milestones ?? [])].sort(
|
||||||
|
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||||
|
);
|
||||||
|
const stops: string[] = [];
|
||||||
|
const labelByYardId = new Map<string, string>();
|
||||||
|
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(
|
private buildScheduleSummary(
|
||||||
s: TrainSchedule,
|
s: TrainSchedule,
|
||||||
items: BatchBoardBooking[],
|
items: BatchBoardBooking[],
|
||||||
|
yardsByBookingId: Map<
|
||||||
|
string,
|
||||||
|
{ originYardId: string | null; destinationYardId: string | null }
|
||||||
|
>,
|
||||||
): BatchBoardSchedule {
|
): BatchBoardSchedule {
|
||||||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||||||
|
const { stops, labelByYardId } = this.stopsFromGraph(s);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scheduleId: s.id,
|
scheduleId: s.id,
|
||||||
@@ -1966,9 +2033,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
|
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
|
||||||
stops: [],
|
stops,
|
||||||
labelByYardId: new Map(),
|
labelByYardId,
|
||||||
yardsByBookingId: new Map(),
|
yardsByBookingId,
|
||||||
trainLengthMeters: this.builtTrainLengthOf(s),
|
trainLengthMeters: this.builtTrainLengthOf(s),
|
||||||
}),
|
}),
|
||||||
counts: {
|
counts: {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.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 { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||||
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-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")
|
@Get("schedules/:id/composition-removals")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "Get removal log for a schedule" })
|
@ApiOperation({ summary: "Get removal log for a schedule" })
|
||||||
|
|||||||
@@ -1351,4 +1351,88 @@ describe('TrainSchedulingService', () => {
|
|||||||
).rejects.toThrow(/over its/);
|
).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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1886,6 +1886,11 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
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;
|
const bookingReference = booking?.reference ?? null;
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
@@ -5384,6 +5389,7 @@ export class TrainSchedulingService {
|
|||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||||||
status: booking.status,
|
status: booking.status,
|
||||||
|
isGovernment: Boolean(booking.isGovernment),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7189,6 +7195,7 @@ export class TrainSchedulingService {
|
|||||||
// dispatch. Defaults UNLOADED for links written before the column.
|
// dispatch. Defaults UNLOADED for links written before the column.
|
||||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||||
|
isGovernment: Boolean(sb.booking?.isGovernment),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
// Ordered corridor stops (route milestones; falls back to the two
|
// Ordered corridor stops (route milestones; falls back to the two
|
||||||
// endpoints) — lets the UI draw per-segment occupancy and label legs.
|
// 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. */
|
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||||
async previewAllocationForSchedule(
|
async previewAllocationForSchedule(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import toast from "react-hot-toast";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -40,6 +41,7 @@ export function MySignatureCard() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
|
|
||||||
const defaultName =
|
const defaultName =
|
||||||
user?.name?.en || user?.username || user?.email || "";
|
user?.name?.en || user?.username || user?.email || "";
|
||||||
@@ -47,6 +49,7 @@ export function MySignatureCard() {
|
|||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
setStampData(saved?.stampImageUrl ?? null);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +59,10 @@ export function MySignatureCard() {
|
|||||||
{
|
{
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: signatureData,
|
||||||
|
// Only send the stamp when it changed — omitted keeps the saved one.
|
||||||
|
...(stampData && stampData !== saved?.stampImageUrl
|
||||||
|
? { stampImageBase64: stampData }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -101,6 +108,18 @@ export function MySignatureCard() {
|
|||||||
You have not saved a signature yet.
|
You have not saved a signature yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{saved?.stampImageUrl && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.stampImageUrl}
|
||||||
|
alt="My saved company stamp"
|
||||||
|
className="mx-auto h-24 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,6 +145,11 @@ export function MySignatureCard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description="Stored on your profile and prefilled when you sign contracts."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ArrowRight, Package } from "lucide-react";
|
import { ArrowRight, Landmark, Package } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -21,11 +21,13 @@ function EligibleBookingRow({
|
|||||||
freightType,
|
freightType,
|
||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onSwitch,
|
||||||
}: {
|
}: {
|
||||||
booking: EligibleContainerBooking;
|
booking: EligibleContainerBooking;
|
||||||
freightType?: FreightType;
|
freightType?: FreightType;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
onSwitch?: (booking: EligibleContainerBooking) => void;
|
||||||
}) {
|
}) {
|
||||||
const resolvedFreightType = booking.freightType ?? freightType;
|
const resolvedFreightType = booking.freightType ?? freightType;
|
||||||
const isBulk = resolvedFreightType === "BULK";
|
const isBulk = resolvedFreightType === "BULK";
|
||||||
@@ -61,6 +63,26 @@ function EligibleBookingRow({
|
|||||||
{booking.schedulingStatus}
|
{booking.schedulingStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{booking.isGovernment ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<Landmark size={10} />}
|
||||||
|
>
|
||||||
|
Government
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{booking.isGovernment && onSwitch ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
size="compact-xs"
|
||||||
|
onClick={() => onSwitch(booking)}
|
||||||
|
>
|
||||||
|
Switch onto train
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{booking.customer}
|
{booking.customer}
|
||||||
@@ -102,6 +124,7 @@ export function EligibleBookingsPanel({
|
|||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
assignedIds = [],
|
assignedIds = [],
|
||||||
freightType,
|
freightType,
|
||||||
|
onSwitch,
|
||||||
}: {
|
}: {
|
||||||
items: EligibleContainerBooking[];
|
items: EligibleContainerBooking[];
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -109,6 +132,7 @@ export function EligibleBookingsPanel({
|
|||||||
onSelectionChange: (ids: string[]) => void;
|
onSelectionChange: (ids: string[]) => void;
|
||||||
assignedIds?: string[];
|
assignedIds?: string[];
|
||||||
freightType?: FreightType;
|
freightType?: FreightType;
|
||||||
|
onSwitch?: (booking: EligibleContainerBooking) => void;
|
||||||
}) {
|
}) {
|
||||||
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
|
const assignedSet = useMemo(() => new Set(assignedIds), [assignedIds]);
|
||||||
|
|
||||||
@@ -230,6 +254,7 @@ export function EligibleBookingsPanel({
|
|||||||
freightType={freightType}
|
freightType={freightType}
|
||||||
selected={selectedIds.includes(booking.id)}
|
selected={selectedIds.includes(booking.id)}
|
||||||
onToggle={() => toggle(booking.id)}
|
onToggle={() => toggle(booking.id)}
|
||||||
|
onSwitch={onSwitch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { ArrowRight, Package, Train } from "lucide-react";
|
import { ArrowRight, Landmark, Package, Train } from "lucide-react";
|
||||||
|
|
||||||
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
import type { EligibleContainerBooking, FreightType } from "@/types/trainScheduling";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export type AssignedBookingRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
reference: string;
|
reference: string;
|
||||||
weightTons?: number;
|
weightTons?: number;
|
||||||
|
isGovernment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ScheduleBookingsStep({
|
export function ScheduleBookingsStep({
|
||||||
@@ -29,6 +30,7 @@ export function ScheduleBookingsStep({
|
|||||||
freightType,
|
freightType,
|
||||||
canRemove,
|
canRemove,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onSwitch,
|
||||||
}: {
|
}: {
|
||||||
assignedBookings: AssignedBookingRow[];
|
assignedBookings: AssignedBookingRow[];
|
||||||
eligibleItems: EligibleContainerBooking[];
|
eligibleItems: EligibleContainerBooking[];
|
||||||
@@ -39,6 +41,7 @@ export function ScheduleBookingsStep({
|
|||||||
freightType?: FreightType;
|
freightType?: FreightType;
|
||||||
canRemove?: boolean;
|
canRemove?: boolean;
|
||||||
onRemove?: (bookingId: string) => void;
|
onRemove?: (bookingId: string) => void;
|
||||||
|
onSwitch?: (booking: EligibleContainerBooking) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
<Paper p="md" radius="lg" withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||||
@@ -91,6 +94,16 @@ export function ScheduleBookingsStep({
|
|||||||
{booking.weightTons}T
|
{booking.weightTons}T
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{booking.isGovernment ? (
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<Landmark size={10} />}
|
||||||
|
>
|
||||||
|
Government
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={6}>
|
<Group gap={6}>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -130,6 +143,7 @@ export function ScheduleBookingsStep({
|
|||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
assignedIds={assignedIds}
|
assignedIds={assignedIds}
|
||||||
freightType={freightType}
|
freightType={freightType}
|
||||||
|
onSwitch={onSwitch}
|
||||||
/>
|
/>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Landmark } from "lucide-react";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EligibleContainerBooking,
|
||||||
|
TrainScheduleDetail,
|
||||||
|
} from "@/types/trainScheduling";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Government-priority switch confirmation: pick the assigned commercial
|
||||||
|
* bookings to take off the train so the government booking can have their
|
||||||
|
* wagons. Mount with key={govBooking.id} so selection resets per booking.
|
||||||
|
*/
|
||||||
|
export function SwitchGovernmentBookingModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
govBooking,
|
||||||
|
assignedBookings,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
govBooking: EligibleContainerBooking | null;
|
||||||
|
assignedBookings: TrainScheduleDetail["bookings"];
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: (removeBookingIds: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const candidates = useMemo(
|
||||||
|
() => assignedBookings.filter((b) => !b.isGovernment && b.wagonAssigned),
|
||||||
|
[assignedBookings],
|
||||||
|
);
|
||||||
|
const freedWagons = candidates
|
||||||
|
.filter((b) => selected.includes(b.id))
|
||||||
|
.reduce((sum, b) => sum + (b.wagonsRequired ?? 0), 0);
|
||||||
|
|
||||||
|
const toggle = (id: string) =>
|
||||||
|
setSelected((ids) =>
|
||||||
|
ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Landmark size={18} />
|
||||||
|
<Text fw={600}>Switch in government booking</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
radius="lg"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="yellow" radius="md" variant="light">
|
||||||
|
Government bookings have priority. Select the booking(s) to switch out —
|
||||||
|
together they must free at least as many wagons as{" "}
|
||||||
|
<Text span fw={600}>
|
||||||
|
{govBooking?.reference}
|
||||||
|
</Text>{" "}
|
||||||
|
needs. Switched-out customers are notified to rebook.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{candidates.length ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{candidates.map((b) => (
|
||||||
|
<Group
|
||||||
|
key={b.id}
|
||||||
|
justify="space-between"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
color="edr-green"
|
||||||
|
checked={selected.includes(b.id)}
|
||||||
|
onChange={() => toggle(b.id)}
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{b.reference}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{b.customer}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge variant="outline" size="xs" color="edr-green">
|
||||||
|
{b.weightTons}T
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="light" size="xs">
|
||||||
|
{b.wagonsRequired ?? "?"} wagon{b.wagonsRequired === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||||
|
No commercial bookings with wagons on this train to switch out.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Badge variant="light" color={selected.length ? "edr-green" : "gray"}>
|
||||||
|
Frees {freedWagons} wagon{freedWagons === 1 ? "" : "s"}
|
||||||
|
</Badge>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Button variant="default" radius="md" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
disabled={!selected.length}
|
||||||
|
loading={loading}
|
||||||
|
onClick={() => onConfirm(selected)}
|
||||||
|
>
|
||||||
|
Confirm switch
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -366,6 +366,8 @@ export const URL_CONSTANTS = {
|
|||||||
},
|
},
|
||||||
UNASSIGN_BOOKING: (scheduleId: string, bookingId: string) =>
|
UNASSIGN_BOOKING: (scheduleId: string, bookingId: string) =>
|
||||||
`/train-scheduling/schedules/${scheduleId}/bookings/${bookingId}`,
|
`/train-scheduling/schedules/${scheduleId}/bookings/${bookingId}`,
|
||||||
|
SWITCH_GOVERNMENT_BOOKING: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/switch-government-booking`,
|
||||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
FileSignature,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Layers,
|
Layers,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
@@ -254,6 +255,20 @@ export default function BookingRequestDetailPage() {
|
|||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
/>
|
/>
|
||||||
|
{booking.isGovernment && booking.contractSummary && (
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="default"
|
||||||
|
leftSection={<FileSignature size={16} />}
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View / sign contract
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{booking.customsClearingEnabled && (
|
{booking.customsClearingEnabled && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -400,8 +400,13 @@ export default function NewBookingPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: async (booking) => {
|
onSuccess: async (booking) => {
|
||||||
if (isGovernment) {
|
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);
|
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 {
|
} else {
|
||||||
toast.success("Booking created as draft");
|
toast.success("Booking created as draft");
|
||||||
}
|
}
|
||||||
@@ -891,7 +896,7 @@ export default function NewBookingPage() {
|
|||||||
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
|
<Info size={14} color="var(--mantine-color-gray-5)" style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{isGovernment
|
{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."}
|
: "Overweight container lines are allowed here and flagged later at scheduling."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export default function ContractViewPage() {
|
|||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
setStampData(null);
|
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||||
|
setStampData(data?.savedSignature?.stampImageUrl ?? null);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTr
|
|||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||||
|
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
|
||||||
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
||||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +80,7 @@ import { trainSchedulingService } from "@/services/trainScheduling.service";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import type {
|
import type {
|
||||||
ContainerPlacement,
|
ContainerPlacement,
|
||||||
|
EligibleContainerBooking,
|
||||||
FreightType,
|
FreightType,
|
||||||
TrainSchedulePreviewResponse,
|
TrainSchedulePreviewResponse,
|
||||||
} from "@/types/trainScheduling";
|
} from "@/types/trainScheduling";
|
||||||
@@ -110,6 +112,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||||
|
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||||
const autoPreviewedRef = useRef(false);
|
const autoPreviewedRef = useRef(false);
|
||||||
|
|
||||||
const detailQuery = useQuery(
|
const detailQuery = useQuery(
|
||||||
@@ -192,6 +195,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||||
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
const downloadMarshalling = useMutation({
|
const downloadMarshalling = useMutation({
|
||||||
@@ -511,6 +515,17 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnassign = async (bookingId: string) => {
|
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 {
|
try {
|
||||||
await unassign.mutateAsync({ id: scheduleId, bookingId });
|
await unassign.mutateAsync({ id: scheduleId, bookingId });
|
||||||
toast({ title: "Booking unassigned" });
|
toast({ title: "Booking unassigned" });
|
||||||
@@ -631,6 +646,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
id: b.id,
|
id: b.id,
|
||||||
reference: b.reference ?? b.id.slice(0, 8),
|
reference: b.reference ?? b.id.slice(0, 8),
|
||||||
weightTons: b.weightTons,
|
weightTons: b.weightTons,
|
||||||
|
isGovernment: b.isGovernment,
|
||||||
}))}
|
}))}
|
||||||
eligibleItems={eligibleQuery.data?.items ?? []}
|
eligibleItems={eligibleQuery.data?.items ?? []}
|
||||||
eligibleLoading={eligibleQuery.isLoading}
|
eligibleLoading={eligibleQuery.isLoading}
|
||||||
@@ -643,6 +659,7 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
freightType={freightType}
|
freightType={freightType}
|
||||||
canRemove={canModifyBookings}
|
canRemove={canModifyBookings}
|
||||||
onRemove={handleUnassign}
|
onRemove={handleUnassign}
|
||||||
|
onSwitch={canModifyBookings ? setSwitchTarget : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canEditBookings ? (
|
{canEditBookings ? (
|
||||||
@@ -1274,6 +1291,36 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
onSaved={() => void detailQuery.refetch()}
|
onSaved={() => void detailQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SwitchGovernmentBookingModal
|
||||||
|
key={switchTarget?.id ?? "none"}
|
||||||
|
opened={Boolean(switchTarget)}
|
||||||
|
onClose={() => 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={dispatchConfirmOpen}
|
opened={dispatchConfirmOpen}
|
||||||
onClose={() => setDispatchConfirmOpen(false)}
|
onClose={() => setDispatchConfirmOpen(false)}
|
||||||
|
|||||||
@@ -659,6 +659,22 @@ export const api = {
|
|||||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
() => 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<
|
setLoadingStatus: endpoint<
|
||||||
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
|
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
|
||||||
TrainScheduleDetail
|
TrainScheduleDetail
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export interface ContractView {
|
|||||||
savedSignature?: {
|
savedSignature?: {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ const SIGNATURE_URL = "/me/signature";
|
|||||||
export interface SavedSignature {
|
export interface SavedSignature {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveSignaturePayload {
|
export interface SaveSignaturePayload {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
|
/** Omit to keep the existing saved stamp. */
|
||||||
|
stampImageBase64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const signaturesService = {
|
export const signaturesService = {
|
||||||
|
|||||||
@@ -367,6 +367,18 @@ export const trainSchedulingService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
switchGovernmentBooking: async (
|
||||||
|
scheduleId: string,
|
||||||
|
governmentBookingId: string,
|
||||||
|
removeBookingIds: string[],
|
||||||
|
): Promise<TrainScheduleDetail> => {
|
||||||
|
const response = await client.post<TrainScheduleDetail>(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.SWITCH_GOVERNMENT_BOOKING(scheduleId),
|
||||||
|
{ governmentBookingId, removeBookingIds },
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
pinWagons: async (
|
pinWagons: async (
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
payload: PinWagonsPayload,
|
payload: PinWagonsPayload,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface EligibleContainerBooking {
|
|||||||
status: string;
|
status: string;
|
||||||
schedulingStatus?: SchedulingStatus;
|
schedulingStatus?: SchedulingStatus;
|
||||||
priorityScore?: number;
|
priorityScore?: number;
|
||||||
|
isGovernment?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EligibleContainerBookingsResponse {
|
export interface EligibleContainerBookingsResponse {
|
||||||
@@ -686,6 +687,7 @@ export interface TrainScheduleDetail {
|
|||||||
arrivedAt?: string | null;
|
arrivedAt?: string | null;
|
||||||
loadingStatus?: "LOADED" | "UNLOADED";
|
loadingStatus?: "LOADED" | "UNLOADED";
|
||||||
wagonAssigned?: boolean;
|
wagonAssigned?: boolean;
|
||||||
|
isGovernment?: boolean;
|
||||||
}>;
|
}>;
|
||||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||||
stops?: Array<{ yardId: string; label: string }>;
|
stops?: Array<{ yardId: string; label: string }>;
|
||||||
|
|||||||
@@ -137,8 +137,15 @@ export function InitiateBookingButton({
|
|||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
navigate(`/bookings/${booking.id}`);
|
navigate(`/bookings/${booking.id}`);
|
||||||
},
|
},
|
||||||
onError: (e: Error) =>
|
onError: (e: Error) => {
|
||||||
toast.error(e.message || "Could not initiate the booking"),
|
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 (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
import { FileSignature, Loader2 } from "lucide-react";
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
useMySignature,
|
useMySignature,
|
||||||
@@ -37,12 +38,14 @@ export function MySignatureCard() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
const [stampData, setStampData] = useState<string | null>(null);
|
||||||
|
|
||||||
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
const defaultName = user?.name?.en || user?.username || user?.email || "";
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
setSignerName(saved?.signerDisplayName ?? defaultName);
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
setStampData(saved?.stampImageUrl ?? null);
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +55,10 @@ export function MySignatureCard() {
|
|||||||
{
|
{
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: signatureData,
|
||||||
|
// Only send the stamp when it changed — omitted keeps the saved one.
|
||||||
|
...(stampData && stampData !== saved?.stampImageUrl
|
||||||
|
? { stampImageBase64: stampData }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
{ onSuccess: () => setOpen(false) },
|
{ onSuccess: () => setOpen(false) },
|
||||||
);
|
);
|
||||||
@@ -91,6 +98,18 @@ export function MySignatureCard() {
|
|||||||
You have not saved a signature yet.
|
You have not saved a signature yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{saved?.stampImageUrl && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.stampImageUrl}
|
||||||
|
alt="My saved company stamp"
|
||||||
|
className="mx-auto h-24 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Company stamp</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -116,6 +135,11 @@ export function MySignatureCard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
<StampUpload
|
||||||
|
value={stampData}
|
||||||
|
onChange={setStampData}
|
||||||
|
description="Stored on your profile and prefilled when you sign contracts."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ export default function ContractViewPage() {
|
|||||||
if (!canProceedToSign) return;
|
if (!canProceedToSign) return;
|
||||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
setStampData(null);
|
// Prefill with the reusable stamp saved on the profile; still replaceable.
|
||||||
|
setStampData(data?.savedSignature?.stampImageUrl ?? null);
|
||||||
setDrawNew(false);
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface ContractView {
|
|||||||
savedSignature?: {
|
savedSignature?: {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ const SIGNATURE_URL = "/api/me/signature";
|
|||||||
export interface SavedSignature {
|
export interface SavedSignature {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
|
stampImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveSignaturePayload {
|
export interface SaveSignaturePayload {
|
||||||
signerDisplayName: string;
|
signerDisplayName: string;
|
||||||
signatureImageBase64: string;
|
signatureImageBase64: string;
|
||||||
|
/** Omit to keep the existing saved stamp. */
|
||||||
|
stampImageBase64?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const signaturesService = {
|
export const signaturesService = {
|
||||||
|
|||||||
Reference in New Issue
Block a user