Merge pull request #1138 from Tria-plc/lastmilerequest

Last mile Contract
This commit is contained in:
Hagernesh Tadesse
2026-08-06 10:21:30 +03:00
committed by GitHub
20 changed files with 1241 additions and 30 deletions

View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Last-Mile Delivery Contract — {{bookingReference}}</title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #1a1a1a; margin: 0; font-size: 12px; line-height: 1.5; }
main { padding: 32px 40px; }
.brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; }
.logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; }
.kicker { margin: 0; font-weight: 700; }
.muted { margin: 0; color: #666; }
h1 { font-size: 20px; margin: 24px 0 4px; }
h2 { font-size: 14px; margin: 26px 0 8px; color: #1a5632; border-bottom: 1px solid #d8d8d8; padding-bottom: 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #d8d8d8; padding: 6px 10px; text-align: left; vertical-align: top; }
th { background: #f2f6f3; width: 32%; font-weight: 600; }
.rates th { width: auto; }
.rates .amount { text-align: right; white-space: nowrap; }
.rates tfoot td { font-weight: 700; background: #f2f6f3; }
.terms p { margin: 6px 0; }
.sig-grid { display: flex; gap: 24px; margin-top: 18px; }
.sig-card { flex: 1; border: 1px solid #d8d8d8; border-radius: 6px; padding: 14px; min-height: 130px; }
.sig-card h3 { margin: 0 0 8px; font-size: 12px; color: #1a5632; }
.sig-card img { max-height: 60px; max-width: 100%; }
.sig-line { border-top: 1px solid #999; margin-top: 40px; padding-top: 4px; color: #666; }
.consent { margin-top: 6px; font-style: italic; color: #444; }
.pending { color: #999; font-style: italic; }
</style>
</head>
<body>
<main>
<div class="brand-row">
<div class="logo-mark">EDR</div>
<div>
<p class="kicker">Ethio-Djibouti Standard Gauge Railway Share Company</p>
<p class="muted">Last-Mile Delivery Contract</p>
</div>
</div>
<h1>Last-Mile Delivery Contract</h1>
<p class="muted">Booking {{bookingReference}}{{companyName}}</p>
<h2>Shipment Details</h2>
<table>
<tr><th>Client</th><td>{{companyName}}</td></tr>
<tr><th>Booking Reference</th><td>{{bookingReference}}</td></tr>
{{#if containerCount}}
<tr><th>Number of Containers</th><td>{{containerCount}}</td></tr>
<tr><th>Containers</th><td>{{containerList}}</td></tr>
{{/if}}
{{#if cargoDescription}}
<tr><th>Cargo Description</th><td>{{cargoDescription}}</td></tr>
{{/if}}
{{#if deliveryAddress}}
<tr><th>Delivery Address</th><td>{{deliveryAddress}}</td></tr>
{{/if}}
{{#if trainDepartureDate}}
<tr><th>Train Departure from Djibouti</th><td>{{trainDepartureDate}}</td></tr>
{{/if}}
<tr><th>Last-Mile Delivery Date</th><td>{{deliveryDate}}</td></tr>
<tr><th>Request Date</th><td>{{requestDate}}</td></tr>
<tr><th>Approval Date</th><td>{{approvalDate}}</td></tr>
</table>
<h2>Rates</h2>
<table class="rates">
<thead>
<tr><th>Description</th><th class="amount">Amount{{#if currency}} ({{currency}}){{/if}}</th></tr>
</thead>
<tbody>
{{#each rateLines}}
<tr><td>{{description}}</td><td class="amount">{{amount}}</td></tr>
{{/each}}
{{#if estimatedKm}}
<tr><td class="muted">Estimated distance</td><td class="amount">{{estimatedKm}} km</td></tr>
{{/if}}
</tbody>
<tfoot>
<tr><td>Advance payable on signing</td><td class="amount">{{advanceAmount}}{{#if currency}} {{currency}}{{/if}}</td></tr>
</tfoot>
</table>
<h2>Terms</h2>
<div class="terms">
<p>1. The Service Provider shall deliver the goods identified above from the arrival yard to the Client's delivery address on or about the last-mile delivery date stated above.</p>
<p>2. The Client shall pay the advance stated above upon signing this contract. The final delivery fee is computed on completion per the Service Provider's published last-mile rates and actual distance.</p>
<p>3. The Client shall ensure access and receipt of the goods at the delivery address. Waiting time and truck detention beyond free time may incur additional charges per the applicable tariff.</p>
<p>4. This contract is governed by the laws applicable to the Ethio-Djibouti Standard Gauge Railway Share Company's freight services.</p>
</div>
<h2>Signatures</h2>
<div class="sig-grid">
<div class="sig-card">
<h3>Client</h3>
{{#if signature}}
<img src="{{signature.imageUrl}}" alt="Customer signature" />
<div class="sig-line">{{signature.signerDisplayName}} — signed {{signature.signedAt}}</div>
{{#if signature.consentText}}<div class="consent">"{{signature.consentText}}"</div>{{/if}}
{{else}}
<p class="pending">Awaiting customer signature.</p>
<div class="sig-line">Name, signature &amp; date</div>
{{/if}}
</div>
<div class="sig-card">
<h3>Service Provider</h3>
<p>Ethio-Djibouti Standard Gauge Railway Share Company</p>
<div class="sig-line">Authorized representative</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Last-mile contract fields on freight.last_mile_requests: the customer-chosen
* delivery date, the chief-approved advance amount (held until the invoice is
* generated at signing time), the rate snapshot rendered into the contract,
* and the customer signature bookkeeping. The signed PDF and signature image
* live in freight.files (resource 'last_mile_requests').
*/
export class LastMileRequestContract3290000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
ADD COLUMN IF NOT EXISTS requested_delivery_date date,
ADD COLUMN IF NOT EXISTS approved_advance_amount numeric(14,2),
ADD COLUMN IF NOT EXISTS contract_summary jsonb,
ADD COLUMN IF NOT EXISTS contract_generated_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_signed_at timestamptz,
ADD COLUMN IF NOT EXISTS signer_display_name varchar(160),
ADD COLUMN IF NOT EXISTS consent_text text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_requests
DROP COLUMN IF EXISTS requested_delivery_date,
DROP COLUMN IF EXISTS approved_advance_amount,
DROP COLUMN IF EXISTS contract_summary,
DROP COLUMN IF EXISTS contract_generated_at,
DROP COLUMN IF EXISTS customer_signed_at,
DROP COLUMN IF EXISTS signer_display_name,
DROP COLUMN IF EXISTS consent_text
`);
}
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class SignLastMileContractDto {
@ApiPropertyOptional({
description:
'Signature PNG as base64 (data URI or raw). Omitted = reuse the saved profile signature.',
})
@IsOptional()
@IsString()
signatureImageBase64?: string;
@ApiProperty({ description: 'Name shown under the signature.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
signerDisplayName!: string;
@ApiPropertyOptional({ description: 'The consent statement the customer agreed to.' })
@IsOptional()
@IsString()
@MaxLength(500)
consentText?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsDateString, IsString } from 'class-validator';
export class SubmitLastMileRequestDto {
@ApiProperty({
@@ -12,4 +12,12 @@ export class SubmitLastMileRequestDto {
@ArrayUnique()
@IsString({ each: true })
containerNumbers!: string[];
@ApiProperty({
description:
'Requested last-mile delivery date (ISO date), chosen by the customer against the train departure from Djibouti.',
example: '2026-08-15',
})
@IsDateString()
deliveryDate!: string;
}

View File

@@ -65,6 +65,47 @@ export class LastMileRequest extends BaseEntity {
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
resultingLastMileId?: string | null;
/** Customer-chosen last-mile delivery date (guided by the train's Djibouti departure). */
@Column({ name: 'requested_delivery_date', type: 'date', nullable: true })
requestedDeliveryDate?: string | null;
/** Chief-approved advance — invoiced only after the customer signs the LM contract. */
@Column({
name: 'approved_advance_amount',
type: 'numeric',
precision: 14,
scale: 2,
nullable: true,
transformer: {
to: (v?: number | null) => v,
from: (v?: string | null) => (v == null ? null : Number(v)),
},
})
approvedAdvanceAmount?: number | null;
/** Rate snapshot taken at approval, rendered into the contract document. */
@Column({ name: 'contract_summary', type: 'jsonb', nullable: true })
contractSummary?: {
estimatedKm: number | null;
mode: string | null;
currency: string | null;
total: number | null;
lines: Array<{ description: string; amount: number }>;
advanceAmount: number;
} | null;
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
@Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true })
customerSignedAt?: Date | null;
@Column({ name: 'signer_display_name', type: 'varchar', length: 160, nullable: true })
signerDisplayName?: string | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;
@ManyToOne(() => LastMile, { nullable: true, eager: false })
@JoinColumn({ name: 'resulting_last_mile_id' })
resultingLastMile?: LastMile | null;

View File

@@ -0,0 +1,318 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import Handlebars from 'handlebars';
import { Readable } from 'stream';
import { DataSource } from 'typeorm';
import { LastMileRequestStatus } from '@edr/types';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsService } from '../bookings/bookings.service';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { MinioService } from '../minio/minio.service';
import { SignaturesService } from '../signatures/signatures.service';
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
import { LastMileRequestsService } from './last-mile-requests.service';
const FILE_RESOURCE = 'last_mile_requests';
/**
* The LM contract in front of the advance payment: generated when the chief
* approves the request, viewed and signed by the customer in the portal, and
* only then invoiced (LastMileRequestsService.generateAdvanceInvoice). Single
* signer (customer), so the signature lives on the request row itself — no
* signature-rows table like bookings/CRSP contracts need for multi-role.
*/
@Injectable()
export class LastMileContractService {
private readonly logger = new Logger(LastMileContractService.name);
private compiledTemplate: Handlebars.TemplateDelegate | null = null;
constructor(
private readonly requestsRepository: LastMileRequestsRepository,
private readonly requestsService: LastMileRequestsService,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly pdfService: ContractPdfService,
private readonly signaturesService: SignaturesService,
private readonly dataSource: DataSource,
) {}
async getContractView(id: string, viewerUserId?: string | null) {
const request = await this.requireApprovedRequest(id);
const booking = await this.requireBooking(request);
const view = await this.buildViewModel(request, booking);
const html = this.render(view);
const savedSignature = viewerUserId
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
: undefined;
return {
requestId: request.id,
bookingId: request.bookingId,
bookingReference: booking.reference,
status: request.status,
html,
customerSignedAt: request.customerSignedAt ?? null,
signerDisplayName: request.signerDisplayName ?? null,
canSign: !request.customerSignedAt,
savedSignature,
};
}
async streamContract(id: string) {
const request = await this.requireApprovedRequest(id);
const booking = await this.requireBooking(request);
const record = await this.upsertContractPdf(request, booking);
return this.filesService.streamById(record.id);
}
async sign(
id: string,
dto: SignLastMileContractDto,
signerUserId: string | null,
): Promise<LastMileRequest> {
const request = await this.requireApprovedRequest(id);
if (request.customerSignedAt) {
throw new BadRequestException('This last-mile contract is already signed');
}
const booking = await this.requireBooking(request);
if (signerUserId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(signerUserId);
if (companyId && booking.companyId && companyId !== booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
// Drawn signature wins; otherwise fall back to the saved profile signature
// (same contract-signing convention as modules/contracts).
let imageBase64 = dto.signatureImageBase64;
if (!imageBase64 && signerUserId) {
const saved = await this.signaturesService.getForUser(signerUserId);
if (saved?.signatureImageUrl?.startsWith('data:')) {
imageBase64 = saved.signatureImageUrl;
}
}
if (!imageBase64) {
throw new BadRequestException(
'No signature image provided and no saved signature on your profile',
);
}
const buffer = this.decodeSignatureImage(imageBase64);
const sigFile = this.toUploadFile(
`signature-customer-${booking.reference ?? request.id}.png`,
'image/png',
buffer,
);
const fileRecord = await this.filesService.upsertByCode({
resourceId: request.id,
resource: FILE_RESOURCE,
code: 'signature_customer',
file: sigFile,
});
await this.requestsRepository.update(id, {
customerSignedAt: new Date(),
signerDisplayName: dto.signerDisplayName,
consentText: dto.consentText ?? null,
} as Partial<LastMileRequest>);
// Best-effort: keep the reusable profile signature fresh for next time.
if (signerUserId && dto.signatureImageBase64) {
try {
await this.signaturesService.upsertForUser({
userId: signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(`Could not save reusable signature for user ${signerUserId}: ${err}`);
}
}
const signed = (await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
}))!;
// Render + store the signed PDF, then invoice the advance. PDF failure must
// not block the invoice — the document re-renders on view/download.
try {
await this.upsertContractPdf(signed, booking, fileRecord);
} catch (err) {
this.logger.warn(
`Signed LM contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
);
}
await this.requestsService.generateAdvanceInvoice(signed);
return signed;
}
private async upsertContractPdf(
request: LastMileRequest,
booking: Booking,
signatureRecord?: FileRecord,
): Promise<FileRecord> {
const view = await this.buildViewModel(request, booking, signatureRecord);
const html = this.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const companyName = booking.company?.name ?? 'Customer';
const fileName = `LM_${companyName.replace(/[^A-Za-z0-9._-]+/g, '_')}.pdf`;
const file = this.toUploadFile(fileName, 'application/pdf', pdfBuffer);
return this.filesService.upsertByCode({
resourceId: request.id,
resource: FILE_RESOURCE,
code: 'contract',
file,
});
}
private async buildViewModel(
request: LastMileRequest,
booking: Booking,
signatureRecord?: FileRecord,
) {
const summary = request.contractSummary;
const containers = request.requestedContainerNumbers ?? [];
const cargoDescription =
booking.cargoFreeText || booking.cargoType?.cargoTypeName || null;
const departedRows: Array<{ departedAt: Date | null }> = await this.dataSource.query(
`SELECT departed_from_djibouti_at AS "departedAt"
FROM freight.import_djibouti_operations
WHERE train_schedule_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[request.trainScheduleId],
);
return {
companyName: booking.company?.name ?? 'Customer',
bookingReference: booking.reference ?? request.bookingId,
containerCount: containers.length || null,
containerList: containers.join(', '),
cargoDescription,
deliveryAddress: booking.lastMileDeliveryAddress ?? null,
trainDepartureDate: this.formatDate(departedRows[0]?.departedAt),
deliveryDate: this.formatDate(request.requestedDeliveryDate) ?? '—',
requestDate: this.formatDate(request.submittedAt ?? request.reminderSentAt ?? request.createdAt) ?? '—',
approvalDate: this.formatDate(request.reviewedAt) ?? '—',
currency: summary?.currency ?? booking.paymentCurrency ?? 'ETB',
rateLines: (summary?.lines ?? []).map((l) => ({
description: l.description,
amount: this.formatAmount(l.amount),
})),
estimatedKm: summary?.estimatedKm ?? null,
advanceAmount: this.formatAmount(
summary?.advanceAmount ?? request.approvedAdvanceAmount ?? 0,
),
signature: request.customerSignedAt
? {
signerDisplayName: request.signerDisplayName ?? '',
signedAt: this.formatDate(request.customerSignedAt) ?? '',
consentText: request.consentText ?? null,
imageUrl: await this.signatureImageDataUri(request, signatureRecord),
}
: null,
};
}
/** Signature PNG as a data URI so the PDF renderer needs no MinIO access. */
private async signatureImageDataUri(
request: LastMileRequest,
signatureRecord?: FileRecord,
): Promise<string | null> {
try {
const record =
signatureRecord ??
(await this.filesService.findByCode(request.id, FILE_RESOURCE, 'signature_customer'));
if (!record.url) return null;
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
return `data:image/png;base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
private render(view: Record<string, unknown>): string {
if (!this.compiledTemplate) {
const source = fs.readFileSync(
path.join(__dirname, '..', '..', 'contracts', 'templates', 'last-mile.hbs'),
'utf-8',
);
this.compiledTemplate = Handlebars.compile(source);
}
return this.compiledTemplate(view);
}
private async requireApprovedRequest(id: string): Promise<LastMileRequest> {
const request = await this.requestsService.findById(id);
if (request.status !== LastMileRequestStatus.Approved) {
throw new BadRequestException(
`The last-mile contract is available once the request is approved (current status: ${request.status})`,
);
}
return request;
}
private async requireBooking(request: LastMileRequest): Promise<Booking> {
const booking = await this.dataSource.manager.findOne(Booking, {
where: { id: request.bookingId },
relations: { company: true, cargoType: true },
});
if (!booking) throw new BadRequestException(`Booking ${request.bookingId} not found`);
return booking;
}
private formatDate(value?: Date | string | null): string | null {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
private formatAmount(value: number): string {
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
private toUploadFile(name: string, mimetype: string, buffer: Buffer): Express.Multer.File {
return {
fieldname: 'file',
originalname: name,
encoding: '7bit',
mimetype,
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
}
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)));
});
}
}

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query, Res } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
@@ -9,14 +10,19 @@ import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
import { SignLastMileContractDto } from './dto/sign-last-mile-contract.dto';
import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
import { LastMileContractService } from './last-mile-contract.service';
import { LastMileRequestsService } from './last-mile-requests.service';
@ApiTags('last-mile-requests')
@ApiBearerAuth()
@Controller('last-mile-requests')
export class LastMileRequestsController {
constructor(private readonly requestsService: LastMileRequestsService) {}
constructor(
private readonly requestsService: LastMileRequestsService,
private readonly contractService: LastMileContractService,
) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@@ -52,6 +58,36 @@ export class LastMileRequestsController {
return this.requestsService.priceEstimate(id);
}
// Customer-facing like :id/submit — the service ownership-checks against the
// resolved company; staff may also open it (read-only view).
@Get(':id/contract/view')
@ApiOperation({ summary: 'LM contract view model + rendered HTML + saved signature' })
contractView(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
return this.contractService.getContractView(id, user?.id ?? null);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download the LM contract PDF (LM_<CustomerName>.pdf)' })
async contractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${record.name}"`);
stream.pipe(res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Customer agrees and signs the LM contract — then the advance invoice is issued' })
signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignLastMileContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.contractService.sign(id, dto, user?.id ?? null);
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
@@ -69,12 +105,12 @@ export class LastMileRequestsController {
@Body() dto: SubmitLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers);
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers, dto.deliveryDate);
}
@Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' })
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — LM contract becomes signable; the advance invoice follows the customer signature' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto,

View File

@@ -1,11 +1,16 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { FilesModule } from '../files/files.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { MinioModule } from '../minio/minio.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileContractService } from './last-mile-contract.service';
import { LastMileRequestsController } from './last-mile-requests.controller';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
import { LastMileRequestsService } from './last-mile-requests.service';
@@ -17,9 +22,17 @@ import { LastMileRequestsService } from './last-mile-requests.service';
forwardRef(() => BookingsModule),
LastMileModule,
NotificationInboxModule,
FilesModule,
MinioModule,
SignaturesModule,
],
controllers: [LastMileRequestsController],
providers: [LastMileRequestsRepository, LastMileRequestsService],
providers: [
LastMileRequestsRepository,
LastMileRequestsService,
LastMileContractService,
ContractPdfService,
],
exports: [LastMileRequestsService],
})
export class LastMileRequestsModule {}

View File

@@ -252,7 +252,12 @@ export class LastMileRequestsService {
});
}
async submit(id: string, userId: string | null, containerNumbers: string[]): Promise<LastMileRequest> {
async submit(
id: string,
userId: string | null,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
@@ -274,6 +279,7 @@ export class LastMileRequestsService {
await this.requestsRepository.update(id, {
requestedContainerNumbers: selected,
requestedDeliveryDate: deliveryDate,
status: LastMileRequestStatus.Submitted,
submittedByUserId: userId,
submittedAt: new Date(),
@@ -308,12 +314,53 @@ export class LastMileRequestsService {
advancedPayment: 0,
});
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
// once the customer has signed the LM contract — doc first, then payment.
// Snapshot the rate estimate now so the contract shows the numbers the
// chief actually approved against, immune to later rate edits.
const estimate = await this.priceEstimate(id);
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
resultingLastMileId: lastMile.id,
approvedAdvanceAmount: advanceAmount,
contractSummary: { ...estimate, advanceAmount },
contractGeneratedAt: new Date(),
} as Partial<LastMileRequest>);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.CONTRACT_STATUS,
title: 'Last-mile contract ready — view and sign',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`,
link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`,
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
/** The advance invoice, deferred from approve() until the LM contract is signed. */
async generateAdvanceInvoice(request: LastMileRequest): Promise<void> {
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
const advanceAmount = request.approvedAdvanceAmount;
if (!advanceAmount || !request.resultingLastMileId) {
throw new BadRequestException('Request has no approved advance to invoice');
}
await this.billing.generateInvoice({
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
// match the existing source string LastMileInvoiceService/LastMileService
// already query by (findBySourceIds/findPayable/attachInvoices).
source: 'last_mile' as Freight.InvoiceSource,
sourceId: lastMile.id,
sourceId: request.resultingLastMileId,
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
@@ -328,27 +375,18 @@ export class LastMileRequestsService {
totalAmount: advanceAmount,
});
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
resultingLastMileId: lastMile.id,
} as Partial<LastMileRequest>);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'Last-mile request approved — payment due',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`,
title: 'Last-mile contract signed — payment due',
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
link: '/billing/invoices',
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {

View File

@@ -94,6 +94,20 @@ export function LastMileRequestsPanel() {
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
const downloadContract = async (r: LastMileRequest) => {
try {
const { data: blob } = await lastMileRequestsService.contractDocument(r.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `LM_${r.booking?.company?.name?.replace(/[^A-Za-z0-9._-]+/g, "_") ?? r.id}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast({ title: "Contract PDF not available", variant: "destructive" });
}
};
const approve = useMutation({
mutationFn: () =>
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
@@ -141,8 +155,16 @@ export function LastMileRequestsPanel() {
id: "containers",
header: () => <span>Requested Containers</span>,
cell: ({ row }) => {
const nums = row.original.requestedContainerNumbers;
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
const r = row.original;
const nums = r.requestedContainerNumbers;
return (
<Stack gap={0}>
<Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>
{r.requestedDeliveryDate && (
<Text size="xs" c="dimmed">Delivery: {r.requestedDeliveryDate}</Text>
)}
</Stack>
);
},
},
{
@@ -162,6 +184,24 @@ export function LastMileRequestsPanel() {
);
},
},
{
id: "contract",
header: () => <span>LM Contract</span>,
cell: ({ row }) => {
const r = row.original;
if (r.status !== "APPROVED") return <Text size="sm" c="dimmed"></Text>;
return (
<Stack gap={4} align="flex-start">
<Badge color={r.customerSignedAt ? "green" : "yellow"} variant="light" size="sm">
{r.customerSignedAt ? "Signed" : "Awaiting signature"}
</Badge>
<Button size="compact-xs" variant="subtle" onClick={() => void downloadContract(r)}>
Download PDF
</Button>
</Stack>
);
},
},
...(canApprove
? [
{

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, Plus, Trash2 } from "lucide-react";
import {
ActionIcon,
Modal,
Button,
TextInput,
@@ -41,6 +42,37 @@ type FormRow =
| { kind: "pair"; fields: [FormFieldDef, FormFieldDef] }
| { kind: "single"; field: FormFieldDef };
/** One editable distance tier of a tierList field (raw input strings). */
type TierRow = { minKm: string; maxKm: string; rateValue: string };
const emptyTier = (fromKm = ""): TierRow => ({ minKm: fromKm, maxKm: "", rateValue: "" });
/**
* Validate a tier set before submit: every tier complete, ranges sane, no
* overlaps, and only the last tier open-ended. Returns the error message, or
* null when the set is valid.
*/
const validateTiers = (rows: TierRow[]): string | null => {
if (!rows.length) return "Add at least one tier.";
for (const row of rows) {
if (row.minKm === "" || row.rateValue === "") {
return "Every tier needs a From km and a Rate value.";
}
if (row.maxKm !== "" && Number(row.maxKm) <= Number(row.minKm)) {
return "Each tier's To km must be greater than its From km.";
}
}
const sorted = [...rows].sort((a, b) => Number(a.minKm) - Number(b.minKm));
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1];
if (prev.maxKm === "") return "Only the last tier can leave To km empty.";
if (Number(sorted[i].minKm) < Number(prev.maxKm)) {
return `Tiers overlap around ${sorted[i].minKm} km — each distance must fall in exactly one tier.`;
}
}
return null;
};
const isShortField = (field: FormFieldDef) =>
field.type === "text" ||
field.type === "number" ||
@@ -54,7 +86,7 @@ const buildFormRows = (fields: FormFieldDef[]): FormRow[] => {
while (index < fields.length) {
const field = fields[index];
if (field.type === "textarea" || field.type === "boolean") {
if (field.type === "textarea" || field.type === "boolean" || field.type === "tierList") {
rows.push({ kind: "single", field });
index += 1;
continue;
@@ -85,6 +117,8 @@ const buildInitialValues = (
: record?.[field.name];
if (field.type === "multiselect") {
values[field.name] = Array.isArray(raw) ? raw.map(String) : [];
} else if (field.type === "tierList") {
values[field.name] = [emptyTier("0")];
} else if (raw !== undefined && raw !== null) {
if (field.type === "date" && typeof raw === "string") {
values[field.name] = raw.slice(0, 10);
@@ -241,6 +275,19 @@ const RuleEngineFormDialog = ({
if (field.type === "multiselect") {
// Always the full replacement list — the API syncs the relation to it.
payload[field.name] = Array.isArray(raw) ? raw : [];
} else if (field.type === "tierList") {
const rows = (Array.isArray(raw) ? raw : []) as TierRow[];
const error = validateTiers(rows);
if (error) {
setFieldErrors((current) => ({ ...current, [field.name]: error }));
blocked = true;
} else {
payload[field.name] = rows.map((row) => ({
minKm: Number(row.minKm),
maxKm: row.maxKm === "" ? null : Number(row.maxKm),
rateValue: Number(row.rateValue),
}));
}
} else if (field.type === "number") {
if (raw === "" || raw === undefined) continue;
payload[field.name] = Number(raw);
@@ -313,6 +360,101 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "tierList") {
const rows = Array.isArray(values[field.name])
? (values[field.name] as TierRow[])
: [];
const setRows = (next: TierRow[]) => setField(field.name, next);
const setRow = (index: number, key: keyof TierRow, value: string) => {
if (value.trim().startsWith("-")) return;
setRows(rows.map((row, i) => (i === index ? { ...row, [key]: value } : row)));
};
return (
<Box key={field.name}>
<Text size="sm" fw={600} mb={2} c="var(--mantine-color-gray-8)">
{label}
</Text>
{field.description ? (
<Text size="xs" c="dimmed" mb={8}>
{field.description}
</Text>
) : null}
<Stack gap="xs">
{rows.map((row, index) => (
<Group key={index} gap="xs" wrap="nowrap" align="flex-end">
<TextInput
label={index === 0 ? "From km" : undefined}
type="number"
min={0}
step="any"
placeholder="0"
value={row.minKm}
onChange={(e) => setRow(index, "minKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "To km" : undefined}
type="number"
min={0}
step="any"
placeholder="No limit"
value={row.maxKm}
onChange={(e) => setRow(index, "maxKm", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<TextInput
label={index === 0 ? "Rate value" : undefined}
type="number"
min={0}
step="any"
placeholder="Rate per km"
value={row.rateValue}
onChange={(e) => setRow(index, "rateValue", e.currentTarget.value)}
size="md"
radius="md"
styles={inputStyles}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
size="lg"
mb={2}
aria-label="Remove tier"
disabled={rows.length === 1}
onClick={() => setRows(rows.filter((_, i) => i !== index))}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Group justify="flex-start">
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
// The next tier naturally starts where the previous one ends.
onClick={() => setRows([...rows, emptyTier(rows[rows.length - 1]?.maxKm ?? "")])}
>
Add tier
</Button>
</Group>
{fieldErrors[field.name] ? (
<Text size="xs" c="red">
{fieldErrors[field.name]}
</Text>
) : null}
</Stack>
</Box>
);
}
if (field.type === "multiselect") {
const options = field.optionsFromValues
? field.optionsFromValues(values)

View File

@@ -714,6 +714,7 @@ export const URL_CONSTANTS = {
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
},
DRIVERS: {

View File

@@ -306,7 +306,34 @@ const RuleEngineResourcePage = () => {
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) => {
// Last-mile container bands: creating uses the multi-row tier list (one
// rate per tier); editing an existing band row keeps the single
// From/To/value fields (a rate row IS one band).
const bandFields = config.formFields.filter((field) => {
if (config.slug !== "rates") return true;
if (field.type === "tierList") return !editing;
if (editing) return true;
return field.name !== "minKm" && field.name !== "maxKm";
});
return bandFields.map((field) => {
// On create, the tier rows carry the per-band rate values — the single
// last-mile "Rate value" field then only applies to bulk mode.
if (
config.slug === "rates" &&
!editing &&
field.name === "rateValue" &&
field.showWhen?.field === "appliesTo" &&
field.showWhen.equals.includes("LAST_MILE")
) {
return {
...field,
showWhen: undefined,
showIf: (values: Record<string, unknown>) =>
values.appliesTo === "LAST_MILE" && values.lastMileMode === "BULK",
};
}
return field;
}).map((field) => {
if (isPriorityRules && field.name === "minWagonCount") {
return {
...field,
@@ -624,6 +651,31 @@ const RuleEngineResourcePage = () => {
);
return;
}
// Container-mode create: the tier list becomes one rate row per tier,
// created sequentially so an overlap/duplicate rejection stops the batch
// with its own toast instead of half-failing in parallel.
const tiers = (
payload as {
tiers?: Array<{ minKm: number; maxKm: number | null; rateValue: number }>;
}
).tiers;
if (!editing?.id && Array.isArray(tiers)) {
const { tiers: _omitted, ...base } = payload as Record<string, unknown>;
void _omitted;
void (async () => {
try {
for (const tier of tiers) {
await create.mutateAsync({ ...base, ...tier });
}
setFormOpen(false);
setEditing(null);
} catch {
// The create mutation already toasted the failure; keep the dialog
// open so the admin can fix the tier set and retry.
}
})();
return;
}
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };

View File

@@ -17,7 +17,7 @@ export type ColumnFormat =
| "entityLabel"
| "rateLabel";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "email" | "select" | "multiselect" | "textarea" | "radio" | "tierList";
export interface ResourceColumn {
id: string;
@@ -1022,6 +1022,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showWhen: { field: "appliesTo", equals: ["LAST_MILE"] },
getInitialValue: (record) => String(record.currency ?? "ETB"),
},
// ── Distance tiers (create only — the page swaps this for the single
// From/To/value fields when editing an existing band row). Each tier
// becomes its own rate row, so every band keeps edit/delete/approval. ──
{
name: "tiers",
label: "Distance tiers",
type: "tierList",
required: true,
description:
"One rate per distance range. To km is exclusive (030 then 30+); leave the last tier's To km empty for no upper limit.",
showIf: (v) =>
v.appliesTo === "LAST_MILE" && v.lastMileMode === "CONTAINER",
},
// ── Container type — Container freight, container-kind intercity, and
// the empty-container return surcharge (20ft vs 40ft price differently) ─
{

View File

@@ -28,6 +28,9 @@ export interface LastMileRequest {
reviewedAt?: string | null;
rejectionReason?: string | null;
resultingLastMileId?: string | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
signerDisplayName?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -58,4 +61,6 @@ export const lastMileRequestsService = {
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
reject: (id: string, reason: string) =>
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
contractDocument: (id: string) =>
api.get<Blob>(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }),
};

View File

@@ -47,6 +47,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
import LastMileContractPage from "./pages/bookings/last-mile-contract/LastMileContractPage";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractsList from "./pages/contracts/ContractsList";
@@ -323,6 +324,10 @@ const App = () => {
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/last-mile-contract"
element={<LastMileContractPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}

View File

@@ -221,5 +221,8 @@ export const URL_CONSTANTS = {
LAST_MILE_REQUESTS: {
BY_ID: (id: string) => `/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
CONTRACT_VIEW: (id: string) => `/last-mile-requests/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/last-mile-requests/${id}/contract/sign`,
},
};

View File

@@ -1,4 +1,5 @@
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
@@ -31,6 +32,7 @@ export default function LastMileConfirmPage() {
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const [deliveryDate, setDeliveryDate] = useState<string | null>(null);
const {
data: request,
@@ -51,7 +53,7 @@ export default function LastMileConfirmPage() {
const containerNumbers = booking?.containerNumbers ?? [];
const submitMutation = useMutation({
mutationFn: () => lastMileRequestsService.submit(requestId!, selected),
mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!),
onSuccess: () => {
toast.success("Last-mile confirmation submitted");
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
@@ -101,6 +103,23 @@ export default function LastMileConfirmPage() {
Reason: {request.rejectionReason}
</Text>
)}
{request.status === "APPROVED" && (
<>
<Text mt={8} c="dimmed" fz="sm">
{request.customerSignedAt
? "You have signed the last-mile contract."
: "Review and sign the last-mile contract to receive your advance invoice."}
</Text>
<Button
mt={16}
onClick={() =>
navigate(`/bookings/${id}/last-mile-contract?requestId=${requestId}`)
}
>
{request.customerSignedAt ? "View LM contract" : "View and Sign LM"}
</Button>
</>
)}
</SectionCard>
</PageShell>
);
@@ -146,9 +165,24 @@ export default function LastMileConfirmPage() {
))}
</Stack>
<DatePickerInput
mt={16}
label="Delivery date"
description="When should we deliver? Pick a date after the train departs Djibouti."
placeholder="Pick the last-mile delivery date"
value={deliveryDate}
onChange={(date) =>
setDeliveryDate(
date ? new Date(date).toISOString().slice(0, 10) : null,
)
}
minDate={new Date()}
required
/>
<Button
mt={20}
disabled={selected.length === 0}
disabled={selected.length === 0 || !deliveryDate}
loading={submitMutation.isPending}
onClick={() => submitMutation.mutate()}
>

View File

@@ -0,0 +1,240 @@
import { useCallback, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Download, FileSignature, Loader2, Printer } from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import {
lastMileRequestsService,
type SignLastMileContractPayload,
} from "@/services/last-mile-requests.service";
import { Button } from "@edr/ui-common";
const CONSENT_TEXT = "I agree to the terms of this last-mile delivery contract.";
export default function LastMileContractPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [agreed, setAgreed] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// When a saved signature exists we offer it for approval first; the customer
// can switch to drawing a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["last-mile-contract-view", requestId],
queryFn: () => lastMileRequestsService.getContractView(requestId!),
enabled: Boolean(requestId),
});
const savedSignature = data?.savedSignature ?? null;
const savedSignatureImage = savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const openSign = () => {
setSignerName(savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setAgreed(false);
setSignOpen(true);
};
const signMutation = useMutation({
mutationFn: (payload: SignLastMileContractPayload) =>
lastMileRequestsService.signContract(requestId!, payload),
onSuccess: () => {
toast.success("Contract signed — your advance invoice is ready");
setSignOpen(false);
void refetch();
qc.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
navigate("/billing");
},
onError: () => toast.error("Failed to sign contract"),
});
const confirmSign = () => {
if (!signerName.trim() || !agreed) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate({
signatureImageBase64: image,
signerDisplayName: signerName.trim(),
consentText: CONSENT_TEXT,
});
};
const downloadPdf = useCallback(async () => {
if (!requestId) return;
try {
const blob = await lastMileRequestsService.downloadContractDocument(requestId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `LM_${data?.bookingReference ?? requestId}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("PDF not ready yet. Contact EDR if this persists.");
}
}, [requestId, data?.bookingReference]);
const handlePrint = useCallback(() => {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.print();
}
}, []);
if (!requestId) {
return (
<div className="p-8">
<p className="text-muted-foreground">Missing request id.</p>
</div>
);
}
if (isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}
if (isError || !data) {
return (
<div className="p-8">
<p className="text-muted-foreground">
Could not load the last-mile contract. It becomes available once your
request is approved.
</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>
Go back
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="mx-auto max-w-4xl">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 print:hidden">
<Button variant="ghost" size="sm" onClick={() => navigate(`/bookings/${id}`)}>
<ArrowLeft className="mr-2 size-4" />
Back to booking
</Button>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="mr-2 size-4" />
Print
</Button>
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="mr-2 size-4" />
PDF
</Button>
{data.canSign && (
<Button size="sm" onClick={openSign}>
<FileSignature className="mr-2 size-4" />
{usingSaved ? "Approve & sign" : "Sign contract"}
</Button>
)}
</div>
</div>
{data.customerSignedAt && (
<p className="mb-3 text-sm text-muted-foreground print:hidden">
Signed by {data.signerDisplayName} on{" "}
{new Date(data.customerSignedAt).toLocaleDateString()}.
</p>
)}
<iframe
ref={iframeRef}
srcDoc={data.html}
className="w-full rounded-lg border bg-white shadow-sm"
style={{ minHeight: "80vh" }}
title="Last-mile contract document"
/>
</div>
{signOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 print:hidden">
<div className="w-full max-w-md rounded-xl bg-background p-6 shadow-xl">
<h2 className="text-lg font-semibold">
{usingSaved ? "Approve signature" : "Sign contract"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Booking {data.bookingReference ?? data.bookingId} signing issues
your advance invoice.
</p>
<div className="mt-4 space-y-3">
<label className="text-sm font-medium" htmlFor="lmSigner">
Full name
</label>
<input
id="lmSigner"
className="w-full rounded-md border px-3 py-2 text-sm"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
/>
{usingSaved ? (
<div className="space-y-2">
<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"
className="text-xs text-primary underline"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</button>
</div>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-0.5"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>{CONSENT_TEXT}</span>
</label>
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
disabled={
signMutation.isPending ||
(!usingSaved && !signatureData) ||
!agreed ||
!signerName.trim()
}
onClick={confirmSign}
>
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -10,11 +10,35 @@ export interface LastMileRequest {
trainScheduleId: string;
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
requestedContainerNumbers?: string[] | null;
requestedDeliveryDate?: string | null;
customerSignedAt?: string | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
}
export interface LastMileContractView {
requestId: string;
bookingId: string;
bookingReference?: string | null;
status: LastMileRequest["status"];
html: string;
customerSignedAt: string | null;
signerDisplayName: string | null;
canSign: boolean;
savedSignature?: {
signerDisplayName: string;
signatureImageUrl: string | null;
stampImageUrl: string | null;
};
}
export interface SignLastMileContractPayload {
signatureImageBase64?: string;
signerDisplayName: string;
consentText?: string;
}
export const lastMileRequestsService = {
/** One last-mile confirmation request, by id. */
get: async (id: string): Promise<LastMileRequest> => {
@@ -22,12 +46,36 @@ export const lastMileRequestsService = {
return data.data ?? data;
},
/** Confirm which containers on the booking go via EDR last-mile. */
/** Confirm which containers go via EDR last-mile and the requested delivery date. */
submit: async (
id: string,
containerNumbers: string[],
deliveryDate: string,
): Promise<LastMileRequest> => {
const { data } = await client.post(L.SUBMIT(id), { containerNumbers });
const { data } = await client.post(L.SUBMIT(id), { containerNumbers, deliveryDate });
return data.data ?? data;
},
/** LM contract view model + rendered HTML + saved signature. */
getContractView: async (id: string): Promise<LastMileContractView> => {
const { data } = await client.get(L.CONTRACT_VIEW(id));
return data.data ?? data;
},
/** Agree & sign the LM contract — the advance invoice is issued right after. */
signContract: async (
id: string,
payload: SignLastMileContractPayload,
): Promise<LastMileRequest> => {
const { data } = await client.post(L.CONTRACT_SIGN(id), payload);
return data.data ?? data;
},
/** The LM contract PDF (LM_<CustomerName>.pdf). */
downloadContractDocument: async (id: string): Promise<Blob> => {
const { data } = await client.get(L.CONTRACT_DOCUMENT(id), {
responseType: "blob",
});
return data;
},
};