diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 36d20799e..32237b507 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -46,7 +46,9 @@ "pg": "^8.13.0", "puppeteer": "^24.2.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "typeorm": "^0.3.30" + }, "devDependencies": { "@edr/api-common": "workspace:*", @@ -69,7 +71,6 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typeorm": "^0.3.30", "typescript": "^5.5.4" }, "jest": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 6dd250283..30b25e013 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { SignaturesModule } from "./modules/signatures/signatures.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -82,6 +83,7 @@ import { OverviewModule } from './modules/overview/overview.module'; permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + SignaturesModule, FilesModule, ConsignmentsModule, LocomotivesModule, diff --git a/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts b/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts new file mode 100644 index 000000000..e0c36f67b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1784000000000-CreateSavedSignatures.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSavedSignatures1784000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.saved_signatures;`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index c64b6221b..6601ca704 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -23,6 +23,7 @@ import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { SignaturesService } from '../signatures/signatures.service'; @Injectable() export class BookingContractService { @@ -38,6 +39,7 @@ export class BookingContractService { private readonly pdfService: ContractPdfService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly signaturesService: SignaturesService, ) {} buildContractSummary(booking: Booking): string { @@ -75,10 +77,16 @@ export class BookingContractService { return { summary }; } - async getContractView(bookingId: string): Promise { + async getContractView( + bookingId: string, + viewerUserId?: string, + ): Promise { const { view } = await this.viewModelBuilder.build(bookingId); await this.inlineSignatureImages(view.signatures); const html = this.renderer.render(view); + const savedSignature = viewerUserId + ? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined) + : undefined; return { bookingId: view.bookingId, reference: view.reference, @@ -90,6 +98,7 @@ export class BookingContractService { canSignStaff: view.canSignStaff, hasContractDocument: view.hasContractDocument, signatures: view.signatures, + savedSignature, pricingSchedule: view.pricing as unknown as Record, }; } @@ -194,6 +203,23 @@ export class BookingContractService { 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 = {}; if (role === 'CUSTOMER') { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts index 332916727..2140a7688 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ statuses: readonly string[] | null; }> = [ { key: 'all', statuses: null }, - { key: 'intake', statuses: ['SUBMITTED'] }, + { key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] }, { key: 'in_approval', statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], @@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{ { key: 'payment', statuses: ['FULLY_EXECUTED'] }, { key: 'operations', - statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'], + statuses: ['IN_TRANSIT', 'PAID'], }, { key: 'completed', statuses: ['COMPLETED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 9974b807a..b2c3ffbfb 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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 { assertCanApproveBookingStep } from '../../common/freight-permission.util'; @@ -184,6 +190,16 @@ export class BookingTransitionService { const booking = await this.bookingsService.findById(bookingId); 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, { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 77d60b439..ba9fffb66 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -342,8 +342,12 @@ export class BookingsController { @Get(':id/contract/view') @ApiOkResponse({ type: ContractViewDto }) @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) - getContractView(@Param('id', ParseUUIDPipe) id: string) { - return this.contractService.getContractView(id); + getContractView( + @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') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index cb5d87714..f55a5a0f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { SignaturesModule } from '../signatures/signatures.module'; import { BookingContractService } from './booking-contract.service'; import { BookingPaymentService } from './booking-payment.service'; import { BookingPricingService } from './booking-pricing.service'; @@ -50,6 +51,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; CompaniesModule, // CustomersModule, RuleEngineModule, + SignaturesModule, ], controllers: [BookingsController, PayController], providers: [ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 3e95b917d..d304e2946 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -93,6 +93,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.rateSnapshots', 'snapshots') .leftJoinAndSelect('booking.cargoModifiers', 'modifiers') .leftJoinAndSelect('booking.reviewNotes', 'reviewNotes') + .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.id = :id', { id }) .leftJoinAndMapMany( 'booking.files', @@ -177,7 +178,7 @@ export class BookingsRepository extends BaseRepository { .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') .andWhere('b.status IN (:...statuses)', { - statuses: ['DRAFT', 'PENDING_CONSOLIDATION'], + statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'], }) .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, @@ -214,15 +215,27 @@ export class BookingsRepository extends BaseRepository { 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 { await this.repository.update(bookingId, { consolidationPartnerId: partnerId, - status: 'CONSOLIDATED', + status: 'SUBMITTED', } as never); await this.repository.update(partnerId, { consolidationPartnerId: bookingId, - status: 'CONSOLIDATED', + status: 'SUBMITTED', + } as never); + } + + /** Park a booking that needs consolidation but has no partner yet. */ + async parkForConsolidation(bookingId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + status: 'PENDING_CONSOLIDATION', } as never); } @@ -429,6 +442,7 @@ export class BookingsRepository extends BaseRepository { .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .leftJoinAndSelect('booking.serviceType', 'serviceType') .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d1c135152..ebf8273de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -204,6 +204,48 @@ export class BookingsService { 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. */ async create( dto: CreateBookingDto, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts index 4af9e535d..919652af6 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -14,6 +14,14 @@ export class ContractSignatureDto { signatureImageUrl?: string | null; } +export class SavedSignatureViewDto { + @ApiProperty() + signerDisplayName!: string; + + @ApiPropertyOptional() + signatureImageUrl?: string | null; +} + export class ContractViewDto { @ApiProperty() bookingId!: string; @@ -45,6 +53,9 @@ export class ContractViewDto { @ApiProperty({ type: [ContractSignatureDto] }) signatures!: ContractSignatureDto[]; + @ApiPropertyOptional({ type: SavedSignatureViewDto }) + savedSignature?: SavedSignatureViewDto; + @ApiPropertyOptional() pricingSchedule?: Record; } diff --git a/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts new file mode 100644 index 000000000..a5ae63100 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/dto/save-signature.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts new file mode 100644 index 000000000..08263cf7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/entities/saved-signature.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts new file mode 100644 index 000000000..d112edef3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.controller.ts @@ -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 { + 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 { + 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); + } +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.module.ts b/apps/edr-freight-api/src/modules/signatures/signatures.module.ts new file mode 100644 index 000000000..32292d995 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts new file mode 100644 index 000000000..70c04ad7b --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.repository.ts @@ -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 { + constructor( + @InjectRepository(SavedSignature) + repo: Repository, + ) { + super(repo); + } + + findByUserId(userId: string): Promise { + return this.repository.findOne({ + where: { userId } as never, + relations: ['signatureFile'], + }); + } + + /** Insert or update the single saved signature for a user. */ + async upsert(data: Partial): Promise { + 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)); + } +} diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts new file mode 100644 index 000000000..6ff10f7d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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))); + }); + } +} diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 742d15278..cfe22845d 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -28,6 +28,7 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import OverviewPage from "./pages/dashboard/OverviewPage"; +import MyProfilePage from "./pages/dashboard/MyProfilePage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; @@ -257,6 +258,7 @@ const App = () => { } /> }> } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 3d8a0792a..466bfe505 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -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"; const statusColorMap: Record = { @@ -24,14 +25,26 @@ const statusColorMap: Record = { 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] ?? { label: status, color: "gray", }; const color = statusColorMap[status] ?? "gray"; - return ( + const statusBadge = ( ); + + if (!consolidated) return statusBadge; + + return ( + + {statusBadge} + } + title={ + partnerReference + ? `Consolidated with ${partnerReference}` + : "Part of a consolidation" + } + style={{ + fontSize: "0.7rem", + letterSpacing: "0.05em", + display: "inline-flex", + whiteSpace: "nowrap", + }} + > + Consolidated + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDetailHeader.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDetailHeader.tsx index aa4e00b19..e03f4580e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDetailHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingDetailHeader.tsx @@ -36,7 +36,11 @@ export function BookingDetailHeader({ {booking.reference} - + diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationWaitingBanner.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationWaitingBanner.tsx new file mode 100644 index 000000000..5e95ec7ef --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ConsolidationWaitingBanner.tsx @@ -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 ( + } + title="Waiting for a consolidation partner" + > + + 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. + + {data?.wagonSlots?.length ? ( + + {data.wagonSlots.map((slot) => ( + + {slot.slotsNeeded} more × {slot.containerTypeCode} ( + {slot.containersPerWagon} per wagon; you have {slot.quantity}) + + ))} + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx index cc6454f60..cc8074850 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/FreightDashboardHeader.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; import { Bell, ChevronDown, + FileSignature, Languages, LogOut, MessageSquare, @@ -10,6 +11,7 @@ import { User, } from "lucide-react"; import { Group, Stack, Text, Menu, Tooltip, Box } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; import type { PageMeta } from "./types"; import "./FreightDashboardHeader.css"; @@ -37,6 +39,7 @@ const FreightDashboardHeader = ({ theme, onToggleTheme, }: FreightDashboardHeaderProps) => { + const navigate = useNavigate(); const initials = userInitials ?? (userName @@ -200,10 +203,22 @@ const FreightDashboardHeader = ({ } - onClick={() => setIsUserMenuOpen(false)} + onClick={() => { + setIsUserMenuOpen(false); + navigate("/dashboard/profile"); + }} > Profile + } + onClick={() => { + setIsUserMenuOpen(false); + navigate("/dashboard/profile#signature"); + }} + > + My signature + } color="red" diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index bc57dcdeb..3c421090a 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -36,6 +36,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ 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/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx new file mode 100644 index 000000000..3ac661e1a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/MySignatureCard.tsx @@ -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(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 ( + + + + + My signature + + + This signature can be reused to sign booking contracts. + + + + {isLoading ? ( +
+ +
+ ) : saved?.signatureImageUrl ? ( +
+
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+
+ ) : ( +

+ You have not saved a signature yet. +

+ )} + +
+ + + + + Save your signature + + Draw your signature below. It will be stored on your profile for + future contracts. + + +
+
+ + setSignerName(e.target.value)} + placeholder="As shown on contracts" + /> +
+ +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index bbc01b402..5aeeabbd7 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -347,6 +347,10 @@ export function getBookingActions( case "CHANGES_REQUESTED": actions = [CANCEL_ACTION]; break; + case "PENDING_CONSOLIDATION": + // View-only while waiting for a consolidation partner; cancel still allowed. + actions = [CANCEL_ACTION]; + break; default: actions = []; } diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 834dcee3e..213cb7c2e 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -212,21 +212,25 @@ export const BOOKING_STATUS_META: Record = { }, PENDING_CONSOLIDATION: { title: "Pending Consolidation", - description: "Waiting for consolidation partner.", + description: "Blocked — waiting for a consolidation partner before approval.", color: "text-amber-600", - stage: 4, + stage: 0, }, CONSOLIDATED: { title: "Consolidated", description: "Paired with another booking.", color: "text-indigo-600", - stage: 4, + stage: 0, }, }; export const BOOKING_LIST_TABS = [ { 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", label: "In approval", @@ -256,7 +260,7 @@ export const BOOKING_LIST_TABS = [ { key: "operations", label: "Operations", - statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"], + statuses: ["PAID", "IN_TRANSIT"], }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { 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 const WORKFLOW_STAGES = [ - { label: "Submission", statuses: ["DRAFT", "SUBMITTED", "CHANGES_REQUESTED"] }, + { + label: "Submission", + statuses: [ + "DRAFT", + "SUBMITTED", + "CHANGES_REQUESTED", + "PENDING_CONSOLIDATION", + ], + }, { label: "Approval", statuses: ["PENDING_APPROVAL", "APPROVED_PENDING_SIGNATURE"], @@ -286,7 +298,7 @@ export const WORKFLOW_STAGES = [ }, { label: "Operations", - statuses: ["PAID", "IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"], + statuses: ["PAID", "IN_TRANSIT"], }, { label: "Done", statuses: ["COMPLETED"] }, ] as const; diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts index f458b11e6..7f250daeb 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/mapBookingListRow.ts @@ -42,6 +42,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow { trainScheduleId: booking.trainScheduleId ?? null, isGovernment: booking.isGovernment ?? false, governmentInstitution: booking.governmentInstitution ?? null, + consolidationPartnerId: booking.consolidationPartnerId ?? null, + consolidationPartnerReference: booking.consolidationPartner?.reference ?? null, createdAt: booking.createdAt, }; } diff --git a/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts new file mode 100644 index 000000000..b8c9480a7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useSavedSignature.ts @@ -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"), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 7c22f9d45..4689d28fa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -40,6 +40,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(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({ queryKey: [...QUERY_KEYS.BOOKINGS.byId(id ?? ""), "contract-view"], @@ -53,6 +56,12 @@ export default function BookingContractPage() { ? "STAFF" : 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({ mutationFn: (payload: SignContractPayload) => bookingsService.signContract(id!, payload), @@ -88,16 +97,22 @@ export default function BookingContractPage() { }; 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); + setDrawNew(false); setSignOpen(true); }; 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({ role: signRole, - signatureImageBase64: signatureData, + signatureImageBase64: image, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", }); @@ -183,7 +198,9 @@ export default function BookingContractPage() { {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - 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}.`}
@@ -196,7 +213,32 @@ export default function BookingContractPage() { placeholder="As shown on the contract" />
- + {usingSaved ? ( +
+ +
+ Saved signature +
+ +
+ ) : ( + + )}