mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
feat(last-mile): customer-signed LM contract gates the advance invoice
Approval now snapshots the rate estimate and generates a last-mile contract instead of invoicing immediately. The customer picks a delivery date on the confirm form, then reviews and signs the contract in the portal (saved signature or drawn); the signed PDF is stored as LM_<CustomerName>.pdf and only then is the advance invoice issued. Backoffice shows signature status and the contract download.
This commit is contained in:
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal file
114
apps/edr-freight-api/src/contracts/templates/last-mile.hbs
Normal 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 & 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||||
@@ -9,14 +10,19 @@ import { BookingStaff } from '../../common/booking-guards';
|
|||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
|
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
|
||||||
import { RejectLastMileRequestDto } from './dto/reject-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 { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
|
||||||
|
import { LastMileContractService } from './last-mile-contract.service';
|
||||||
import { LastMileRequestsService } from './last-mile-requests.service';
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||||
|
|
||||||
@ApiTags('last-mile-requests')
|
@ApiTags('last-mile-requests')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('last-mile-requests')
|
@Controller('last-mile-requests')
|
||||||
export class LastMileRequestsController {
|
export class LastMileRequestsController {
|
||||||
constructor(private readonly requestsService: LastMileRequestsService) {}
|
constructor(
|
||||||
|
private readonly requestsService: LastMileRequestsService,
|
||||||
|
private readonly contractService: LastMileContractService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
@@ -52,6 +58,36 @@ export class LastMileRequestsController {
|
|||||||
return this.requestsService.priceEstimate(id);
|
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')
|
@Get(':id')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||||
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
||||||
@@ -69,12 +105,12 @@ export class LastMileRequestsController {
|
|||||||
@Body() dto: SubmitLastMileRequestDto,
|
@Body() dto: SubmitLastMileRequestDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@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')
|
@Post(':id/approve')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
|
@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(
|
approve(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: ApproveLastMileRequestDto,
|
@Body() dto: ApproveLastMileRequestDto,
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { LastMileRequest } from './entities/last-mile-request.entity';
|
import { LastMileRequest } from './entities/last-mile-request.entity';
|
||||||
|
import { LastMileContractService } from './last-mile-contract.service';
|
||||||
import { LastMileRequestsController } from './last-mile-requests.controller';
|
import { LastMileRequestsController } from './last-mile-requests.controller';
|
||||||
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
import { LastMileRequestsRepository } from './last-mile-requests.repository';
|
||||||
import { LastMileRequestsService } from './last-mile-requests.service';
|
import { LastMileRequestsService } from './last-mile-requests.service';
|
||||||
@@ -17,9 +22,17 @@ import { LastMileRequestsService } from './last-mile-requests.service';
|
|||||||
forwardRef(() => BookingsModule),
|
forwardRef(() => BookingsModule),
|
||||||
LastMileModule,
|
LastMileModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
|
FilesModule,
|
||||||
|
MinioModule,
|
||||||
|
SignaturesModule,
|
||||||
],
|
],
|
||||||
controllers: [LastMileRequestsController],
|
controllers: [LastMileRequestsController],
|
||||||
providers: [LastMileRequestsRepository, LastMileRequestsService],
|
providers: [
|
||||||
|
LastMileRequestsRepository,
|
||||||
|
LastMileRequestsService,
|
||||||
|
LastMileContractService,
|
||||||
|
ContractPdfService,
|
||||||
|
],
|
||||||
exports: [LastMileRequestsService],
|
exports: [LastMileRequestsService],
|
||||||
})
|
})
|
||||||
export class LastMileRequestsModule {}
|
export class LastMileRequestsModule {}
|
||||||
|
|||||||
@@ -314,12 +314,53 @@ export class LastMileRequestsService {
|
|||||||
advancedPayment: 0,
|
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({
|
await this.billing.generateInvoice({
|
||||||
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
|
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
|
||||||
// match the existing source string LastMileInvoiceService/LastMileService
|
// match the existing source string LastMileInvoiceService/LastMileService
|
||||||
// already query by (findBySourceIds/findPayable/attachInvoices).
|
// already query by (findBySourceIds/findPayable/attachInvoices).
|
||||||
source: 'last_mile' as Freight.InvoiceSource,
|
source: 'last_mile' as Freight.InvoiceSource,
|
||||||
sourceId: lastMile.id,
|
sourceId: request.resultingLastMileId,
|
||||||
type: 'LAST_MILE_ADVANCE',
|
type: 'LAST_MILE_ADVANCE',
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId || '',
|
companyProfileId: booking.companyProfileId || '',
|
||||||
@@ -334,27 +375,18 @@ export class LastMileRequestsService {
|
|||||||
totalAmount: advanceAmount,
|
totalAmount: advanceAmount,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.requestsRepository.update(id, {
|
|
||||||
status: LastMileRequestStatus.Approved,
|
|
||||||
reviewedByStaffId: staffId,
|
|
||||||
reviewedAt: new Date(),
|
|
||||||
resultingLastMileId: lastMile.id,
|
|
||||||
} as Partial<LastMileRequest>);
|
|
||||||
|
|
||||||
if (booking.companyId) {
|
if (booking.companyId) {
|
||||||
void this.notifications.notify({
|
void this.notifications.notify({
|
||||||
recipients: { companyId: booking.companyId },
|
recipients: { companyId: booking.companyId },
|
||||||
audience: NotificationAudience.PORTAL,
|
audience: NotificationAudience.PORTAL,
|
||||||
type: NotificationType.INVOICE_ISSUED,
|
type: NotificationType.INVOICE_ISSUED,
|
||||||
title: 'Last-mile request approved — payment due',
|
title: 'Last-mile contract signed — payment due',
|
||||||
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`,
|
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
|
||||||
link: '/billing/invoices',
|
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,
|
priority: NotificationPriority.HIGH,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findById(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
|
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
|
||||||
|
|||||||
@@ -94,6 +94,20 @@ export function LastMileRequestsPanel() {
|
|||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
|
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({
|
const approve = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
|
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
|
||||||
@@ -141,8 +155,16 @@ export function LastMileRequestsPanel() {
|
|||||||
id: "containers",
|
id: "containers",
|
||||||
header: () => <span>Requested Containers</span>,
|
header: () => <span>Requested Containers</span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const nums = row.original.requestedContainerNumbers;
|
const r = row.original;
|
||||||
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
|
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
|
...(canApprove
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -714,6 +714,7 @@ export const URL_CONSTANTS = {
|
|||||||
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
PRICE_ESTIMATE: (id: string) => `/last-mile-requests/${id}/price-estimate`,
|
||||||
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
|
||||||
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
|
||||||
|
CONTRACT_DOCUMENT: (id: string) => `/last-mile-requests/${id}/contract/document`,
|
||||||
},
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface LastMileRequest {
|
|||||||
reviewedAt?: string | null;
|
reviewedAt?: string | null;
|
||||||
rejectionReason?: string | null;
|
rejectionReason?: string | null;
|
||||||
resultingLastMileId?: string | null;
|
resultingLastMileId?: string | null;
|
||||||
|
requestedDeliveryDate?: string | null;
|
||||||
|
customerSignedAt?: string | null;
|
||||||
|
signerDisplayName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -58,4 +61,6 @@ export const lastMileRequestsService = {
|
|||||||
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
|
||||||
reject: (id: string, reason: string) =>
|
reject: (id: string, reason: string) =>
|
||||||
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
|
||||||
|
contractDocument: (id: string) =>
|
||||||
|
api.get<Blob>(LMR.CONTRACT_DOCUMENT(id), { responseType: 'blob' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
|||||||
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
|
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 ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractsList from "./pages/contracts/ContractsList";
|
import ContractsList from "./pages/contracts/ContractsList";
|
||||||
@@ -323,6 +324,10 @@ const App = () => {
|
|||||||
path="/bookings/:id/last-mile-confirm"
|
path="/bookings/:id/last-mile-confirm"
|
||||||
element={<LastMileConfirmPage />}
|
element={<LastMileConfirmPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bookings/:id/last-mile-contract"
|
||||||
|
element={<LastMileContractPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bookings/:id/contract"
|
path="/bookings/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
|
|||||||
@@ -221,5 +221,8 @@ export const URL_CONSTANTS = {
|
|||||||
LAST_MILE_REQUESTS: {
|
LAST_MILE_REQUESTS: {
|
||||||
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
BY_ID: (id: string) => `/last-mile-requests/${id}`,
|
||||||
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
|
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`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
|
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
@@ -31,6 +32,7 @@ export default function LastMileConfirmPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
|
const [deliveryDate, setDeliveryDate] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: request,
|
data: request,
|
||||||
@@ -51,7 +53,7 @@ export default function LastMileConfirmPage() {
|
|||||||
const containerNumbers = booking?.containerNumbers ?? [];
|
const containerNumbers = booking?.containerNumbers ?? [];
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: () => lastMileRequestsService.submit(requestId!, selected),
|
mutationFn: () => lastMileRequestsService.submit(requestId!, selected, deliveryDate!),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Last-mile confirmation submitted");
|
toast.success("Last-mile confirmation submitted");
|
||||||
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
|
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
|
||||||
@@ -101,6 +103,23 @@ export default function LastMileConfirmPage() {
|
|||||||
Reason: {request.rejectionReason}
|
Reason: {request.rejectionReason}
|
||||||
</Text>
|
</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>
|
</SectionCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
@@ -146,9 +165,24 @@ export default function LastMileConfirmPage() {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</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
|
<Button
|
||||||
mt={20}
|
mt={20}
|
||||||
disabled={selected.length === 0}
|
disabled={selected.length === 0 || !deliveryDate}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
onClick={() => submitMutation.mutate()}
|
onClick={() => submitMutation.mutate()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,11 +10,35 @@ export interface LastMileRequest {
|
|||||||
trainScheduleId: string;
|
trainScheduleId: string;
|
||||||
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
|
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
|
||||||
requestedContainerNumbers?: string[] | null;
|
requestedContainerNumbers?: string[] | null;
|
||||||
|
requestedDeliveryDate?: string | null;
|
||||||
|
customerSignedAt?: string | null;
|
||||||
rejectionReason?: string | null;
|
rejectionReason?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: 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 = {
|
export const lastMileRequestsService = {
|
||||||
/** One last-mile confirmation request, by id. */
|
/** One last-mile confirmation request, by id. */
|
||||||
get: async (id: string): Promise<LastMileRequest> => {
|
get: async (id: string): Promise<LastMileRequest> => {
|
||||||
@@ -22,12 +46,36 @@ export const lastMileRequestsService = {
|
|||||||
return data.data ?? data;
|
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 (
|
submit: async (
|
||||||
id: string,
|
id: string,
|
||||||
containerNumbers: string[],
|
containerNumbers: string[],
|
||||||
|
deliveryDate: string,
|
||||||
): Promise<LastMileRequest> => {
|
): 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;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user