mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
Merge pull request #182 from Tria-plc/freight_feature/priority
Freight feature/priority
This commit is contained in:
@@ -46,7 +46,9 @@
|
|||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"puppeteer": "^24.2.0",
|
"puppeteer": "^24.2.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "^0.3.30"
|
||||||
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
@@ -69,7 +71,6 @@
|
|||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typeorm": "^0.3.30",
|
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
|
|||||||
import rabbitmqConfig from "./config/rabbitmq.config";
|
import rabbitmqConfig from "./config/rabbitmq.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
|
import { SignaturesModule } from "./modules/signatures/signatures.module";
|
||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module';
|
|||||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||||
}),
|
}),
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
|
SignaturesModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateSavedSignatures1784000000000 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.saved_signatures (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
signer_display_name VARCHAR(200) NOT NULL,
|
||||||
|
signature_file_id UUID NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted_at TIMESTAMPTZ NULL,
|
||||||
|
CONSTRAINT uq_saved_signatures_user_id UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingContractService {
|
export class BookingContractService {
|
||||||
@@ -38,6 +39,7 @@ export class BookingContractService {
|
|||||||
private readonly pdfService: ContractPdfService,
|
private readonly pdfService: ContractPdfService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
|
private readonly signaturesService: SignaturesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
buildContractSummary(booking: Booking): string {
|
buildContractSummary(booking: Booking): string {
|
||||||
@@ -75,10 +77,16 @@ export class BookingContractService {
|
|||||||
return { summary };
|
return { summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
async getContractView(
|
||||||
|
bookingId: string,
|
||||||
|
viewerUserId?: string,
|
||||||
|
): Promise<ContractViewDto> {
|
||||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||||
await this.inlineSignatureImages(view.signatures);
|
await this.inlineSignatureImages(view.signatures);
|
||||||
const html = this.renderer.render(view);
|
const html = this.renderer.render(view);
|
||||||
|
const savedSignature = viewerUserId
|
||||||
|
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
bookingId: view.bookingId,
|
bookingId: view.bookingId,
|
||||||
reference: view.reference,
|
reference: view.reference,
|
||||||
@@ -90,6 +98,7 @@ export class BookingContractService {
|
|||||||
canSignStaff: view.canSignStaff,
|
canSignStaff: view.canSignStaff,
|
||||||
hasContractDocument: view.hasContractDocument,
|
hasContractDocument: view.hasContractDocument,
|
||||||
signatures: view.signatures,
|
signatures: view.signatures,
|
||||||
|
savedSignature,
|
||||||
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -194,6 +203,23 @@ export class BookingContractService {
|
|||||||
ipAddress: options.ipAddress ?? null,
|
ipAddress: options.ipAddress ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Persist the just-used signature to the signer's reusable profile so they
|
||||||
|
// don't have to redraw it on the next contract. Best-effort: a failure here
|
||||||
|
// must never block contract execution.
|
||||||
|
if (options.signerUserId) {
|
||||||
|
try {
|
||||||
|
await this.signaturesService.upsertForUser({
|
||||||
|
userId: options.signerUserId,
|
||||||
|
signerDisplayName: dto.signerDisplayName,
|
||||||
|
signatureImageBase64: dto.signatureImageBase64,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (role === 'CUSTOMER') {
|
if (role === 'CUSTOMER') {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
|||||||
statuses: readonly string[] | null;
|
statuses: readonly string[] | null;
|
||||||
}> = [
|
}> = [
|
||||||
{ key: 'all', statuses: null },
|
{ key: 'all', statuses: null },
|
||||||
{ key: 'intake', statuses: ['SUBMITTED'] },
|
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
|
||||||
{
|
{
|
||||||
key: 'in_approval',
|
key: 'in_approval',
|
||||||
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
|
||||||
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
|||||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||||
{
|
{
|
||||||
key: 'operations',
|
key: 'operations',
|
||||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
statuses: ['IN_TRANSIT', 'PAID'],
|
||||||
},
|
},
|
||||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
|
|
||||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||||
@@ -184,6 +190,16 @@ export class BookingTransitionService {
|
|||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['SUBMITTED']);
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
|
|
||||||
|
// Consolidation gate: a booking whose containers don't fill whole wagons
|
||||||
|
// cannot be accepted until it is paired with a complementary booking.
|
||||||
|
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
|
||||||
|
if (gate.blocked) {
|
||||||
|
throw new ConflictException(
|
||||||
|
gate.message ??
|
||||||
|
'Booking requires consolidation and cannot be accepted until a partner is found.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
cargoTypeId: booking.cargoTypeId,
|
cargoTypeId: booking.cargoTypeId,
|
||||||
|
|||||||
@@ -342,8 +342,12 @@ export class BookingsController {
|
|||||||
@Get(':id/contract/view')
|
@Get(':id/contract/view')
|
||||||
@ApiOkResponse({ type: ContractViewDto })
|
@ApiOkResponse({ type: ContractViewDto })
|
||||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||||
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
getContractView(
|
||||||
return this.contractService.getContractView(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
) {
|
||||||
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
|
return this.contractService.getContractView(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract/document')
|
@Get(':id/contract/document')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
|
|||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { BookingContractService } from './booking-contract.service';
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingPaymentService } from './booking-payment.service';
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingPricingService } from './booking-pricing.service';
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
@@ -50,6 +51,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
// CustomersModule,
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
|
SignaturesModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController],
|
controllers: [BookingsController, PayController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||||
|
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||||
.where('booking.id = :id', { id })
|
.where('booking.id = :id', { id })
|
||||||
.leftJoinAndMapMany(
|
.leftJoinAndMapMany(
|
||||||
'booking.files',
|
'booking.files',
|
||||||
@@ -177,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.andWhere('b.allowConsolidation = true')
|
.andWhere('b.allowConsolidation = true')
|
||||||
.andWhere('b.consolidationPartnerId IS NULL')
|
.andWhere('b.consolidationPartnerId IS NULL')
|
||||||
.andWhere('b.status IN (:...statuses)', {
|
.andWhere('b.status IN (:...statuses)', {
|
||||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||||
})
|
})
|
||||||
.andWhere('b.originYardId = :originYardId', {
|
.andWhere('b.originYardId = :originYardId', {
|
||||||
originYardId: booking.originYardId,
|
originYardId: booking.originYardId,
|
||||||
@@ -214,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pair two bookings for consolidation. */
|
/**
|
||||||
|
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
|
||||||
|
* accept them into the approval chain; the link itself (consolidationPartnerId)
|
||||||
|
* marks them as consolidated in the UI.
|
||||||
|
*/
|
||||||
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||||
await this.repository.update(bookingId, {
|
await this.repository.update(bookingId, {
|
||||||
consolidationPartnerId: partnerId,
|
consolidationPartnerId: partnerId,
|
||||||
status: 'CONSOLIDATED',
|
status: 'SUBMITTED',
|
||||||
} as never);
|
} as never);
|
||||||
await this.repository.update(partnerId, {
|
await this.repository.update(partnerId, {
|
||||||
consolidationPartnerId: bookingId,
|
consolidationPartnerId: bookingId,
|
||||||
status: 'CONSOLIDATED',
|
status: 'SUBMITTED',
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Park a booking that needs consolidation but has no partner yet. */
|
||||||
|
async parkForConsolidation(bookingId: string): Promise<void> {
|
||||||
|
await this.repository.update(bookingId, {
|
||||||
|
consolidationPartnerId: null,
|
||||||
|
status: 'PENDING_CONSOLIDATION',
|
||||||
} as never);
|
} as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +442,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||||
|
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||||
.where('booking.deleted_at IS NULL');
|
.where('booking.deleted_at IS NULL');
|
||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
|
|||||||
@@ -204,6 +204,48 @@ export class BookingsService {
|
|||||||
return { booking: pending, messages };
|
return { booking: pending, messages };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consolidation gate used at staff-accept time. Returns the (possibly newly
|
||||||
|
* paired) booking plus whether it still needs a consolidation partner.
|
||||||
|
* When a booking needs consolidation and none is found, it is parked in
|
||||||
|
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
|
||||||
|
*/
|
||||||
|
async resolveConsolidationGate(bookingId: string): Promise<{
|
||||||
|
booking: Booking;
|
||||||
|
blocked: boolean;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
let booking = await this.findById(bookingId);
|
||||||
|
|
||||||
|
// Already paired — passes the gate.
|
||||||
|
if (booking.consolidationPartnerId) {
|
||||||
|
return { booking, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const needs =
|
||||||
|
await this.consolidationService.needsConsolidationFromBooking(booking);
|
||||||
|
if (!needs) {
|
||||||
|
return { booking, blocked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A partner may have appeared since submission — try to pair now.
|
||||||
|
const result = await this.tryAutoConsolidate(booking);
|
||||||
|
booking = result.booking;
|
||||||
|
if (booking.consolidationPartnerId) {
|
||||||
|
return { booking, blocked: false, message: result.messages.join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still no partner — park it and block the accept.
|
||||||
|
await this.bookingsRepository.parkForConsolidation(booking.id);
|
||||||
|
booking = await this.findById(booking.id);
|
||||||
|
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||||
|
return {
|
||||||
|
booking,
|
||||||
|
blocked: true,
|
||||||
|
message: this.consolidationService.describePending(booking, slots),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Create a new freight booking. */
|
/** Create a new freight booking. */
|
||||||
async create(
|
async create(
|
||||||
dto: CreateBookingDto,
|
dto: CreateBookingDto,
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ export class ContractSignatureDto {
|
|||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SavedSignatureViewDto {
|
||||||
|
@ApiProperty()
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class ContractViewDto {
|
export class ContractViewDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
bookingId!: string;
|
bookingId!: string;
|
||||||
@@ -45,6 +53,9 @@ export class ContractViewDto {
|
|||||||
@ApiProperty({ type: [ContractSignatureDto] })
|
@ApiProperty({ type: [ContractSignatureDto] })
|
||||||
signatures!: ContractSignatureDto[];
|
signatures!: ContractSignatureDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: SavedSignatureViewDto })
|
||||||
|
savedSignature?: SavedSignatureViewDto;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
pricingSchedule?: Record<string, unknown>;
|
pricingSchedule?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SaveSignatureDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'PNG signature image as base64 (with or without data URL prefix)',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
signatureImageBase64!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SavedSignatureDto {
|
||||||
|
@ApiProperty()
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
signatureImageUrl!: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { FileRecord } from '../../files/entities/file.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable signature that belongs to a single user (customer or staff).
|
||||||
|
* Captured once and applied to many booking contracts so the signer does not
|
||||||
|
* have to redraw it every time. One active saved signature per user.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'saved_signatures' })
|
||||||
|
@Index(['userId'], { unique: true })
|
||||||
|
export class SavedSignature extends BaseEntity {
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||||
|
signatureFileId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileRecord, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'signature_file_id' })
|
||||||
|
signatureFile?: FileRecord | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Put, Request } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { SignaturesService } from './signatures.service';
|
||||||
|
import { SaveSignatureDto, SavedSignatureDto } from './dto/save-signature.dto';
|
||||||
|
|
||||||
|
@ApiTags('Signatures')
|
||||||
|
@Controller('me/signature')
|
||||||
|
export class SignaturesController {
|
||||||
|
constructor(private readonly signaturesService: SignaturesService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOkResponse({ type: SavedSignatureDto })
|
||||||
|
@ApiOperation({ summary: "Current user's reusable saved signature" })
|
||||||
|
getMySignature(
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
): Promise<SavedSignatureDto | null> {
|
||||||
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
|
if (!userId) return Promise.resolve(null);
|
||||||
|
return this.signaturesService.getForUser(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@ApiOkResponse({ type: SavedSignatureDto })
|
||||||
|
@ApiOperation({ summary: 'Create or update the reusable saved signature' })
|
||||||
|
async saveMySignature(
|
||||||
|
@Body() dto: SaveSignatureDto,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
): Promise<SavedSignatureDto | null> {
|
||||||
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
|
if (!userId) return null;
|
||||||
|
await this.signaturesService.upsertForUser({
|
||||||
|
userId,
|
||||||
|
signerDisplayName: dto.signerDisplayName,
|
||||||
|
signatureImageBase64: dto.signatureImageBase64,
|
||||||
|
});
|
||||||
|
return this.signaturesService.getForUser(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { MinioModule } from '../minio/minio.module';
|
||||||
|
import { SignaturesController } from './signatures.controller';
|
||||||
|
import { SignaturesService } from './signatures.service';
|
||||||
|
import { SignaturesRepository } from './signatures.repository';
|
||||||
|
import { SavedSignature } from './entities/saved-signature.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([SavedSignature]),
|
||||||
|
FilesModule,
|
||||||
|
MinioModule,
|
||||||
|
],
|
||||||
|
controllers: [SignaturesController],
|
||||||
|
providers: [SignaturesService, SignaturesRepository],
|
||||||
|
exports: [SignaturesService],
|
||||||
|
})
|
||||||
|
export class SignaturesModule {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { SavedSignature } from './entities/saved-signature.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SignaturesRepository extends BaseRepository<SavedSignature> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(SavedSignature)
|
||||||
|
repo: Repository<SavedSignature>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByUserId(userId: string): Promise<SavedSignature | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { userId } as never,
|
||||||
|
relations: ['signatureFile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or update the single saved signature for a user. */
|
||||||
|
async upsert(data: Partial<SavedSignature>): Promise<SavedSignature> {
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { userId: data.userId! } as never,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, data);
|
||||||
|
return this.repository.save(existing);
|
||||||
|
}
|
||||||
|
return this.repository.save(this.repository.create(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { MinioService } from '../minio/minio.service';
|
||||||
|
import { SignaturesRepository } from './signatures.repository';
|
||||||
|
import { SavedSignature } from './entities/saved-signature.entity';
|
||||||
|
import { SavedSignatureDto } from './dto/save-signature.dto';
|
||||||
|
|
||||||
|
export interface UpsertSignatureInput {
|
||||||
|
userId: string;
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SignaturesService {
|
||||||
|
constructor(
|
||||||
|
private readonly signaturesRepository: SignaturesRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Saved signature for a user, with the image inlined as a data URL (or null). */
|
||||||
|
async getForUser(userId: string): Promise<SavedSignatureDto | null> {
|
||||||
|
const saved = await this.signaturesRepository.findByUserId(userId);
|
||||||
|
if (!saved) return null;
|
||||||
|
return {
|
||||||
|
signerDisplayName: saved.signerDisplayName,
|
||||||
|
signatureImageUrl: await this.inlineImageUrl(saved.signatureFile?.url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert or update the user's reusable signature, storing the image in MinIO. */
|
||||||
|
async upsertForUser(input: UpsertSignatureInput): Promise<SavedSignature> {
|
||||||
|
const buffer = this.decodeSignatureImage(input.signatureImageBase64);
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'signature',
|
||||||
|
originalname: `signature-${input.userId}.png`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileRecord = await this.filesService.upsertByCode({
|
||||||
|
resourceId: input.userId,
|
||||||
|
resource: 'saved_signatures',
|
||||||
|
code: 'signature',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.signaturesRepository.upsert({
|
||||||
|
userId: input.userId,
|
||||||
|
signerDisplayName: input.signerDisplayName,
|
||||||
|
signatureFileId: fileRecord.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inlineImageUrl(
|
||||||
|
url?: string | null,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('data:')) return url;
|
||||||
|
try {
|
||||||
|
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||||
|
const stream = await this.minioService.getFileStream(objectName);
|
||||||
|
const buffer = await this.streamToBuffer(stream);
|
||||||
|
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeSignatureImage(base64: string): Buffer {
|
||||||
|
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||||
|
return Buffer.from(raw, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
private streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on('error', reject);
|
||||||
|
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage
|
|||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
//import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage";
|
||||||
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage";
|
||||||
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage";
|
||||||
@@ -257,6 +258,7 @@ const App = () => {
|
|||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardShell />}>
|
<Route path="/dashboard" element={<DashboardShell />}>
|
||||||
<Route path="overview" element={<OverviewPage />} />
|
<Route path="overview" element={<OverviewPage />} />
|
||||||
|
<Route path="profile" element={<MyProfilePage />} />
|
||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Badge } from "@mantine/core";
|
import { Badge, Group } from "@mantine/core";
|
||||||
|
import { Link2 } from "lucide-react";
|
||||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||||
|
|
||||||
const statusColorMap: Record<string, string> = {
|
const statusColorMap: Record<string, string> = {
|
||||||
@@ -24,14 +25,26 @@ const statusColorMap: Record<string, string> = {
|
|||||||
CONSOLIDATED: "indigo",
|
CONSOLIDATED: "indigo",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatusBadge({ status }: { status: string }) {
|
interface BookingStatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
/** When the booking is part of a consolidation, show a sibling badge. */
|
||||||
|
consolidated?: boolean;
|
||||||
|
/** Partner booking reference for the consolidated badge tooltip. */
|
||||||
|
partnerReference?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingStatusBadge({
|
||||||
|
status,
|
||||||
|
consolidated,
|
||||||
|
partnerReference,
|
||||||
|
}: BookingStatusBadgeProps) {
|
||||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||||
label: status,
|
label: status,
|
||||||
color: "gray",
|
color: "gray",
|
||||||
};
|
};
|
||||||
const color = statusColorMap[status] ?? "gray";
|
const color = statusColorMap[status] ?? "gray";
|
||||||
|
|
||||||
return (
|
const statusBadge = (
|
||||||
<Badge
|
<Badge
|
||||||
color={color}
|
color={color}
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -51,4 +64,34 @@ export function BookingStatusBadge({ status }: { status: string }) {
|
|||||||
{style.label}
|
{style.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!consolidated) return statusBadge;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={4} wrap="nowrap">
|
||||||
|
{statusBadge}
|
||||||
|
<Badge
|
||||||
|
color="indigo"
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
leftSection={<Link2 size={12} />}
|
||||||
|
title={
|
||||||
|
partnerReference
|
||||||
|
? `Consolidated with ${partnerReference}`
|
||||||
|
: "Part of a consolidation"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
display: "inline-flex",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Consolidated
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function BookingDetailHeader({
|
|||||||
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
|
||||||
{booking.reference}
|
{booking.reference}
|
||||||
</Title>
|
</Title>
|
||||||
<BookingStatusBadge status={booking.status} />
|
<BookingStatusBadge
|
||||||
|
status={booking.status}
|
||||||
|
consolidated={Boolean(booking.consolidationPartnerId)}
|
||||||
|
partnerReference={booking.consolidationPartner?.reference}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
<Building2 size={14} color="var(--mantine-color-gray-5)" />
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Alert, List, Text } from "@mantine/core";
|
||||||
|
import { Link2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown on a booking parked in PENDING_CONSOLIDATION. Explains that the booking
|
||||||
|
* cannot be approved until a complementary booking fills the wagon, and lists
|
||||||
|
* the partial-wagon container lines that are waiting for a partner.
|
||||||
|
*/
|
||||||
|
export function ConsolidationWaitingBanner({ bookingId }: { bookingId: string }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: [...QUERY_KEYS.BOOKINGS.byId(bookingId), "consolidation"],
|
||||||
|
queryFn: () => bookingsService.getConsolidationDetails(bookingId),
|
||||||
|
enabled: Boolean(bookingId),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
icon={<Link2 size={18} />}
|
||||||
|
title="Waiting for a consolidation partner"
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
This booking cannot be approved until a matching booking fills the
|
||||||
|
wagon. It will return to the approval queue automatically once a partner
|
||||||
|
is found.
|
||||||
|
</Text>
|
||||||
|
{data?.wagonSlots?.length ? (
|
||||||
|
<List size="sm" mt="xs" spacing={2}>
|
||||||
|
{data.wagonSlots.map((slot) => (
|
||||||
|
<List.Item key={slot.containerTypeCode}>
|
||||||
|
{slot.slotsNeeded} more × {slot.containerTypeCode} (
|
||||||
|
{slot.containersPerWagon} per wagon; you have {slot.quantity})
|
||||||
|
</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
) : null}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FileSignature,
|
||||||
Languages,
|
Languages,
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
|
import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
import "./FreightDashboardHeader.css";
|
import "./FreightDashboardHeader.css";
|
||||||
@@ -37,6 +39,7 @@ const FreightDashboardHeader = ({
|
|||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
}: FreightDashboardHeaderProps) => {
|
}: FreightDashboardHeaderProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const initials =
|
const initials =
|
||||||
userInitials ??
|
userInitials ??
|
||||||
(userName
|
(userName
|
||||||
@@ -200,10 +203,22 @@ const FreightDashboardHeader = ({
|
|||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<User size={15} />}
|
leftSection={<User size={15} />}
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
onClick={() => {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
navigate("/dashboard/profile");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Profile
|
Profile
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<FileSignature size={15} />}
|
||||||
|
onClick={() => {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
navigate("/dashboard/profile#signature");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
My signature
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<LogOut size={15} />}
|
leftSection={<LogOut size={15} />}
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
|||||||
subtitle: "Dashboard summary and key metrics",
|
subtitle: "Dashboard summary and key metrics",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
prefix: "/dashboard/profile",
|
||||||
|
meta: {
|
||||||
|
title: "My Profile",
|
||||||
|
subtitle: "Manage your account and signature",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
prefix: "/dashboard/operations/train-scheduling-v2/",
|
prefix: "/dashboard/operations/train-scheduling-v2/",
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { FileSignature, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import {
|
||||||
|
useMySignature,
|
||||||
|
useSaveSignature,
|
||||||
|
} from "@/hooks/useSavedSignature";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the signed-in user view and update the reusable signature stored on
|
||||||
|
* their profile. The same signature is offered for approval when signing a
|
||||||
|
* booking contract.
|
||||||
|
*/
|
||||||
|
export function MySignatureCard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { data: saved, isLoading } = useMySignature();
|
||||||
|
const saveMutation = useSaveSignature();
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [signerName, setSignerName] = useState("");
|
||||||
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const defaultName =
|
||||||
|
user?.name?.en || user?.username || user?.email || "";
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
setSignerName(saved?.signerDisplayName ?? defaultName);
|
||||||
|
setSignatureData(null);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!signatureData || !signerName.trim()) return;
|
||||||
|
saveMutation.mutate(
|
||||||
|
{
|
||||||
|
signerDisplayName: signerName.trim(),
|
||||||
|
signatureImageBase64: signatureData,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setOpen(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileSignature className="size-4" />
|
||||||
|
My signature
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This signature can be reused to sign booking contracts.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-36 items-center justify-center">
|
||||||
|
<Loader2 className="size-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : saved?.signatureImageUrl ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={saved.signatureImageUrl}
|
||||||
|
alt="My saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Saved as {saved.signerDisplayName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
You have not saved a signature yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||||
|
{saved?.signatureImageUrl ? "Update signature" : "Add signature"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save your signature</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Draw your signature below. It will be stored on your profile for
|
||||||
|
future contracts.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="profileSignerName">Full name</Label>
|
||||||
|
<Input
|
||||||
|
id="profileSignerName"
|
||||||
|
value={signerName}
|
||||||
|
onChange={(e) => setSignerName(e.target.value)}
|
||||||
|
placeholder="As shown on contracts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={
|
||||||
|
saveMutation.isPending || !signatureData || !signerName.trim()
|
||||||
|
}
|
||||||
|
onClick={save}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Save signature"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -347,6 +347,10 @@ export function getBookingActions(
|
|||||||
case "CHANGES_REQUESTED":
|
case "CHANGES_REQUESTED":
|
||||||
actions = [CANCEL_ACTION];
|
actions = [CANCEL_ACTION];
|
||||||
break;
|
break;
|
||||||
|
case "PENDING_CONSOLIDATION":
|
||||||
|
// View-only while waiting for a consolidation partner; cancel still allowed.
|
||||||
|
actions = [CANCEL_ACTION];
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
actions = [];
|
actions = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
|||||||
},
|
},
|
||||||
PENDING_CONSOLIDATION: {
|
PENDING_CONSOLIDATION: {
|
||||||
title: "Pending Consolidation",
|
title: "Pending Consolidation",
|
||||||
description: "Waiting for consolidation partner.",
|
description: "Blocked — waiting for a consolidation partner before approval.",
|
||||||
color: "text-amber-600",
|
color: "text-amber-600",
|
||||||
stage: 4,
|
stage: 0,
|
||||||
},
|
},
|
||||||
CONSOLIDATED: {
|
CONSOLIDATED: {
|
||||||
title: "Consolidated",
|
title: "Consolidated",
|
||||||
description: "Paired with another booking.",
|
description: "Paired with another booking.",
|
||||||
color: "text-indigo-600",
|
color: "text-indigo-600",
|
||||||
stage: 4,
|
stage: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BOOKING_LIST_TABS = [
|
export const BOOKING_LIST_TABS = [
|
||||||
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
{ key: "all", label: "All bookings", statuses: null as string[] | null },
|
||||||
{ key: "intake", label: "Submitted", statuses: ["SUBMITTED"] },
|
{
|
||||||
|
key: "intake",
|
||||||
|
label: "Submitted",
|
||||||
|
statuses: ["SUBMITTED", "PENDING_CONSOLIDATION"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "in_approval",
|
key: "in_approval",
|
||||||
label: "In approval",
|
label: "In approval",
|
||||||
@@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [
|
|||||||
{
|
{
|
||||||
key: "operations",
|
key: "operations",
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
statuses: ["PAID", "IN_TRANSIT"],
|
||||||
},
|
},
|
||||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||||
@@ -265,7 +269,15 @@ export const BOOKING_LIST_TABS = [
|
|||||||
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
export type BookingStatusTabKey = (typeof BOOKING_LIST_TABS)[number]["key"];
|
||||||
|
|
||||||
export const WORKFLOW_STAGES = [
|
export const WORKFLOW_STAGES = [
|
||||||
{ label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] },
|
{
|
||||||
|
label: "Submission",
|
||||||
|
statuses: [
|
||||||
|
"DRAFT",
|
||||||
|
"SUBMITTED",
|
||||||
|
"CHANGES_REQUESTED",
|
||||||
|
"PENDING_CONSOLIDATION",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Approval",
|
label: "Approval",
|
||||||
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"],
|
||||||
@@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Operations",
|
label: "Operations",
|
||||||
statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
|
statuses: ["PAID", "IN_TRANSIT"],
|
||||||
},
|
},
|
||||||
{ label: "Done", statuses: ["COMPLETED"] },
|
{ label: "Done", statuses: ["COMPLETED"] },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
|||||||
trainScheduleId: booking.trainScheduleId ?? null,
|
trainScheduleId: booking.trainScheduleId ?? null,
|
||||||
isGovernment: booking.isGovernment ?? false,
|
isGovernment: booking.isGovernment ?? false,
|
||||||
governmentInstitution: booking.governmentInstitution ?? null,
|
governmentInstitution: booking.governmentInstitution ?? null,
|
||||||
|
consolidationPartnerId: booking.consolidationPartnerId ?? null,
|
||||||
|
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
|
||||||
createdAt: booking.createdAt,
|
createdAt: booking.createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
signaturesService,
|
||||||
|
type SaveSignaturePayload,
|
||||||
|
} from "@/services/signatures.service";
|
||||||
|
|
||||||
|
const SAVED_SIGNATURE_KEY = ["me", "signature"] as const;
|
||||||
|
|
||||||
|
export function useMySignature() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: SAVED_SIGNATURE_KEY,
|
||||||
|
queryFn: () => signaturesService.getMySignature(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveSignature() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: SaveSignaturePayload) =>
|
||||||
|
signaturesService.saveMySignature(payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Signature saved");
|
||||||
|
void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY });
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Failed to save signature"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ export default function BookingContractPage() {
|
|||||||
const [signOpen, setSignOpen] = useState(false);
|
const [signOpen, setSignOpen] = useState(false);
|
||||||
const [signerName, setSignerName] = useState("");
|
const [signerName, setSignerName] = useState("");
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
|
// When the user has a saved signature we offer it for approval first; they
|
||||||
|
// can switch to drawing a fresh one.
|
||||||
|
const [drawNew, setDrawNew] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
|
queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"],
|
||||||
@@ -53,6 +56,12 @@ export default function BookingContractPage() {
|
|||||||
? "STAFF"
|
? "STAFF"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const savedSignature = data?.savedSignature ?? null;
|
||||||
|
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
|
||||||
|
// Show the approval view only while a saved signature exists and the user
|
||||||
|
// hasn't opted to draw a new one.
|
||||||
|
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||||
|
|
||||||
const signMutation = useMutation({
|
const signMutation = useMutation({
|
||||||
mutationFn: (payload: SignContractPayload) =>
|
mutationFn: (payload: SignContractPayload) =>
|
||||||
bookingsService.signContract(id!, payload),
|
bookingsService.signContract(id!, payload),
|
||||||
@@ -88,16 +97,22 @@ export default function BookingContractPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openSign = () => {
|
const openSign = () => {
|
||||||
setSignerName("");
|
// Prefill from the saved signature when available so the user only has to
|
||||||
|
// approve it; otherwise start with an empty pad.
|
||||||
|
setSignerName(savedSignature?.signerDisplayName ?? "");
|
||||||
setSignatureData(null);
|
setSignatureData(null);
|
||||||
|
setDrawNew(false);
|
||||||
setSignOpen(true);
|
setSignOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmSign = () => {
|
const confirmSign = () => {
|
||||||
if (!signRole || !signatureData || !signerName.trim()) return;
|
if (!signRole || !signerName.trim()) return;
|
||||||
|
// Approve the saved signature, or submit the freshly drawn one.
|
||||||
|
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||||
|
if (!image) return;
|
||||||
signMutation.mutate({
|
signMutation.mutate({
|
||||||
role: signRole,
|
role: signRole,
|
||||||
signatureImageBase64: signatureData,
|
signatureImageBase64: image,
|
||||||
signerDisplayName: signerName.trim(),
|
signerDisplayName: signerName.trim(),
|
||||||
consentText: "I agree to the terms of this contract.",
|
consentText: "I agree to the terms of this contract.",
|
||||||
});
|
});
|
||||||
@@ -183,7 +198,9 @@ export default function BookingContractPage() {
|
|||||||
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
|
{signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Sign to execute the contract for {data.reference}.
|
{usingSaved
|
||||||
|
? `Review your saved signature and approve it to execute the contract for ${data.reference}.`
|
||||||
|
: `Sign to execute the contract for ${data.reference}.`}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -196,7 +213,32 @@ export default function BookingContractPage() {
|
|||||||
placeholder="As shown on the contract"
|
placeholder="As shown on the contract"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ContractSignaturePad onChange={setSignatureData} />
|
{usingSaved ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Saved signature</Label>
|
||||||
|
<div className="overflow-hidden rounded-lg border-2 border-dashed border-border bg-white">
|
||||||
|
<img
|
||||||
|
src={savedSignatureImage ?? undefined}
|
||||||
|
alt="Saved signature"
|
||||||
|
className="mx-auto h-36 w-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto p-0 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawNew(true);
|
||||||
|
setSignatureData(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draw a new signature instead
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContractSignaturePad onChange={setSignatureData} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
<Button variant="outline" onClick={() => setSignOpen(false)}>
|
||||||
@@ -205,13 +247,15 @@ export default function BookingContractPage() {
|
|||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
signMutation.isPending ||
|
signMutation.isPending ||
|
||||||
!signatureData ||
|
(!usingSaved && !signatureData) ||
|
||||||
!signerName.trim()
|
!signerName.trim()
|
||||||
}
|
}
|
||||||
onClick={confirmSign}
|
onClick={confirmSign}
|
||||||
>
|
>
|
||||||
{signMutation.isPending ? (
|
{signMutation.isPending ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : usingSaved ? (
|
||||||
|
"Approve & sign"
|
||||||
) : (
|
) : (
|
||||||
"Confirm signature"
|
"Confirm signature"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
|
|||||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||||
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
|
||||||
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
|
||||||
|
import { ConsolidationWaitingBanner } from "@/components/bookings/detail/ConsolidationWaitingBanner";
|
||||||
import {
|
import {
|
||||||
detailStyles,
|
detailStyles,
|
||||||
BookingRequestHero,
|
BookingRequestHero,
|
||||||
@@ -128,6 +129,10 @@ export default function BookingRequestDetailPage() {
|
|||||||
titleColor={statusMeta.color}
|
titleColor={statusMeta.color}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{booking.status === "PENDING_CONSOLIDATION" && (
|
||||||
|
<ConsolidationWaitingBanner bookingId={booking.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Grid gutter="lg">
|
<Grid gutter="lg">
|
||||||
{/* LEFT — primary content */}
|
{/* LEFT — primary content */}
|
||||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
|
|||||||
@@ -227,7 +227,11 @@ export default function BookingRequestsPage() {
|
|||||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<BookingStatusBadge status={row.original.status} />
|
<BookingStatusBadge
|
||||||
|
status={row.original.status}
|
||||||
|
consolidated={Boolean(row.original.consolidationPartnerId)}
|
||||||
|
partnerReference={row.original.consolidationPartnerReference}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { MySignatureCard } from "@/components/profile/MySignatureCard";
|
||||||
|
|
||||||
|
export default function MyProfilePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-2xl space-y-6 p-4">
|
||||||
|
<div id="signature">
|
||||||
|
<MySignatureCard />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,6 +80,26 @@ export interface ContractView {
|
|||||||
signedAt: string;
|
signedAt: string;
|
||||||
signatureImageUrl?: string | null;
|
signatureImageUrl?: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
/** Current viewer's reusable saved signature, if they have one. */
|
||||||
|
savedSignature?: {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsolidationWagonSlot {
|
||||||
|
containerTypeCode: string;
|
||||||
|
quantity: number;
|
||||||
|
containersPerWagon: number;
|
||||||
|
remainder: number;
|
||||||
|
slotsNeeded: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsolidationDetails {
|
||||||
|
statusMessage: string;
|
||||||
|
wagonSlots: ConsolidationWagonSlot[];
|
||||||
|
partner: { id: string; reference: string } | null;
|
||||||
|
splitBilling: { bookingShare: number; partnerShare: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignContractPayload {
|
export interface SignContractPayload {
|
||||||
@@ -193,6 +213,13 @@ export const bookingsService = {
|
|||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getConsolidationDetails: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<ConsolidationDetails> => {
|
||||||
|
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
||||||
|
return unwrap(response.data) as ConsolidationDetails;
|
||||||
|
},
|
||||||
|
|
||||||
customerSign: (id: string, payload: SignContractPayload) =>
|
customerSign: (id: string, payload: SignContractPayload) =>
|
||||||
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
|
postBooking<BookingDetail>(B.CUSTOMER_SIGN(id), {
|
||||||
...payload,
|
...payload,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { api as client } from "../auth/http";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
|
||||||
|
const SIGNATURE_URL = "/me/signature";
|
||||||
|
|
||||||
|
export interface SavedSignature {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveSignaturePayload {
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageBase64: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signaturesService = {
|
||||||
|
/** Returns the current user's reusable signature, or null if none saved. */
|
||||||
|
getMySignature: async (): Promise<SavedSignature | null> => {
|
||||||
|
const response = await client.get<SavedSignature | null>(SIGNATURE_URL);
|
||||||
|
return (unwrap(response.data) as SavedSignature | null) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveMySignature: async (
|
||||||
|
payload: SaveSignaturePayload,
|
||||||
|
): Promise<SavedSignature | null> => {
|
||||||
|
const response = await client.put<SavedSignature | null>(
|
||||||
|
SIGNATURE_URL,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return (unwrap(response.data) as SavedSignature | null) ?? null;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -101,6 +101,8 @@ export interface BookingDetail {
|
|||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
allowConsolidation: boolean;
|
allowConsolidation: boolean;
|
||||||
|
consolidationPartnerId?: string | null;
|
||||||
|
consolidationPartner?: BookingNamedRef & { reference?: string } | null;
|
||||||
priorityScore: number;
|
priorityScore: number;
|
||||||
schedulingStatus?: string;
|
schedulingStatus?: string;
|
||||||
holdExpiresAt?: string | null;
|
holdExpiresAt?: string | null;
|
||||||
@@ -157,5 +159,7 @@ export interface BookingListRow {
|
|||||||
trainScheduleId?: string | null;
|
trainScheduleId?: string | null;
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
governmentInstitution?: string | null;
|
governmentInstitution?: string | null;
|
||||||
|
consolidationPartnerId?: string | null;
|
||||||
|
consolidationPartnerReference?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -122,6 +122,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
typeorm:
|
||||||
|
specifier: ^0.3.30
|
||||||
|
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@edr/eslint-config':
|
'@edr/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -177,9 +180,6 @@ importers:
|
|||||||
tsconfig-paths:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
typeorm:
|
|
||||||
specifier: ^0.3.30
|
|
||||||
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -14463,7 +14463,7 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
|
||||||
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
'@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)':
|
||||||
@@ -14494,19 +14494,6 @@ snapshots:
|
|||||||
class-transformer: 0.5.1
|
class-transformer: 0.5.1
|
||||||
class-validator: 0.14.4
|
class-validator: 0.14.4
|
||||||
|
|
||||||
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
|
||||||
dependencies:
|
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
|
||||||
iterare: 1.2.1
|
|
||||||
reflect-metadata: 0.2.2
|
|
||||||
rxjs: 7.8.2
|
|
||||||
tslib: 2.8.1
|
|
||||||
optionalDependencies:
|
|
||||||
amqp-connection-manager: 5.0.0(amqplib@0.10.9)
|
|
||||||
amqplib: 0.10.9
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -14591,7 +14578,7 @@ snapshots:
|
|||||||
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
'@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)
|
||||||
|
|
||||||
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
|
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)':
|
||||||
@@ -18670,12 +18657,6 @@ snapshots:
|
|||||||
amqplib: 0.10.9
|
amqplib: 0.10.9
|
||||||
promise-breaker: 6.0.0
|
promise-breaker: 6.0.0
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@0.10.9):
|
|
||||||
dependencies:
|
|
||||||
amqplib: 0.10.9
|
|
||||||
promise-breaker: 6.0.0
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
amqp-connection-manager@5.0.0(amqplib@2.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
amqplib: 2.0.1
|
amqplib: 2.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user